[React] 認識 useCallback、useMemo,了解 hooks 運作原理

Monica
25 min readJul 8, 2024

--

《React 思維進化》 筆記系列
1. [React] DOM, Virtual DOM 與 React element
2. [React] 了解 JSX 與其語法、畫面渲染技巧
3. [React] 單向資料流介紹以及 component 初探
4. [React] 認識狀態管理機制 state 與畫面更新機制 reconciliation
5. [React] 在子 component 觸發父 component 資料更新、深入理解 batch update 與 updater function
6. [React] 了解 immutable state 與 immutable update 方法
7. [React] 認識 component 的生命週期、了解每次 render 都有自己版本的資料
8. [React] React 中的副作用處理、初探 useEffect
9. [React] 對 hooks 的 dependencies 誠實,以維護資料流的連動
10. [React] React 18 effect 函式執行兩次的原因及 useEffect 常見情境
11. [React] 認識 useCallback、useMemo,了解 hooks 運作原理

前言

承接上篇 [React] React 18 effect 函式執行兩次的原因及 useEffect 常見情境,此篇主要敘述的是《React 思維進化》 5–6 ~ 5–7 章節的筆記,也是這系列最後一篇囉🎉,若有錯誤歡迎大家回覆告訴我~

深入了解 useCallback

雖然前面有提過 useCallback,但這篇會更深入的介紹 useCallback hook~需注意的是,useCallback 此 hook 本身效果不是效能優化,但可以協助 React 其他效能優化手段正常運作,若單使用 useCallback反而會讓效能更慢。

關於 useCallback呼叫方式,請見這篇,這裡直接以一個簡單範例說明:

import { useCallback } from "react";

export default function App() {
const doSomething = useCallback(
//每次 render 都會先建立一個函式,再作為參數傳給 useCallback
() => {
console.log(props.foo);
},
[props.foo]
);

//...
}

component 第一次 render 時,useCallback接受傳入的函式和 dependencies 陣列,並記住函式和 dependencies 陣列作為快取,接著將第一個參數的函式回傳。

component re-render 時,useCallback 將新 dependencies 陣列內的所有項目和上一次 render 的 dependencies 比較:

  • 如果全相同,忽略此次 render 傳入的新函式,回傳前次 render 記住的函式
  • 如果有任一不同,以本次 render 的函式和 dependencies 陣列覆蓋舊版本,回傳本次 render 的新函式

過程中可發現,每次 render 呼叫 useCallback時,都會先 inline 建立函式後,才會作為參數傳給 useCallback,因此使用 useCallback不會節省不必要的函式產生。

useCallback 本身用途為「讓函式能反應資料流的變化」,而非減少函式產生而讓效能優化。

以下介紹 useCallback的應用情境。

維持 hooks 依賴鏈的連動反應

若 component 內的函式在 effect 函式內被呼叫,此函式也會作為 dependencies,但這樣效能優化就會失效。此問題在 [React] 對 hooks 的 dependencies 誠實,以維護資料流的連動有提過,但還是大致再說明一下~範例程式碼如下:

import { useState, useEffect } from 'react';

export default function SearchResults(){
const [query, setQuery] = useState('hello');

async function fetchData(){
const result = await fetch(
`https://bar.com/api/search?query=${query}`
);
}

useEffect(()=>{
fetchData();
},[fetchData]);// 對 dependencies 誠實,但效能優化其實永遠都會失效,因為 fetchData 每次 render 都會是新的函式

//...
}

因為 fetchData 每次 render 都會是新的函式,所以 dependencies 的效能優化會永遠失敗,其問題本質在於 component 資料流為「資料→函式→副作用」,但在「函式」節點無法反映資料是否真的有更新,連帶副作用無法判斷源頭是否有更新,示意圖如下:

函式無法反映資料是否更新:不論資料是否更新,函式在每次 render 都重新產生並更新,導致副作用誤以為有更新而一律執行副作用處理

而要如何解決? 就是以 useCallback讓函式能反應資料流的變化:

import { useState, useEffect, useCallback } from "react";

