函數式編程看React Hooks(二)事件綁定副作用深度剖析

函數式編程看React Hooks(一)簡單React Hooks實現

函數式編程看React Hooks(二)事件綁定副作用深度剖析

本教程不講解 React Hooks 的源碼,只用最簡單的方式來揭示 React Hooks 的原理和思想。 (我希望你看本文時,已經看過了上面一篇文章,因爲本文會基於你已經瞭解部分 hooks 本質的前提下而展開的。例如你懂得 hooks 維護的狀態其實是一個由閉包提供的。)

本文通過一個最近遇到了一個關於 React Hooks 的坑來展開講解。一步一步地揭示如何更好地去理解 hooks,去了解函數式的魅力。

緣起

示例:https://codesandbox.io/s/brave-meadow-skl0o

function App() {
  const [count, setCount] = useState(0);
  const [isTag, setTag] = useState(false);

  const onMouseMove = e => {
    if (!isTag) {
      return;
    }
    setCount(count + 1);
  };

  const onMouseUp = e => {
    setTag(false);
  };

  const onMouseDown = e => {
    setTag(true);
  };

  useEffect(() => {
    document.addEventListener("mousemove", onMouseMove);
    document.addEventListener("mouseup", onMouseUp);
    return () => {
      document.removeEventListener("mousemove", onMouseMove);
      document.removeEventListener("mouseup", onMouseUp);
    };
  }, []);

  return (
    <div className="App">
      <h1 onMouseDown={onMouseDown}>hello world</h1>
      <h2>{count}</h2>
    </div>
  );
}

對於一些事件綁定複雜的邏輯,我之前是這麼寫的,爲了演示效果,去除了一些複雜的業務邏輯。

可以看到在這個示例中,我們的 count 始終爲 0。這是爲什麼呢?是 setCount 出問題了?百思不得其解,在我們寫 class 類式編程時,這是一個很常見的編程習慣。爲什麼到了 hooks 這裏卻不行了呢?

我們需要注意的一點是,現在編寫的是函數式組件,可以說是函數式編程 (雖然不完全是,但是是這樣的味道)。函數式編程的特點就是無副作用,輸入輸出一致性。但是對於前端一些 Dom,Bom 等 API 來說,無副作用是不可能的,事件的綁定,定時器等等都,都是有副作用的。。所以,爲了處理這一部分的邏輯,React Hooks 提供了 useEffect 這個鉤子來處理。所以說,我們看到的所有一些奇奇怪怪的地方,效果和理想不一致的情況,最終原因就是這個編程模式轉變後,出現的"後遺症"。如果我們用函數式的思想來理解,這些問題都將會迎刃而解。

現在起,請你拋棄 class 模式的寫法和更新方式,我們單從函數邏輯的角度來進行講解。我們來看看,當 App 函數第一次運行時候各個值的狀態。

function App() {
  const [count -> 0, setCount] = useState(0);
  const [isTag -> false, setTag] = useState(false);

  const onMouseMove = e => {
    if (!isTag -> false) {
      return;
    }
    setCount(count -> 0 + 1);
  };

  const onMouseUp = e => {
    setTag(false);
  };

  const onMouseDown = e => {
    setTag(true);
  };

  useEffect(() => {
    document.addEventListener("mousemove", onMouseMove);
    document.addEventListener("mouseup", onMouseUp);
    return () => {
      document.removeEventListener("mousemove", onMouseMove);
      document.removeEventListener("mouseup", onMouseUp);
    };
  }, []);

  return (
    <div className="App">
      <h1 onMouseDown={onMouseDown}>hello world</h1>
      <h2>{count -> 0}</h2>
    </div>
  );
}

我們第一次渲染過程中的 document.addEventListener("mousemove", onMouseMove);

onMouseMove 的形態就是這樣的。

const onMouseMove = e => {
    if (!false) {
      return;
    }
    setCount(0 + 1);
  };

document.addEventListener("mouseup", onMouseUp);

const onMouseUp = e => {
    setTag(false);
  };

當我們鼠標點擊 hello world 後,會依次運行 onMouseDown, onMouseMove, onMouseUp 函數。

先從 onMouseDown 說起,這個時候使用 setTag 設置了 isTag 的值,設置完成後,整個 App 函數會重新運行,即重新渲染。

此時 App 內函數的狀態。(-> 此符號位標記當前的數值)

