理解 React 中的 useEffect、useMemo 與 useCallback

2023-05-08 12:00:50

useEffect

先理解 useEffect 有助於學習 useMemo 和 useCallback。因為 useMemo 和 useCallback 的實現實際上都是基於 useEffect 的。

useEffect 是 React 中的一個很重要的 Hook,用於執行副作用操作。什麼是副作用?簡單來說,就是那些會改變函數外部變數或有外部可觀察影響的操作。useEffect 允許你在函陣列件中執行副作用操作。它會在元件每次渲染後執行副作用函數。如果指定了 deps 陣列,則只有當 deps 中的某個值變化時才會重新執行副作用函數。

常見的副作用操作有:

  • 訂閱資料:訂閱某個資料來源,當資料變化時更新元件 state。
  • 手動更改 DOM: 通過存取 DOM 節點或使用第三方 DOM 庫來改變 DOM 結構。
  • 紀錄檔記錄:在控制檯列印紀錄檔資訊。
  • 計時器:通過設定 Interval 或 Timeout 來執行定時操作。
  • 事件監聽:為 DOM 節點新增或移除事件監聽器。

useEffect 的兩個引數

useEffect 根據依賴項確定是否重新執行。它接收兩個引數:

  1. effect 函數:執行副作用操作的函數。
  2. deps 陣列 (可選):effect 函數的依賴項陣列。如果指定了 deps,那麼只有當 deps 中的某個值發生變化時,effect 才會被重新執行。
useEffect (() => {
  // 副作用操作
}, [deps])  // 如果指定了 deps, 則只有 deps 的值變化時才重新執行

舉個例子:

useEffect (() => {
  document.title = `你點選了 ${count} 次` 
}, [count])  // 僅當 count 變化時重新設定 document.title

這裡的 effect 函數是設定 document.title,deps 是 [count]。這意味著只有當 count 值變化時,effect 才會重新執行,否則它會被跳過。

如果你傳入一個空陣列 [] 作為第二個引數,那麼 effect 將只在第一次渲染時執行一次:

useEffect (() => {
  document.title = `Hello!`
}, [])

這裡的 effect 函數僅在元件初始渲染時執行一次,因為 [] 意味著該 effect 沒有任何依賴項。

如果你不指定第二個引數,那麼 effect 將在每次渲染後執行:

useEffect (() => {
  document.title = `你點選了 ${count} 次`
})

這裡的 effect 在每次渲染後都會執行,因為我們沒有指定 deps。

總結一下,useEffect 的兩個引數:

  • effect 函數:執行副作用操作的函數。
  • deps 陣列 (可選):effect 函數的依賴項陣列。
    • 如果指定了 deps,那麼只有 deps 中的值變化時,effect 才會重新執行。
    • 如果傳入 [] 作為 deps,則 effect 只會在第一次渲染時執行。
    • 如果不指定 deps,則 effect 將在每次渲染後執行。

理解 effect 函數和 deps 陣列是使用 useEffect 的關鍵。effect 負責執行具體的副作用操作,而 deps 控制 effect 的執行時機。

會執行兩次的 useEffect

在開發環境下,useEffect 第二個引數設定為空陣列時,元件渲染時會執行兩次。這是因為 React 在開發模式下會執行額外的檢查,以檢測 Trumpkin 警告並給出更好的錯誤資訊。當你指定 [] 作為 deps 時,這意味著 effect 沒有任何依賴項,所以它應該只在元件掛載時執行一次。但是,在第一次渲染時,React 無法確定 deps 是否會在將來的渲染中發生變化。所以它會在初始渲染時執行一次 effect,然後在 「呼叫階段」 再執行一次,以確保如果 deps 發生變化,effect 也會再次執行。如果在 「呼叫階段」 重新渲染時 deps 仍然為 [],那麼 React 會更新內部狀態,記錄該 effect 確實沒有依賴項,並在將來的渲染中跳過 「呼叫階段」 重新執行的步驟。