export default function SearchResults() {
const [query, setQuery] = useState("hello");

const fetchData = useCallback(async () => {
const result = await fetch(`https://bar.com/api/search?query=${query})`);
// 對 dependencies 誠實,此函式依賴 query 變數資料
}, [query]);

useEffect(() => {
fetchData();
// dependencies 誠實,只有 query 與前次 render 不同時,fetchData 才會改變,連帶副作用才會再次被執行
// 如果 query 與前次 render 相比沒改變,useCallback 會回傳前次 render 的 fetchData,連帶副作用判定 fetchData 沒改變,可跳過副作用處理
// 只有當 query 與前次 render 不同時,fetchData 才會改變,副作用才會再次被執行
// ✅ 效能優化正常發揮效果
}, [fetchData]);
//...
}

正確填寫 dependencies 的 useCallback可讓…

  • 函式參與資料流
  • 需感知資料流機制的 dependencies 正常運作(如:副作用的 dependencies)

配合 memo:快取 component render 的畫面結果並重用以節省效能成本

React 有提供一種 memo 方法,是一種 higher order component,用來優化 component 的 render 效能,使用方式如下:

import { memo } from "react";

function Child(props) {
return (
<>
<div>Hello, {props.name}</div>
<button onClick={props.showAlert}>alert</button>
</>
);
}

const MemoizedChild = memo(Child); // 以 memo 方法來包裝 Child component,產生 MemoizedChild 這加工過的新 component

當 component re-render 時,memo 會檢查這個 Child component 的 props 與前次 render 的 props 是否完全相同:

  • 若相同,跳過本次的 component render,回傳已快取過的 render 結果(也就是 React element)
  • 若不同,照常 render component 並回傳結果

何時要使用 memo? 當 component props 相同、預計都會 render 出相同結果時,就可用 memo包住 component,component 就可透過快取結果達到效能優化。

memo 方法是一種資料流變動的感知,透過 props 資料來判斷是否可跳過畫面 render,以節省效能。但 memouseEffect 一樣會遇到類似問題,當 memo 過的 component,其 props 包含函式型別,若函式每次 render 都不同,則 memo 的效能優化效果永不會發揮作用:

import { memo } from "react";

function Child(props) {
return (
<>
<div>Hello, {props.name}</div>
<button onClick={props.showAlert}>alert</button>
</>
);
}

const MemoizedChild = memo(Child);

function Parent() {
// showAlert 函式每次 render 都會重新產生,每次 render 都不同
const showAlert = () => alert("hi");

// 因 showAlert 每次 render 都不同,memo 比較 props 是否相同時永遠都會不同,無法使用快取跳過 render,memo 的效能優化永遠失效
return <MemoizedChild name="react" showAlert={showAlert} />;
}

而要如何解決? 就是以 useCallback 包住函式:

import { memo, useCallback } from "react";

function Child(props) {
return (
<>
<div>Hello, {props.name}</div>
<button onClick={props.showAlert}>alert</button>
</>
);
}

const MemoizedChild = memo(Child);

function Parent() {
// 以 useCallback 包住函式,加上誠實的 dependencies,showAlert 就不會每次 render 都不同值
const showAlert = useCallback(() => alert("hi"), []);

// ✅ memo 的效能優化可正常運作
return <MemoizedChild name="react" showAlert={showAlert} />;
}

補充:React 中的「higher order component(HOC)」
higher order component 是一個經特殊設計的函式,此函式會接收一個 component 作為參數,並回傳一個加工過的 component。

承上,何時要用 useCallback 包住函式?

  • 當 component 內的函式被 effect 函式呼叫
  • 當 component 內的函式會透過 prop 傳給一個 memo 過的子 component

深入了解 useMemo

useMemo 的用途與使用情境與 useCallback 類似,通常會以 useMemo 來快取陣列或物件資料,與 useCallback不同的是,useMemo 本身能用於節省計算的效能成本。

補充:React 的 memo 方法和 useMemo hook 是不同的 API,兩者沒有直接相關,但某些時候會搭配使用來優化效能。

以下介紹 useMemo的應用情境。

維持 hooks 依賴鏈的連動反應

useCallback 類似,useMemo 能協助我們正確感知資料流,使效能優化正常運作,使用方式如下。

import { memo, useEffect, useMemo } from "react";

function Child(props) {
return (
<>
<div>Hello, {props.name}</div>
{props.numbers.map((num) => (
<p>{num}</p>
))}
</>
);
}

