如何錯誤地使用 React hooks useCallback 來保存相同的 function instance - Jason's Web Memo

如何錯誤地使用 React hooks useCallback 來保存相同的 function instance

首先我們先用 hooks 寫一個簡單的 Component

import React, { useState, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

const Button = React.memo(({ handleClick }) => {
const refCount = useRef(0);
return (
<button
onClick={handleClick}
>
{`button render count ${refCount.current++}`}</button>
);
});

function App() {
const [isOn, setIsOn] = useState(false);
const handleClick = () => setIsOn(!isOn);
return (
<div className="App">
<h1>{isOn ? "On" : "Off"}</h1>
<Button handleClick={handleClick} />
</div>
);
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Click to load CodeSandbox demo👆

button 透過 handleClick 去切換 state isOn 的狀態,同時我用 useRef 在 Button 來儲存一個變數用來計算 rerender 的數字,我們可觀察到每一次按按鈕 render count 是會不斷升高的,因為每一次 call setIsOn ,handleClick 都是創建新的 function instance,破壞了 React.memo 的 shallowCompare,聽說 useCallback 是 React hooks 提供的 api 可以 React 每一次 rerender 的時候都能保持相同的 function instance,因此我們就來嘗試如何利用 useCallback 來讓 render count 不會隨著點擊而上升。

錯誤使用一. 不看文件直接包上 useCallback

import React, { useState, useCallback, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

const Button = React.memo(({ handleClick }) => {
const refCount = useRef(0);
return (
<button
onClick={handleClick}
>
{`button render count ${refCount.current++}`}</button>
);
});

function App() {
const [isOn, setIsOn] = useState(false);
const handleClick = useCallback(() => setIsOn(!isOn));
return (
<div className="App">
<h1>{isOn ? "On" : "Off"}</h1>
<Button handleClick={handleClick} />
</div>
);
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Click to load CodeSandbox demo👆

這樣硬套下去你會發現點擊按鈕 render count 還是一直在上升,為什麼呢,因為你沒有好好看文件

const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);

useCallback 的第二個參數是一個 input array, 沒有傳的話 React 就會不知道怎麼判斷要不要繼續用之前的 callback,所以預設還是使用新的 function instance,有興趣的讀者可以參考 React mountCallback 跟 updateCallback 的 source code
https://github.com/facebook/react/blob/fd557d453d37eab29eca18f0507750ab2093669d/packages/react-reconciler/src/ReactFiberHooks.js#L990-L1011

官方文件的說法是 input array 必須要放入 memoizedCallback 裡頭會用到的外部變數

錯誤使用二. 乖乖地照著文件範例加入 input array

import React, { useState, useCallback, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

const Button = React.memo(({ handleClick }) => {
const refCount = useRef(0);
return (
<button
onClick={handleClick}
>
{`button render count ${refCount.current++}`}</button>
);
});

function App() {
const [isOn, setIsOn] = useState(false);
const handleClick = useCallback(() => setIsOn(!isOn), [isOn]);
return (
<div className="App">
<h1>{isOn ? "On" : "Off"}</h1>
<Button handleClick={handleClick} />
</div>
);
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Click to load CodeSandbox demo👆

好那我們乖乖照著範例應該沒有問題了吧,useCallback 放入第二個參數 [isOn],結果我們發現 render Count 還是隨著點擊不斷的增加,為什麼呢,因為 useCallback 如果發現 input Array 裡頭的變數改變了,那就會使用新的 function instance,結果有用 useCallback 跟沒用效果一模一樣…..,好吧看來只有想辦法 useCallback 忽略 isOn 總是回傳第一次創建的 function instance

錯誤使用三. 自作聰明的不照文件範例寫 code

import React, { useState, useCallback, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

const Button = React.memo(({ handleClick }) => {
const refCount = useRef(0);
return (
<button
onClick={handleClick}
>
{`button render count ${refCount.current++}`}</button>
);
});

function App() {
const [isOn, setIsOn] = useState(false);
const handleClick = useCallback(() => setIsOn(!isOn), []);
return (
<div className="App">
<h1>{isOn ? "On" : "Off"}</h1>
<Button handleClick={handleClick} />
</div>
);
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Click to load CodeSandbox demo👆

這次我們第二個參數使用一個空陣列,這樣就不會監聽任何變數的變化了,每一次 rerender 都會是相同的 function instance,我們試試看果然按了一次之後,render count 就保持是 0 了,很棒很棒,打完收工。

但是接著不管我們怎麼點擊按鈕 state isOn 都不會切換了…

啊啊啊啊 還是回去用 class Component 好了 (╯°Д°)╯︵ ┻━┻

冷靜一點,仔細思考一下為什麼不會動呢 ?

在 function Component 當中沒有 this,所以 Component 裡頭宣告的 function 只能透過 closure 閉包去取得外在的變數,而 state 在 React render 的機制裡頭是 immutable ,所以 function 還是會使用一開始 useState 回傳的 isOn , 而非 rerender 後的 state,所以就會造成打開以後就關不起來的狀況。詳情可以參考 Dan Abramov 的兩篇技術長文

正確且有效的使用 useCallback 的方法

如果你對 this.setState 夠熟悉的話,你應該知道 setState 除了可以接收 object 以外,也可以接收一個 updater function, 這個 function input 是 prevState 會 return 下一個 state,如果要做 toggle 切換 on off !this.state.isON 狀態,一般來說會建議使用 updater function 而非 Object 的原因是 React 在他的 Synthetic event 會 batch 所有的 setState,在同時觸發多個 setState 的時候,Object 會用 merge,this.state 就不會是上一個經過 setState 後的值,而是原本的 state,updater function input prevState 則是上一個經過 setState 後的值。

同樣 updater function 的機制在 hooks useState 也有保留,因此我們可以改用 updater function 去取得上一個 state 而不是用 closure 的方式去取得外部變數

import React, { useState, useCallback, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

const Button = React.memo(({ handleClick }) => {
const refCount = useRef(0);
return (
<button
onClick={handleClick}
>
{`button render count ${refCount.current++}`}</button>
);
});

function App() {
const [isOn, setIsOn] = useState(false);
const handleClick = useCallback(() => setIsOn((prevIsOn) => !prevIsOn), []);
return (
<div className="App">
<h1>{isOn ? "On" : "Off"}</h1>
<Button handleClick={handleClick} />
</div>
);
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Click to load CodeSandbox demo👆

這樣我們點擊按鈕的時候,render count 沒有更新,多次點擊狀態不動的 bug 也消失了

2019/3/14 補充

如果在更複雜的一點情況 callback 會用到多個  state

import React, { useState, useCallback, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

const Button = React.memo(({ handleClick, text }) => {
const refCount = useRef(0);
return (
<button onClick={handleClick}>
{`${text}`}
<span className={"renderCount"}>
self render count
{refCount.current++}
</span>
</button>
);
});

function App() {
const [numA, setNumA] = useState(0);
const handlePlusAClick = useCallback(
() => setNumA((prevNumA) => prevNumA + 1),
[]
);
const handleMinusAClick = useCallback(
() => setNumA((prevNumA) => prevNumA - 1),
[]
);
const [numB, setNumB] = useState(0);
const handlePlusBClick = useCallback(
() => setNumB((prevNumB) => prevNumB + 1),
[]
);
const handleMinusBClick = useCallback(
() => setNumB((prevNumB) => prevNumB - 1),
[]
);
const [result, setResult] = useState(null);
const handleAPlusB = useCallback(() => setResult(numA + numB), [numA, numB]);
const handleAMinusB = useCallback(() => setResult(numA - numB), [numA, numB]);
return (
<div className="App">
<div className="num">NumA: {numA}</div>
<Button text={"+"} handleClick={handlePlusAClick} />
<Button text={"-"} handleClick={handleMinusAClick} />
<div className="num">NumB: {numB}</div>
<Button text={"+"} handleClick={handlePlusBClick} />
<Button text={"-"} handleClick={handleMinusBClick} />
<div className="num">Result: {result}</div>
<Button text={"A + B"} handleClick={handleAPlusB} />
<Button text={"A - B"} handleClick={handleAMinusB} />
</div>
);
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Click to load CodeSandbox demo👆

上面是一個簡單加法減法器,你可以看到調整 A 的值,A+B button render count 也是會不斷的提升,因為 callback 會用到外部的 state,這就沒有辦法使用 updater function 來處理。

為了要繞過這個問題,也許我們可以把狀態給集中放在一個 Object 容器裡頭,這樣我們就可以使用 updater function 取得其他的狀態

等等,這怎麼聽起來跟我們熟知的 Redux 觀念很像,React hooks 有提供一個 useReducer,我們就試著用 useReducer 來解決這個問題。

這次學聰明了,第一步是好好看文件

const [state, dispatch] = useReducer(reducer, initialArg, init);

其中有一段重點

React guarantees that dispatch function identity is stable and won’t change on re-renders. This is why it’s safe to omit from the useEffect or useCallback dependency list.

dispatch 不會隨著 rerender 而改變

而且 state 放在獨立的 reducer 裡面,而不是 callback 裡面就不用怕需要 input array 要去監聽了

所以程式我們可以改寫成

import React, { useReducer, useCallback, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

const Button = React.memo(({ handleClick, text }) => {
const refCount = useRef(0);
return (
<button onClick={handleClick}>
{`${text}`}
<span className={"renderCount"}>
self render count
{refCount.current++}
</span>
</button>
);
});

const reducer = (state, action) => {
switch (action.type) {
case "INCREASE_A":
return {
...state,
numA: state.numA + 1,
};
case "DECREASE_A":
return {
...state,
numA: state.numA - 1,
};
case "INCREASE_B":
return {
...state,
numB: state.numB + 1,
};
case "DECREASE_B":
return {
...state,
numB: state.numB - 1,
};
case "A_PLUS_B":
return {
...state,
result: state.numA + state.numB,
};
case "A_MINUS_B":
return {
...state,
result: state.numA - state.numB,
};
default:
return state;
}
};

function App() {
const [{ numA, numB, result }, dispatch] = useReducer(reducer, {
numA: 0,
numB: 0,
result: null,
});
const handlePlusAClick = useCallback(
() => dispatch({ type: "INCREASE_A" }),
[dispatch]
);
const handleMinusAClick = useCallback(
() => dispatch({ type: "DECREASE_A" }),
[dispatch]
);
const handlePlusBClick = useCallback(
() => dispatch({ type: "INCREASE_B" }),
[dispatch]
);
const handleMinusBClick = useCallback(
() => dispatch({ type: "DECREASE_B" }),
[dispatch]
);
const handleAPlusB = useCallback(
() => dispatch({ type: "A_PLUS_B" }),
[dispatch]
);
const handleAMinusB = useCallback(
() => dispatch({ type: "A_MINUS_B" }),
[dispatch]
);
return (
<div className="App">
<div className={"num"}>NumA: {numA}</div>
<Button text={"+"} handleClick={handlePlusAClick} />
<Button text={"-"} handleClick={handleMinusAClick} />
<div className={"num"}>NumB: {numB}</div>
<Button text={"+"} handleClick={handlePlusBClick} />
<Button text={"-"} handleClick={handleMinusBClick} />
<div className={"num"}>Result: {result}</div>
<Button text={"A + B"} handleClick={handleAPlusB} />
<Button text={"A - B"} handleClick={handleAMinusB} />
</div>
);
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Click to load CodeSandbox demo👆

這樣我們的 useCallback 就可以 work 了

在更更複雜一點情況 callBack 會用到 props 也會用到  state

如果把 reducer 宣告在 Component 裡面就應該也可以利用 closure 拿到最新的 props,Dan 的文章就有提到這樣的用法

但是我覺得比較好的方式應該是重新組織你的 state 擺放位置,lift state up,把 state 往上層抽,把會有互相影響的 state 收在同一個 reducer 裡頭,這樣可以降低相當多的維護複雜度。

我有寫另外一篇文章討論 useReducer 的錯誤用法,為什麼我的 useReducer 會被執行兩次?在 React hooks 官方文件沒有清楚告訴你的 useReducer 的 bail out 機制,有興趣的讀者可以參考

結論

  1. 你覺得你已經用了 useCallback 的 React hooks 所以你的 React app 又快又潮
  2. 你可能發現你的 useCallback 也犯了我的錯所以導致一點作用都沒有
  3. 這說明了 avoid rerender virtual dom 可能只是一種安慰劑效應,沒有加就渾身不對勁,加了可以讓你自我感覺良好
  4. 而我想說的是 PureComponent or React.memo 不是解決效能的銀彈,也是有其成本,請你好好地開 chrome dev tool 好好的比較使用前後的差異
  5. 最重要最簡單也是最難的事情,請好好讀文件
  6. useReducer 跟 useCallback 是相當好的搭配

Webmention 社群迴響 1

喜歡 0
    轉推 0
      引用或評論 1

      用 Webmentions 參與社群迴響

      如果你的 blog 文章想要引用本文,歡迎透過下方表單用將你的 blog 文章網址傳送給我,若你的 blog 文章含有正確的本文網址連結,並且 blog 文章本身支持 microformat,之後你的引用資訊會更新在上面社群迴響的引用評論列表。

      社群迴響將不定期更新,不保證同步,同時有資料缺漏的可能性。

      Jason Chen - Yahoo Taiwan EC Web frontend engineer currently. Write something about web and React.js here

      Jason Chen

      Yahoo Taiwan Sr. Frontend Engineer. Write something about web and React.js here.

      Other Posts