這就是在開發環境下 effect 會執行兩次的原因。這種行為只在開發模式下發生,在生產模式下 effect 只會執行一次。目的是為了提高開發體驗,給出更清晰的錯誤提示。如果 effect 的 deps 發生變化但沒有再次執行,React 可以明確地給出警告。而在生產模式下,這樣的檢查是不必要的,所以 effect 只會執行一次以減少效能開銷。

總結一下:當你在開發環境下使用 useEffect 並指定 [] 作為依賴項時,effect 函數會在初始渲染時執行兩次。這是因為 React 會在 「呼叫階段」 再次執行 effect,以檢查依賴項是否發生變化,給出更清晰的警告資訊。如果 deps 仍然為 [],那麼 React 會更新狀態並在將來跳過 「呼叫階段」 的重新執行。這種行為只在開發模式下發生,生產模式下 effect 只會執行一次。

什麼是 Trumpkin 警告?

Trumpkin 警告是 useEffect Hook 的一種錯誤警告。它會在開發環境下出現,用來表示 effect 函數中使用的某個狀態或 props 在依賴項 deps 中遺漏。

比如:

function Counter () {
  const [count, setCount] = useState (0);
  
  useEffect (() => {
    document.title = `You clicked ${count} times`;
  });  // 沒有指定 deps
}

這裡,effect 函數使用了 count state,但我們沒有將它新增到 deps 中。所以 React 會在開發環境下給出 Trumpkin 警告: React Hook useEffect has a missing dependency: 'count'. Either include it or remove the dependency array.

這是為了提示我們 count 狀態發生變化時,effect 函數並不會重新執行,這很可能是個 bug。要修復這個警告,我們有兩種選擇:

  1. 新增 count 到 deps:
useEffect (() => {
  document.title = `You clicked ${count} times`;
}, [count]); 
  1. 如果 effect 不依賴任何值,傳入空陣列 []:
useEffect (() => {
  document.title = `You clicked ${count} times`;  
}, []);

為什麼說此時可能是個 bug?當你不指定 useEffect 的第二個引數 (deps) 時,effect 回撥函數會在每次渲染後執行。但是,這並不意味著 effect 中使用的所有狀態和 props 都會在 effect 重新執行時更新。 effect 執行時所使用的變數會被建立出一個閉包,它會捕獲 effect 建立時那一刻變數的快照。所以,如果 effect 使用了某個狀態,但沒將其新增到依賴項 deps 中,當那個狀態更新時,effect 中仍然會使用舊的值。 這很可能導致 bug。

function Counter () {
  const [count, setCount] = useState (0);
  
  useEffect (() => {
    document.title = `You clicked ${count} times`;  // 使用了 count 但沒有指定為依賴
  }); 
}

這裡,effect 中使用了 count 狀態,但是我們沒有將它新增到 deps 中。在第一次渲染時,count 為 0,所以 document.title 會被設定為 "You clicked 0 times"。如果我們隨後將 count 更新為 1, 你可能會期望 document.title 也變為 "You clicked 1 times"。但是,當 effect 被執行時,它會捕獲 count 的 「舊值」 0。所以 document.title 實際上仍然會是 "You clicked 0 times"。 count 的更新並沒有觸發 effect 的重新執行。

這就是 Trumpkin 警告出現的原因,React 會檢測到 effect 中使用了某個狀態,但沒有在依賴項 deps 中指定它,這很有可能導致 bug。 所以 Trumpkin 警告的目的是在開發環境下檢測這樣的錯誤,並給出清晰的提示以修復它們。

瞭解 React 中的 「呼叫階段」

React 在初次渲染後會再次執行 useEffect Hook 的呼叫,以校驗是否有依賴項被遺漏從而產生 Trumpkin 警告。在上例中,React 在第一次渲染時會執行一次 effect,然後在 「呼叫階段」 再次執行 effect。這時,它會檢測到 count 狀態被使用但未在 deps 中指定,所以會產生 Trumpkin 警告。如果 deps 指定為 [],在 「呼叫階段」 的重新執行中它會檢測到 deps 沒有變化,所以會更新內部狀態並在將來的渲染中跳過這個額外步驟(呼叫階段)。