const MemoizedChild = memo(Child);

function Parent() {
// 將陣列以 useMemo 快取,numbers 陣列就不會每次 render 都不同值而導致效能優化失效
const numbers = useMemo(() => [0, 1, 2], []);

// ✅ numbers 陣列參與資料流連動,效能優化正常運作
useEffect(() => console.log(numbers), [numbers]);

// ✅ numbers 陣列參與資料流連動,效能優化正常運作
return <MemoizedChild name="react" numbers={numbers} />;
}

節省計算複雜資料的效能

不同於 useCallbackuseMemo 本身可用於節省計算複雜資料的效能成本,範例如下。

const memoizedValue = useMemo(
() => computeComplexValue(a, b),
[a, b]
)

useMemo 的 dependencies 有更新時,傳給 useMemo 的函式才會再次被執行,否則會跳過此次計算並回傳之前快取的結果。

承上,何時要用 useMemo 包住陣列或物件資料?

  • 當 component render 時才產生的陣列或物件資料被 effect 函式依賴
  • 當 component render 時才產生的陣列或物件資料會透過 prop 傳給一個 memo 過的子 component
  • 當資料運算成本較高,可用 useMemo 的快取來跳過不必要的計算

useCallbackuseMemomemo 比較

簡單整理 useCallbackuseMemomemo 的比較表格,若有錯誤歡迎大家告訴我~

useCallbackuseMemomemo 的比較表格

小提醒,使用 useCallbackuseMemo 時,仍需誠實填寫 dependencies 哦!

Hooks 的運作原理與設計思維

Hooks 的資料本體放在哪裡

當我們呼叫 useState 時,React 是將我們的 state 資料存放在哪裡,又是從哪裡找出我們需要的 state 資料回傳給我們的呢?

const [name, setName] = useState('hi'); 
// 在 component 內透過 useState 取得最新的 state 值,那這個 state 資料的「本體」到底被存在哪裡?

在 React 的畫面管理機制中,分為兩種資料:

  • 描述「某個歷史時刻的畫面結構」,即 React element
  • 儲存「最新狀態資料的畫面節點」,即 fiber node

fiber node 的結構大致長這樣:

一個 fiber node 的大致結構,資料來源:https://ithelp.ithome.com.tw/articles/10308283

那 React element 與 fiber node 的關係與區別是什麼呢,簡單以以下表格說明:

React element 與 fiber node 的關係與區別

當 React 啟動 reconciliation 時,流程如下,可以看到 fiber node 負責儲存最新的狀態資料:

  1. React 執行 component 的 render,將資料改動更新到 fiber node,並產出一份屬於這次 render 版本的 React element
  2. 將該次 render 的 React element 與前次 render 的 React element 比較
  3. 將比較的差異處交給 renderer 處理實際 DOM 更新

fiber node 概念在 function component 和 class component 時代皆存在,React 會將 state 資料和連續呼叫 setState 方法的待執行計算佇列都存放在 fiber node 中。

何時會新建一個 fiber node? 首次 render 一個 component 類型的 React element 時,React 就會在整個應用的 fiber node tree 新建一個 component 實例,並在其中存放 component 中各 hooks 的相關最新狀態,其中這個「component 實例」就是指一個 fiber node。所以首次 render component 類型的 React element 時,就會新建一個 fiber node。

當我們在 component 呼叫 useState,可在 fiber node 看到 state 資料:

function default App() {
const [count, setCount] = useState(10);
}

呼叫 setState 觸發 re-render 後,fiber node 資料就會被覆蓋更新,re-render 呼叫 useState 取出的 state 快照值,就是將 fiber node 那瞬間的 state 值「捕捉」起來並回傳。

如果多次呼叫 useState,fiber node 是怎麼儲存資料的?

function default App() {
const [count1, setCount1] = useState(10);
const [count2, setCount2] = useState(20);
const [count3, setCount3] = useState(30);
}

從 fiber node 可看出,第二個 state 會放在第一個 state 內,第三個 state 會放在第二個 state 內,如下圖。

此存放方式和 hooks 設計原理有關,接下來就來談談 hooks 的設計原理~