function App() {
  const [count -> 0, setCount] = useState(0);
  const [isTag -> true, setTag] = useState(false);

  const onMouseMove = e => {
    if (!isTag -> true) {
      return;
    }
    setCount(isTag -> 0 + 1);
  };

  const onMouseUp = e => {
    setTag(false);
  };

  const onMouseDown = e => {
    setTag(true);
  };

  useEffect(() => {
    document.addEventListener("mousemove", onMouseMove);
    document.addEventListener("mouseup", onMouseUp);
    return () => {
      document.removeEventListener("mousemove", onMouseMove);
      document.removeEventListener("mouseup", onMouseUp);
    };
  }, []);

  return (
    <div className="App">
      <h1 onMouseDown={onMouseDown}>hello world</h1>
      <h2>{count -> 0}</h2>
    </div>
  );
}

這個時候可以看到,新一輪渲染中的 onMouseMove 已經更新了。但是呢,document.addEventListener("mousemove", onMouseMove); 我們事件監聽綁定的事件還是原來的函數也就是以下這個形態。。

  const onMouseMove = e => {
    if (!isTag -> false) {
      return;
    }
    setCount(count -> 0 + 1);
  };

因爲,我們事件綁定一旦綁定後,函數是不會變化的。

接下來就是 onMouseUp 這個時候 將 isTag 值設置成 false。也會觸發 App 的重新運行。在 App 組件中 onMouseMove 的形態。

  const onMouseMove = e => {
    if (!isTag -> false) {
      return;
    }
    setCount(count -> 0 + 1);
  };

我這麼講,你可能有點暈。但是沒有關係,可以看圖。

event-mouse.png

我之所以花費這麼長的篇幅來講解這個 onMouseMove 實際使用中的樣子,就是想讓你明白,千萬不要被 class 的模式給誤導了。不是說 onMouseMove 更新了,事件監聽的回調函數也改變了。事件監聽中的 onMouseMove 始終是我們第一次渲染的樣子,(也就是 isTagfalse 的樣子)不會因爲後面的變化去改變。

所以 isTag 始終爲 falsesetCount 一直無法執行。

面對這個情況,我們可以很自然地想到,如果我們能夠重新綁定一下新的 onMouseMove ,那麼問題不就迎刃而解了嗎?也就是說。只要是我們在 isTag 更新的時候,重新去綁定事件監聽中的回調函數 onMouseMove,就可以解決我們的問題。

所以 React Hooks,給 useEffect 提供了第二個參數,可以放入一個依賴數組。也就是說,當我們 isTag 更新的同時也去更新事件監聽中的回調函數。

但是更新事件函數的前提是,得先解綁舊的函數,否則的話,將會重複綁定事件。因此,react 回調函數中也提供了 return 的方式,來提供解綁。。通過這樣的描述我想大家應該也能理解爲什麼需要 return 解綁函數 了。。

所以上面爲了能夠使得我們的 count 能夠正常更新的解決辦法,就是 hooks 一直說到的,添加正確的依賴很重要,不要去欺騙他。。。

初探

現在是修復後的代碼,添加正確的依賴。

function App() {
  const [count, setCount] = useState(0);
  const [isTag, setTag] = useState(false);

  const onMouseMove = e => {
    if (!isTag) {
      return;
    }
    setCount(count + 1);
  };

  const onMouseUp = e => {
    setTag(false);
  };

  const onMouseDown = e => {
    setTag(true);
  };

  useEffect(() => {
    document.addEventListener("mousemove", onMouseMove);
    document.addEventListener("mouseup", onMouseUp);
    return () => {
      document.removeEventListener("mousemove", onMouseMove);
      document.removeEventListener("mouseup", onMouseUp);
    };
  }, [isTag]);

  return (
    <div className="App">
      <h1 onMouseDown={onMouseDown}>hello world</h1>
      <h2>{count}</h2>
    </div>
  );
}

我們來看看現在事件的綁定中 回調函數的指向。每當 isTag 變化後,都會觸發回調函數的更新。使得每次我們觸發的 onMouseMove 都是最新的。

render-mouse-new.png

但是我們發現,我們點擊移動的時候,不管怎麼移動 count 只會增加 1。因爲我們在添加依賴的時候,還需要對 count 也進行觀察,因爲每次 count 值變化,我們也得去更新綁定事件。

終結

我們繼續修改

  useEffect(() => {
    document.addEventListener("mousemove", onMouseMove);
    document.addEventListener("mouseup", onMouseUp);
    return () => {
      document.removeEventListener("mousemove", onMouseMove);
      document.removeEventListener("mouseup", onMouseUp);
    };
  }, [isTag, count]);

這個時候我們發現只要我們鼠標點擊後, move 事件會不斷地觸發, count 也會不斷地增加, 從而達到了我們的目的。