useEffect 的實現

effect 函數會建立一個閉包,捕獲函數內部使用的所有狀態和 props 的值。這是因為 Javascript 中的函數會隱式建立閉包。當 effect 第一次執行時,它會讀取函數內使用的所有狀態和 props,並將其值儲存到閉包中。

舉個例子:

function Counter () {
  const [count, setCount] = useState (0);
  
  useEffect (() => {
    const foo = count;  // 讀取 count 並存入閉包
    document.title = `You clicked ${foo} times`;  
  });
}

這裡,foo 變數是定義在 effect 函數內部的。當 effect 第一次執行時,它會讀取 count 的當前值 0,並將其儲存到 foo 中。foo 變數及其所捕獲的 0 值都被儲存在 effect 的閉包中。即使後續我們將 count 更新為 1,當 effect 重新執行時,它仍然會在閉包中找到 foo 變數,其值為 0。所以 document.title 不會更新。除非我們指定 [count] 作為 effect 的依賴項:

useEffect (() => {
  const foo = count;
  document.title = `You clicked ${foo} times`;
}, [count]);

現在,每當 count 更新時,effect 會重新執行。它會再次讀取 count 的最新值,並將其儲存到閉包的 foo 中:

  • 第一次執行:count 為 0,foo 被設定為 0
  • count 更新為 1:effect 重新執行,讀取 count 為 1,將其儲存到 foo 中,覆蓋之前的值
  • 以此類推...

要實現這個效果,有兩個關鍵點:

  1. Javascript 函數會隱式建立閉包,用來儲存函數內定義的變數和其值。
  2. effect 會在第一次執行時讀取所有使用的狀態和 props 的值,並將其儲存到閉包中。除非 deps 發生變化,否則 effect 在重新執行時會使用閉包中的 「舊值」。

這就是 effect 如何通過閉包捕獲變數值的實現機制。理解這一點,以及如何通過依賴項 deps 避免使用 「舊值」 導致的 bug,是使用 useEffect 的關鍵。

在 useEffect 中指定了 deps 依賴項時,它會在 deps 中的任何值變化時重新執行 effect 函數。這時,它會重新讀取最新的值,而不是使用閉包中的 「舊值」。這是通過在 effect 函數內部重新宣告狀態和 props 的值來實現的。每當 effect 重新執行時,它會捕獲那一刻的最新值,然後替換閉包中的 「舊值」。

舉個例子:

function Counter () {
  const [count, setCount] = useState (0);
  
  useEffect (() => {
    const foo = count;  // 重新讀取 count 的最新值
    document.title = `You clicked ${foo} times`;  
  }, [count]);  // 指定 count 作為依賴項
}

在第一次執行時,foo 被設定為 count 的初始值 0。當我們更新 count 為 1 時,effect 會重新執行,因為我們指定了 [count] 作為依賴項。這時,effect 會再次讀取 count,現在其值為 1。它會將 1 賦值給 foo,覆蓋閉包中的 「舊值」 0。所以每當 effect 重新執行時,它都會重新讀取狀態和 props 的最新值,並更新閉包中的值。這確保了在 effect 函數中,我們總是使用的是最新的,而不是舊的閉包值。在 React 原始碼中,這是通過在 effect 重新執行時呼叫 create 子函數來實現的:

function useEffect (create, deps) {
  //...
  function recompute () {
    const newValue = create ();  // 重新執行子函數,讀取最新值
    storedValue.current = newValue;  // 更新閉包中的值
  }
  
  if (depsChanged) recompute ();   // 如果 deps 變化,重新計算
}

每當依賴項 deps 變化時,React 會呼叫 recompute 函數來重新執行 create 子函數。create 會讀取最新的狀態和 props 值,並將新值儲存到儲存變數 storedValue 中,覆蓋之前的值。所以,通過在 effect 重新執行時重新讀取值並更新儲存變數,React 確保你總是在 effect 函數中使用最新的 props 和狀態,而不是閉包捕獲的 「舊值」。這就是 useEffect 在指定了 deps 依賴項時如何避免使用閉包中的 「舊值」 的實現機制。