另外補充,關於 fiber node 介紹推薦閱讀這篇:React 開發者一定要知道的底層機制 — React Fiber Reconciler

為什麼 hooks 的運作是依賴於固定的呼叫順序

之前的文章曾提及,hooks 的使用規則是只能在 component function 頂層作用域呼叫,而為何會有這樣的規則呢?

先設想如果多次呼叫 useState,React 怎麼知道誰對應哪個 state?

const [count, setCount] = useState(10);
const [name, setName] = useState('Chair');
const [flag, setFlag] = useState(false);

在上面的程式碼中,每次呼叫 useState 時,我們只提供 state 預設值這個唯一參數,React 怎麼知道這次呼叫 useState 要回傳哪個 state 資料?舉例來說,我呼叫第一次 useState 的時候,React 怎麼知道要回傳給我 count 的 state 資料,而不是 name 的資料?

因為 React fiber node 會以 linked list 結構存放 state 資料,以此區別不同狀態資料,如下圖:

而所謂的以 linked list 存放 state 資料,就是在一個 state 中連著下一個 state,示意圖如下:

因此呼叫 useState hooks 時,React 是以呼叫順序作為區分和存放資料的依據,在 fiber node 第一層存放第一個 hook 的資料,從第一個 hook 再往下找到第二個、第二個往下找到第三個。

如果若跳過某次 hook 呼叫,會讓 hook 的呼叫順序無法對應前一次的呼叫順序,範例程式碼如下:

export function App() {
const [flag, setFlag] = useState(false);
if (!flag) {
// ⛔️此為錯誤示範,故意將 hook 包在條件式
const [foo, setFoo] = useState("foo");
}
const [count, setCount] = useState(10);
const [name, setName] = useState("Chair");

const handleClick = () => {
setFlag(true);
}

return (
<button onClick={handleClick}>click me</button>
)
}

點擊按鈕 re-render 後,const [foo, setFoo] = useState("foo"); 會因為 flag 變成 true 而被跳過,造成後面的 countname state hook 錯誤的對應前次 render 的 hook,資料錯置而產生錯誤,React 也會發現此次 render 呼叫的 hook 總數量與前次不同,因而報錯。

某 hook 在某次 render 被跳過,其後續的所有 hook 的順序都會跟著跳號

因此 React 制定 hooks 的呼叫規則,規定 hooks 要在 component function 頂層作用域呼叫,不能在條件式或迴圈內呼叫,是為了要讓 component 內的 hook 在每次 render 的呼叫順序都維持不變,讓狀態存取機制正常運作。

補充:Linked list(鏈結串列)
- 一種資料結構,由一連串節點組成
- 每個節點包含資料本身和指向下一個節點的「指標」
- 不需連續的記憶體空間,適用於記憶體有限的情況

該怎麼安全的讓 hooks 不再被執行到

如果希望讓某些 hooks 在某些情況不再被執行,就 unmount 包含這些 hooks 的 component,component 內的 hooks 就不會被執行。