那麼再來思考一個問題?每次這樣一個事件綁定我們都得去尋找依賴項。。那麼我們非常有可能忘記添加這個依賴,導致我們整個組件無法正常地運行。

幸好 react 給我提供了一個機制,那就是 依賴項 也接受函數。

  useEffect(() => {
    document.addEventListener("mousemove", onMouseMove);
    document.addEventListener("mouseup", onMouseUp);
    return () => {
      document.removeEventListener("mousemove", onMouseMove);
      document.removeEventListener("mouseup", onMouseUp);
    };
  }, [onMouseMove]);

我們嘗試一下,嗯,看似完美地解決了。但是我們會發現,哇,爲什麼重新渲染了那麼多次?還記得我們 上一篇文章中,介紹 dep 比較的原理嗎?直接對值進行的比較。也就是意味着函數對比的話,就是地址進行比較,顯然,每次創建的函數地址都是不同的。(言外之意就是,每一次的重新渲染,都會導致 onMouseMove 的重新綁定,不單單是 isTag, count 兩個值改變,每一個變量改變引起的重新渲染都會導致 onMouseMove 的更新)

那麼我們要如何解決麼?就要用到我們的 useCallback 了。用來緩存函數,在上一節中,我們也提到過實現原理。通過緩存來達到不創建新的函數。再來改造一下

  const onMouseMove = useCallback(e => {
    if (!isTag) {
      return;
    }
    setCount(count + 1);
  }, [isTag, count]);

  useEffect(() => {
    document.addEventListener("mousemove", onMouseMove);
    document.addEventListener("mouseup", onMouseUp);
    return () => {
      document.removeEventListener("mousemove", onMouseMove);
      document.removeEventListener("mouseup", onMouseUp);
    };
  }, [onMouseMove]);

示例效果:https://codesandbox.io/s/friendly-bose-2kxet

頓悟

現在我們已經完美地解決了我們的問題,並且講解了 hooks 的一些本質,爲什麼這麼做的原理?我們再打上日誌,來感受下,整個 hooks 的運行過程吧。

示例: https://codesandbox.io/s/heuristic-rhodes-gojl5

function App() {
  console.log("開始運行");
  const [count, setCount] = useState(0);
  const [isTag, setTag] = useState(false);

  const onMouseMove = useCallback(
    e => {
      if (!isTag) {
        return;
      }
      setCount(count + 1);
    },
    [isTag, count]
  );

  const onMouseUp = e => {
    console.log("up");
    setTag(false);
  };

  const onMouseDown = e => {
    console.log("down");
    setTag(true);
  };

  useEffect(() => {
    console.log("綁定事件");
    document.addEventListener("mousemove", onMouseMove);
    document.addEventListener("mouseup", onMouseUp);
    return () => {
      console.log("解綁事件");
      document.removeEventListener("mousemove", onMouseMove);
      document.removeEventListener("mouseup", onMouseUp);
    };
  }, [onMouseMove]);

  console.log("一輪結束");

  return (
    <div className="App">
      <h1 onMouseDown={onMouseDown}>hello world</h1>
      <h2>{count}</h2>
    </div>
  );
}

1570364096642.jpg

緣滅

此番 React Hooks 的探究到此結束。如有任何疑問或者改進,請評論區轟炸。

注意事項

  1. 一定要添加觀察依賴,否則 useEffect 中的函數都會執行一次,如果簡單邏輯可能是無察覺的,但是如果是大量的邏輯以及事件綁定,會非常消耗資源。
  2. 一定要添加正確的依賴。否則也會出現性能問題。

自己的一點點小的看法:

1.在某種程度上用性能來換取函數式編程的規範(雖然官方說這樣處理的性能幾乎不可計,我的意思是從寫出差代碼的概率,因爲不是所有人都對 hooks 原理了如指掌。因此寫出問題的依賴的概率非常大。)現在的解決方式是儘可能地添加 React Hooks 的 ESlint eslint-plugin-react-hooks

2.非常佩服 react 團隊的創造力,能想出這樣的解決方法。畢竟是 瀏覽器 與 react 的編程模式是不一樣,他們進行了最大程度上的融合。

參考

https://overreacted.io/zh-hans/making-setinterval-declarative-with-react-hooks/

https://zhuanlan.zhihu.com/p/67183229

https://zhuanlan.zhihu.com/p/75146261

https://overreacted.io/zh-hans/a-complete-guide-to-useeffect/

https://zh-hans.reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies

https://usehooks.com/useEventListener/

更多請關注

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章