每當 deps 變化,它會重新執行 effect 並讀取最新的值,更新儲存在閉包中的值。當你不指定 useEffect 的依賴項 deps 時,effect 函數會在每次渲染後執行。這時,effect 在重新執行時會繼續使用閉包中的 「舊值」,而不是讀取最新的狀態和 props 值。這是因為沒有指定依賴關係,所以 React 認為 effect 不依賴於任何值的變化。在原始碼中,這是通過不呼叫 recompute 函數來實現的。recompute 函數負責在依賴項變化時重新執行 effect 並更新閉包值。所以簡單來說,當你不指定 deps 時,effect 在重新執行時什麼也不會做 —— 它會繼續使用之前閉包中的值。

舉個例子:

function Counter () {
  const [count, setCount] = useState (0);
  
  useEffect (() => {
    const foo = count;  
    document.title = `You clicked ${foo} times`;  
  });
} 

在第一次渲染時,foo 被設定為 count 的初始值 0。當我們更新 count 為 1 時,effect 會重新執行,但這時它不會重新讀取 count。它會繼續使用閉包中儲存的 foo,其值仍為 0。
所以 document.title 不會更新,它將保持 "You clicked 0 times"。這是因為我們沒有指定 deps 陣列,所以 React 認為 effect 不依賴任何值。每次渲染後重新執行 effect 僅僅是為了重新整理副作用。它並不會讀取最新的 props 或狀態值。在原始碼中,effect 的重新執行如下所示:

function useEffect (create, deps) {
  //...
  if (didRender) {
    // 重新執行 effect, 但不會重新計算值
    create (); 
  }
  
  if (depsChanged) recompute ();  
}

所以當你不指定 deps 時,didRender 值會在每次渲染後變為 true,從而重新執行 effect。但是,由於 depsChanged 總是 false,所以 recompute 函數不會被呼叫。effect 在重新執行時只會呼叫 create 函數,但不會重新讀取值或更新閉包中的儲存值。所以它會繼續使用閉包中的 「舊值」,而不是最新的 props 和狀態。這就是當不指定依賴項 deps 時,useEffect 作用在重新執行時如何繼續使用閉包中的 「舊值」 而非最新值的實現機制。

在 useEffect 原始碼中,「舊值」 是通過 useRef hook 儲存的。useRef 返回一個可變的 ref 物件,其 .current 屬性被用於儲存任何值,這個值在元件的整個生命週期中持續存在。所以,useEffect 使用 useRef 來儲存第一次執行時讀取的 props 和狀態的值,這些就是所謂的 「舊值」。

useEffect 的簡化實現如下:

function useEffect (create, deps) {
  const storedValue = useRef (null);  // 使用 useRef 儲存舊值
  
  function recompute () {
    const newValue = create ();   // 重新執行,讀取最新值
    storedValue.current = newValue;  // 更新舊值
  }
  
  if (didRender) {
    create ();  // 重新執行,使用舊值 storedValue.current
  } 
  
  if (depsChanged) recompute ();  // 如果 deps 變化,重新計算新值 
}
  • 在初始渲染時,會執行 create 函數,讀取一些值並將其賦值給 storedValue。這些就是 「舊值」。
  • 如果沒有指定依賴項,didRender 將在每次渲染後變為 true,重新執行 create 函數,但這時仍使用儲存在 storedValue 中的 「舊值」。
  • 如果指定了依賴項 deps,且 deps 發生變化,recompute 函數會重新執行 create,讀取最新的值,並將其更新到 storedValue 中,覆蓋 「舊值」。
  • 如果依賴項 deps 沒有變化,什麼也不會發生 ——storedValue 中的 「舊值」 會繼續被使用。

所以,useRef hook 被用來在 effect 的多次執行之間儲存 props 和狀態的 「舊值」。每當依賴關係無變化時,這些 「舊值」 會繼續被使用。通過指定依賴項,你可以確保在值變化時重新執行 effect, 並使用最新的 props 和狀態值更新儲存的 「舊值」。這就是 useEffect 原始碼中 「舊值」 如何被儲存及使用的實現機制。理解它對於掌握 useEffect 的工作原理非常重要。