function Bar(){
useEffect(() => {//...});
}

function App(){
const [isBar, setIsBar] = useState(true);
// 當 isBar 為 false,<Bar /> component 就會被 unmount,<Bar /> 裡的副作用處理就不會被執行
return isBar ? <Bar /> : <Foo />;
}

此時還有個疑問是,React 為何要將 hooks 設計成以順序性來呼叫,而非以自定義 key 的方式? 這就需要了解 hooks 的設計思維。

Hooks 是為了解決什麼問題

hooks 設計目標是為了綁定 function component 使用,因原先 class component 物件導向的設計思維造成許多概念衝突,理解與上手門檻因而較高,為解決 class component 問題,React 往函數式程式設計方向發展,並需要解決幾個問題:

問題一:讓 function component 擁有狀態

function component 能讓每次 render 獨立、互不干擾,每次 render 都有自己的狀態資料,但仍有以下問題/需求:

  • component 畫面管理仍需要有「狀態資料」,甚至是多種狀態資料
  • 多種狀態要能互相引用、傳值
  • 狀態間需避免命名衝突

如何解決? 需要有個足夠彈性、又能在 React 內部維護 fiber node 的 API 設計。

問題二:Component 間的邏輯重用

前端開發時,常有希望能重用不同 component 間邏輯的需求,但官方沒有推出 class component 的邏輯重用 API。

為何沒有推出? 因 class component 的 state 和生命週期 API 都無法獨立於 component 外定義,且一個功能的邏輯可能散佈在不同生命週期 API 內,難以抽取。

雖然社群有提出一些解法,但仍無法完美解決問題。

Hooks API 的設計思維與脈絡

承上,hooks 配合 function component,成為一套能定義、管理狀態並共用邏輯的 API,也解決了上述問題:

  • 避免命名衝突
  • 可重用邏輯,不同邏輯間可自由拆分、組合與呼叫
  • 避免 render 出的 React element 包含與畫面無關的東西

那麼,hooks API 是採用哪些設計思路來解決這些問題?

思路一:以函式作為載體

我們需要重用 component 間的邏輯,但要如何重用? 若以 component (如 higher order component)來重用邏輯,可能會有以下問題:

  • 命名衝突:若兩段 component 都有 count prop 而同時套用到同一個 component 上,就會命名衝突
  • 邏輯無法互動:承上,兩段邏輯只會是覆蓋關係(A覆蓋B或B覆蓋A),無法互動

因此,React hooks 以函式來重用邏輯,函式可以:

  • ✅ 自由設計參數與回傳值
  • ✅ 自由拆分與組合
  • ✅ 讓與畫面無關的邏輯與畫面本身分離

思路二:依賴於固定的呼叫順序

如何以函式定義狀態資料? React 要求 component 內所有 hooks 都要以固定順序呼叫、確保每個 hooks 都有被呼叫,以此保證內部狀態存取機制。

思路三:解決命名衝突問題

React 為何以順序性來區別資料,而非以唯一 key 值區別? 若以 key 值區別狀態資料,會有命名衝突問題,無法在同 component 內呼叫兩次 key 皆為 'name'useState。例如,若在自定義 hook 定義 key 為 'name'useState,重用自定義 hook 的 component 也定義了 key 為 'name'useState,就會因命名重複而出錯。

因此,React hooks 依賴呼叫順序,讓 hooks 的 key 都是順序性 index,以「這是第幾個被呼叫的 hook」來區分 hook。

思路四:解決鑽石問題

基於 key 來區分 hooks 還會導致另一問題,即鑽石問題(又稱多重繼承問題/菱形繼承問題),其實本質上還是命名衝突問題。

以下範例中,我們在遊戲資料內定義「玩家」和「怪物」兩種類型,兩者都有位置座標概念,因此可重用 hook。

function usePosition(){
// ⛔️ 非真實 useState API,僅為假想 useState 的 API 設計: useState(stateKey, defaultValue)
const [x, setX] = useState('positionX', 0);
const [y, setY] = useState('positionY', 0);

return { x, setX, y, setY};
}

export function usePlayer(){
const position = usePosition();

// ...

return {..., position}
}

export function useMonster(){
const position = usePosition();

// ...

return {..., position}
}

假設在 GameApp 元件同時呼叫 usePlayeruseMonster,就會產生鑽石問題:

import { usePlayer, useMonster} from './hooks'

export default function GameApp(){
const player = usePlayer();
const monster = useMonster();

// ...
}

usePlayeruseMonster 都呼叫 usePosition ,導致 key 值為 positionXpositionY 的 hooks 被重複註冊,產生命名衝突。

usePlayeruseMonster 都呼叫 usePosition ,導致 key 值為 positionXpositionY 的 hooks 被重複註冊

因此,React hooks 改為基於呼叫順序來區分 hooks,以所有 hooks 展開後的呼叫順序來區分並追蹤狀態資料。

React hooks 基於呼叫順序來區分 hooks,解決鑽石問題

小小總結!這是《React 思維進化》筆記系列的最後一篇囉🥳,好感動🥺...雖然只是讀書筆記而不是自己從無到有發想主題的文章,但還是花了很多心力和時間撰寫、畫圖,一路以來也收穫不少,真的感謝 Zet 大大寫的書,讓我更了解 React 的精妙之處,也感謝 Lois 舉辦的讀書會督促我把書看完,還有讀書會第二組的小夥伴們,有大家一起讀書的感覺更有動力好讚👍

--

--