create 函數可以讀取 storedValue 的值,因為:

  1. storedValue 是在 effect 函數內宣告的。
  2. create 函數也是在 effect 函數內定義的,所以它可以存取 effect 作用域中的變數,包括 storedValue。
  3. 這是閉包的結果,create 函數會捕獲 surrounding scope 的變數,使其值得以在多次呼叫之間保持。

舉個例子:

function useEffect (create, deps) {
  const storedValue = useRef (null);
  
  function effect () {
    const create = () => {
      console.log (storedValue.current);  // 可以存取 storedValue
    }
  }
  
  //...
}

這裡,create 函數被定義在 effect 函數內部。所以它可以存取 effect 作用域中的變數,包括 storedValue。當 create 函數在後續呼叫中執行時,它會繼續使用建立時捕獲的 storedValue 變數。這是閉包的結果 —— 即使 effect 函數完成執行,create 函數所捕獲的變數也會被保留在記憶體中,供後續呼叫使用。

總結一下:

  1. create 和 storedValue 都是在 effect 內宣告的,所以 create 可以存取 storedValue。
  2. create 函數捕獲了 surrounding scope 的變數,使得這些變數在函數呼叫之間保持其值。這就是閉包。
  3. 所以,每當 create 被呼叫時,它都可以存取之前宣告的 storedValue,並讀取其當前值。
  4. 這就是 create 如何可以在多次呼叫之間共用並存取同一個 storedValue 的機制。

這一點對理解 useEffect 的工作原理很重要。 effect 中宣告的變數和函數都會被捕獲在閉包中,並在多次 effect 執行之間共用。理解了這一點,useEffect 中 「舊值」 的儲存和讀取機制也就很清楚了。

在 useEffect 原始碼中,effect 函數會在以下情況被呼叫:

  1. 在元件初始渲染時。此時它會執行 effect,讀取 props 和狀態的值,並將其儲存以供後續執行使用。
  2. 如果你指定了依賴項 deps,且 deps 中的任何值發生變化時。這時它會重新執行 effect,讀取最新的 props 和狀態值,並更新儲存的 「舊值」。
  3. 如果你不指定依賴項 deps,則在每次渲染後都會呼叫 effect。這時它會繼續使用儲存的 「舊值」。

大致的 useEffect 實現如下:

function useEffect (create, deps) {
  const effect = () => {
    const newValue = create ();  // 讀取最新值
    storedValue.current = newValue;  // 更新舊值
  }
  
  if (!deps) {
    didRender = true;  // 沒有依賴項,每次渲染後執行
  }
  
  if (didRender) effect ();   // 執行 effect
  
  if (deps && depsChanged) {  
    effect ();     // 如果有依賴項且變化了,執行 effect
  }
}

根據是否指定了依賴項 deps 及其是否發生變化,effect 會在以下情況被呼叫:

  1. 第一次渲染。此時會呼叫 effect,將 create 函數讀取的值儲存為 「舊值」。
  2. 如果指定了 deps 但未變化,什麼也不會發生。繼續使用儲存的 「舊值」。
  3. 如果指定了 deps 且其發生變化,會呼叫 effect,通過 create 函數讀取最新的值,並更新儲存的 「舊值」。
  4. 如果不指定 deps,didRender 會在每次渲染後變為 true,從而呼叫 effect。但這時會繼續使用儲存的 「舊值」。

effect 函數的呼叫與是否指定依賴項 deps 及 deps 是否發生變化直接相關。理解 effect 根據這些條件的不同呼叫方式,是理解 useEffect 的關鍵。useEffect 會在合適的時機呼叫 effect,以執行必要的副作用操作,同時確保你在 effect 中總是使用最新的 props 和狀態值。這就是 useEffect 的強大之處。

useEffect 大致實現
function useEffect (create, deps) {
  const effect = () => {
    const newValue = create ();  // Re-run create and get new value
    storedValue.current = newValue;  // Update stored value
  };

  const storedValue = useRef (null);

  const [depsChanged, setDepsChanged] = useState (false);

  if (didRenderRef.current && deps === undefined) {
    throw new Error ('Must either specify deps or no deps');
  }

  const prevDeps = useRef (deps);
  const didRenderRef = useRef (false);

  if (depsChanged || !prevDeps.current) {
    prevDeps.current = deps;  // Update prevDeps ref 
    didRenderRef.current = true;
  }

  useLayoutEffect (() => {
    if (didRenderRef.current && !depsChanged && prevDeps.current !== deps) {
      setDepsChanged (true);  // Trigger re-run of effect
    }
  });

  // Call the effect
  useLayoutEffect (() => {
    effect ();
  });

  // Re-run effect if deps change 
  useLayoutEffect (() => {
    if (depsChanged && didRenderRef.current) {
      effect ();
      didRenderRef.current = false;
      setDepsChanged (false);
    }
  }, deps);

  // Always re-run on mount
  useLayoutEffect (effect, []); 
}

useMemo

useMemo 用於優化元件的渲染效能。它會在依賴項變化時重新計算 memoized 值,並且只在依賴項變化時重新渲染元件。你應該在以下情況使用 useMemo:

  1. 昂貴的計算:如果你有一個複雜的計算,它應該只在某些依賴項變化時重新執行,那麼 useMemo 非常有用。它會記住最後計算的值,並僅在依賴項變化時重新計算。
  2. 避免不必要的渲染:如果你有一個元件,它在重新渲染時執行昂貴的 DOM 操作,那麼你應該通過 useMemo 來優化它,使其只在依賴項變化時重新渲染。
  3. 依賴項變化時才重新計算值:如果你想基於 props 的某些值來計算一些資料,並且你只想在依賴 props 值變化時重新計算該資料。

在 React 函陣列件中,每當元件重新渲染時,其函數體都會被執行。這意味著任何計算的資料或渲染的元素都會重新計算和重新建立。這通常沒什麼問題,但如果計算或渲染代價高昂,它可能會造成效能問題。

舉個例子:

const expensiveComputation = (a, b) => {
  // 做一些昂貴的計算...
  return result;
}

function MyComponent () {
  const [a, setA] = useState (1);
  const [b, setB] = useState (1);

  const result = expensiveComputation (a, b);
  //...
}

在這裡,每當元件重新渲染時,expensiveComputation 都會被呼叫,即使 a 和 b 沒有變化。使用 useMemo 可以解決這個問題:

function MyComponent () {
  const [a, setA] = useState (1);
  const [b, setB] = useState (1);
  const result = useMemo (() => expensiveComputation (a, b), [a, b]);

  //...
}

現在,result 只會在 a 或 b 變化時重新計算。所以,useMemo 的主要目的就是為了避免 React 函陣列件不必要的重複計算,提高元件的效能。

useMemo 的實現

useMemo 的實現比較簡單,它基本上是 useEffect 的一個特例。

function useMemo (nextCreate, deps) {
  currentlyRenderingMemo++;

  const create = useRef (nextCreate);
  const depsRef = useRef (deps);
  
  function recompute () {
    currentlyRenderingMemo++;
    const memoizedValue = create.current ();
    memoized.current = memoizedValue;
    currentlyRenderingMemo--;
  }
  
  if (deps.current !== deps) {
    deps.current = deps; 
    recompute ();
  }

  const memoized = useRef (null);
  if (currentlyRenderingMemo === 0) {
    recompute ();
  } 
  
  return memoized.current;
}

它做了以下幾件事:

  1. 當前渲染的 useMemo 數量加 1。這是為了避免在巢狀的 useMemo 呼叫中重複執行 effects。
  2. 用 useRef 建立對 create 函數和 deps 陣列的參照。
  3. 定義 recompute 函數來呼叫 create 函數並更新 memoized 值。
  4. 如果 deps 變化了,呼叫 recompute 來重新計算 memoized 值。
  5. 如果這是第一個 useMemo 呼叫,呼叫 recompute 來計算初始 memoized 值。
  6. 返回 memoized 值。
  7. 在元件解除安裝時,自動清空 refs,相當於執行過清理函數。

所以本質上,它只在依賴項變化時重新執行 create 函數,並記住最後的值,這與 useEffect 有些相似。但 useMemo 專注於記憶化值,而不產生任何副作用。這就是 React 中 useMemo 的簡單實現原理。它通過跟蹤依賴項和快取上次計算的值來優化元件渲染效能。

useCallback

useCallback 與 useMemo 類似,它也是用於優化效能的。但是它用於記憶化函數,而不是值。useCallback 會返回一個 memoized 回撥函數,它可以確保函數身份在多次渲染之間保持不變,僅在某個依賴項變化時才會更新,這可以用於避免在每次渲染時都建立新的函數範例。所以,當你有一個會在多次渲染之間保持不變的函數時,使用 useCallback 是一個很好的優化手段。

舉個例子,當你有一個函數作為事件處理程式時,它通常在建立後就不會改變。但是,如果你直接在渲染方法中定義這個函數,它會在每次渲染時重新建立。

function MyComponent () {
  const [count, setCount] = useState (1);
  
  function handleClick () {
    setCount (c => c + 1);
  }
  
  return <button onClick={handleClick}>Increment</button>
}

這裡,handleClick 函數在每次渲染時都會重新定義。雖然它的邏輯在多次渲染之間沒有變化。

使用 useCallback 優化這段程式碼:

function MyComponent () {
  const [count, setCount] = useState (1);

  const handleClick = useCallback (() => {
    setCount (c => c + 1);
  }, []);  // 依賴項 [] 表示僅在第一次渲染時建立
  
  return <button onClick={handleClick}>Increment</button>
}

現在,handleClick 只會在第一次渲染時建立。在隨後的渲染中,它都指向同一個函數範例。這可以避免在每次渲染時建立新的事件處理程式,從而優化元件的效能。

總結一下: useCallback 的主要作用是:

  1. 記憶化函數範例,避免在每次渲染時建立新的函數。
  2. 當函數作為 props 傳遞給子元件時,可以讓子元件避免不必要的重新渲染。

與 useMemo 類似,你應該在依賴項變化時才更新回撥函數。否則,它就沒有意義了。總之,useCallback 主要用於效能優化,通過記憶化函數範例來避免不必要的重新建立和重新渲染。

useCallback 的實現

useCallback 的實現也比較簡單。它基本上就是用 useMemo 來記憶化一個函數。

function useCallback (callback, deps) {
  return useMemo (() => callback, deps);
}

它直接呼叫 useMem,傳入 callback 函數和 deps 陣列。

所以,useCallback 的工作原理是:

  1. 在第一次渲染時,呼叫 callback 函數並記住結果。
  2. 在後續渲染中,如果 deps 沒有變化,直接返回上次記住的函數。
  3. 如果 deps 變化了,再次呼叫 callback 並記住新結果。
  4. 在元件解除安裝時,自動清理 useMemo 的副作用。

所以本質上,它就是把函數當作 useMemo 的建立函數來呼叫,並根據依賴項決定是否需要重新建立函數範例。這與事件處理程式的例子非常吻合。

舉個具體例子:

function useCallback (callback, [a, b]) {
  return useMemo (() => {
    callback ()     // 只在第一次渲染時呼叫
  }, [a, b])      // 如果 a 或 b 變化時重新呼叫 callback
}

那麼第一次渲染時會立即呼叫 callback,並記住結果。如果後續 a 或 b 變化,callback 會再次被呼叫,並更新記憶的值。如果 a 和 b 保持不變,直接返回上次記住的函數範例。這就是 useCallback 的簡單實現原理。它通過將函數範例記憶化來確保函數身份在多次渲染之間保持一致,從而優化效能。綜上,useCallback 的實現是基於 useMemo 的。它利用 useMemo 的記憶化特性來記憶化函數,以此來提高元件渲染效能。