iT邦幫忙

2022 iThome 鐵人賽

DAY 27
1
Modern Web

一次打破 React 常見的學習門檻與觀念誤解系列 第 27

[Day 27] useCallback 與 useMemo 的正確使用時機

  • 分享至 

  • xImage
  •  

除了最核心的 useState 以及 useEffect 以外,在 React 中最常被我們使用到的內建 hooks 應該就屬 useCallback 以及 useMemo 了。不過這兩個 hooks 到底在什麼情境下才需要被使用一直都是許多人有點疑惑的事情,在這個篇章中我們會來進行深入的解析它們正確的使用時機。


useCallback

我們先從更常被使用到的 useCallback 說起。與許多人的直覺不同的是,useCallback 本身的用途其實並不是效能最佳化,使用了 useCallback 反而會讓效能變慢。不過雖然它本身並不是用來做效能最佳化的,但是它本身的特性卻能協助其它的效能最佳化手段保持正常運作。

我們先來觀察看看 useCallback 的調用方法:

function App() {
  const doSomething = useCallback(
    () => {
      console.log(props.foo);
    },
    [....]
  );

}

從上面的程式碼中你可以看到,如果我們在每次 render 時都會重新建立一個 inline function 然後當作第一個參數傳給 useCallback我們其實並不會因為使用了 useCallback 而節省了「不必要的函式產生」,因為我們還是已經完成函式產生的動作後才傳給 useCallback 的。而如果 dependencies 中的依賴與前一次 render 時的依賴比較起來全部都相同的話,則它會無視你本次 render 傳入的新函式,返回給你上一次 render 時的函式。

這就是為什麼上面說 useCallback 本身其實並不能提供效能最佳化的效果,甚至反而效能會變慢。因為它既無法避免每次 render 時重新產生函式,且它的 dependencies 比較動作也是需要花費效能的(不過這個損耗通常是小到可以接受並忽略的)。

useCallback 真正的用途到底是什麼呢?以概念層面來解釋的話,它其實是用於協助我們的 component 能夠「感知資料流的變化」。這是什麼意思呢?我們以兩種最常見的實際使用情境來解析:

維持 hooks dependencies chain 的連動

當我們今天有一個 component 中的函式會在 effect 中被呼叫時,這個函式也會被判定為 effect 的依賴:

function SearchResults(props) {
  async function fetchData(query) {
    const result = await axios(
      `https://foo.com/api/search?query=${query}&rowsPerPage=${props.rows}`,
    );
  }
 
  useEffect(
    () => {
      fetchData('react').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 誠實,但是 fetchData 函式每次 render 時都會重新產生,
    // 因此這個 effect deps 的效能最佳化永遠都不會奏效
    [fetchData]
  );
  
  // ...
}

然而如果這個函式,如上面範例中的 fetchData,沒有經過 useCallback 處理的話,其實每次 component re-render 時都會是全新的版本。此時由於 fetchData 在每次 render 中都不同,因此這個 effect 的 dependencies 比較的結果永遠都會是「依賴有發生變化所以無需略過,正常執行 effect」,導致 useEffect dependencies 的效能最佳化永遠都會是失敗的。

useEffect 的 dependencies 本質上就是為了「感知資料流的變化來做效能最佳化」。當依賴的資料有變化時就重新執行 effect,當沒有任何依賴有改變時就略過本次執行以節省效能。然而,當 component 中定義的函式每次 render 都一定會重新產生時,調用了這個函式的 effects 就喪失了「對資料流變化的正確感知能力」!它們的 dependencies 效能最佳化將永遠都發揮不了節省的作用。

因此 useCallback 其實就是為了解決這種問題而存在的。它可以幫助你的 component 中的函式「感知資料流的變化並且反應在自己身上」,讓使用這個函式的其它 hooks 也能延續感知資料流的變化,就像是一個「dependencies chain」一樣:

function SearchResults(props) {
  const fetchData = useCallback(
    async (query) => {
      const result = await axios(
        `https://foo.com/api/search?query=${query}&rowsPerPage=${props.rows}`,
      );
      return result;
    },
    [props.rows] // callback deps 誠實
  );
 
  useEffect(
    () => {
      fetchData('react').then(result => { /* 用資料進行某些操作 */ });
    },
    // effect deps 是誠實的,
    // 且只有當 props.rows 不同時,useCallback 回傳的 fetchData 才會是新版的,連帶的此時 effect 才會再次被執行。
    // 而如果 props.rows 沒有改變時,useCallback 就會回傳與前一次 render 相同的舊版函式,
    // 則連帶的這個 effect 就會被忽略。
    // 因此這裡的 effect deps 效能最佳化可以正常發揮效果
    [fetchData]
  );
 
  // ...
}

在上面這個範例中,我們將 fetchData 函式直接定義在 component 中,函式裡依賴了 props.rows 資料,且會在 effect 中被呼叫。如果我們沒有使用 useCallbackfetchData 包起來的話,effect 就會因為 fetchData 在每次 render 時都不同而永遠最佳化失敗。而如果我們 fetchDatauseCallback 包起來的話,這個函式就能夠參與到 component 的「dependencies chain」當中了。

只有當 props.rows 不同時,useCallback 回傳的 fetchData 才會是新版的,連帶的此時 effect 才會再次被執行。而如果 props.rows 沒有改變時,useCallback 就會回傳與前一次 render 相同的舊版函式,則連帶的這個 effect 就會在該次 render 時被略過,此時這裡的 effect dependencies 效能最佳化就能夠正常發揮效果,它就像是一個資料流的連鎖反應一樣。

有了 useCallback的輔助之下,函式完全可以參與資料流。如果函式所依賴的資料有改變的話,函式才會跟著改變,而如果依賴不變的話,它會保持與前一次 render 時是相同的函式。

最後補充一點是,如果你的函式邏輯只會在一個 effect 裡面被使用的話,其實可以直接把那段邏輯寫進 effect 裡就好。相關的 effect 設計技巧你可以參考系列文中前面的篇章:[Day 23] 保持資料流 — 不要欺騙 hooks 的 dependencies(下)

配合 React.memo:略過 component render 以達到效能最佳化

在 React 中,除了 useEffect 有「如果依賴的資料沒有變化時則略過」的效能最佳化手段,component 的整個 render 動作本身其實也有類似的效能最佳化手段,也就是 React.memo 方法:

import React from 'react';

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

const MemoizedChild = React.memo(Child);

React.memo 是一種 Higher order component。如果你的 component 在 props 相同的時候都會 render 出相同的畫面結果的話,你可以將其包在 React.memo之中,透過快取 render 結果來達到效能最佳化的提升效果。這代表如果下一次這個 component 的 props 與前一次 render 時的 props 內容都相同時,React 會跳過本次 render 的流程而直接返回上一次 render 的結果。所以這其實也是一種資料流的變動感知,透過 props 的資料檢查來幫助 component 判斷是不是可以省下一次 render 的工作。

然而 React.memo 其實與 useEffect 一樣都會遇到類似的問題,當我們以 React.memo 包住的 component 的 props 中有函式,而這個函式在每次 render 時都會改變的話,那 React.memo 的效能最佳化效果永遠都不會成功觸發:

import React from 'react';

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

const MemoizedChild = React.memo(Child);

function Parent() {
  const showAlert = () => alert('hi');
  return (
    <MemoizedChild
      name="zet"
      showAlert={showAlert}
    />
  );
}

在上面的範例中,由於 <Parent> 裡的 showAlert 函式在每次 render 時都會不同,而這個函式會作為 prop 傳給子 component <MemoizedChild>,因此已經包過 React.memo<MemoizedChild> 的 props 比較結果永遠都會是不一致,導致效能最佳化效果永遠都無法奏效。

沒錯!此時 useCallback 又可以幫助我們解決這種情況了:

import React, { useCallback } from 'react';

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

const MemoizedChild = React.memo(Child);

function Parent() {
  const showAlert = useCallback(() => alert('hi'), []);

  return (
    <MemoizedChild
      name="zet"
      showAlert={showAlert}
    />
  );
}

在將 <Parent> 裡的 showAlert 函式透過 useCallback 包起來並加上誠實的 dependencies 後,這個函式也能參與到資料流的變化感知當中,並協助 React.memo 也能正確的感知資料流的變動了。

因此總結整理一下,當一個 component 裡的函式有被 effect 所調用,或是會透過 prop 來傳給一個 memo 過的子 component 時,就會建議將這個函式以 useCallback 給包起來。同時別忘了一定要保持對應的 dependencies 是誠實的,才能保證資料流感知能力的可靠性。

推薦參考資料


useMemo

接下來讓我們談談與 useCallback 有點類似的 useMemo。其實 useMemo 的用途與使用情境都跟 useCallback 是差不多的,只是差別是通常 useMemo 會用來 memoize 的是一包陣列或物件類型的資料集合,此外 useMemo 本身也真正能用於計算的效能節省。

useMemo 同樣能夠幫助我們在 hooks dependencies chain 以及 React.memo 等情境的資料流感知,以達到效能最佳化的效果:

import React from 'react';

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

const MemoizedChild = React.memo(Child);

function Parent() {
  const numbers = [1, 2, 3];

  // effect 的 deps 效能最佳化永遠都會失敗,因為 numbers 在每次 render 時都不同
  useEffect(
    () => console.log(numbers),
    [numbers]
  );

  // <MemoizedChild> 的 render 快取效能最佳化永遠都會失敗,
  // 因為 prop 「numbers」在每次 render 時都是不同的
  return (
    <MemoizedChild
      name="zet"
      numbers={numbers}
    />
  );
}

類似的情況,在上面的範例中的 effect dependencies 與 React.memo 的效能最佳化都是完全不會奏效,因為 numbers 陣列在每次 render 時都是不同的。

透過 useMemo 處理之後就能解決這種問題:

import React, { useMemo } from 'react';

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

const MemoizedChild = React.memo(Child);

function Parent() {
  const numbers = useMemo(
    () => [1, 2, 3],
    []
  );

  // effect 的 deps 效能最佳化可以正常運行
  useEffect(
    () => console.log(numbers),
    [numbers]
  );

  // <MemoizedChild> 的 render 快取效能最佳化可以正常運行
  return (
    <MemoizedChild
      name="zet"
      numbers={numbers}
    />
  );
}

另外不同於 useCallback 的是,useMemo 本身也是可以用於複雜資料計算的效能節省

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

當每次 useMemo 的 dependencies 中的依賴有所改變時,你傳給 useMemo 的 create function 才會被再次執行,否則就會略過本次的計算並直接返回之前曾算好的快取結果。

因此整體來說,當一個 component render 時才產生的陣列或物件資料有被 effect 所依賴,或是會透過 prop 來傳給一個 memo 過的子 component 時,就會建議將這個資料的產生以 useMemo 處理。當然,當你的資料的計算相當昂貴時,也可以用 useMemo 的快取行為來幫助你節省依賴不變時的重複計算。同時別忘了一定要保持對應的 dependencies 是誠實的,才能保證資料流感知能力的可靠性。


參考資料


2024/2 更新 - 實體書平裝版本預購

在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~

《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》

目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:

天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695

博客來(平裝版):
https://www.books.com.tw/products/0010982322

momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845


上一篇
[Day 26] Effects & cleanups 常見情境的設計技巧
下一篇
[Day 28] 一次弄懂 React hooks 的運作原理與設計思維(上)
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
apo7752
iT邦新手 5 級 ‧ 2022-10-16 15:20:25

安安Zet大,
文章中的「湊效」好像多為「奏效」。
另外useMemo段落中的Child component的numbers.map方法前面好像忘記加上props(props.numbers.map),再請看看是否有誤了!

Zet iT邦新手 2 級 ‧ 2022-10-16 17:39:58 檢舉

已修正 感謝提醒

0
jacky0326
iT邦新手 5 級 ‧ 2024-04-07 13:05:37

想請問書中提到的範例,當使用 const numbers = [1,2,3],為什麼在次Render時都會產生被重新產生並要使用React useMemo做處理,而string、number等就沒有這個問題呢,在我們沒修改數據的情況下,請問是因為function、array、object的記憶體會在re-Render時被重新被產生指向別處而string、number、boolean...不會發生這情況嗎?

hannahpun iT邦新手 3 級 ‧ 2024-06-24 11:14:21 檢舉

因為 [1,2,3] 這是一個 array,在 js 裡 array 跟 object 都是 by reference,意思就是你以為他一樣,但其實他指向不同 reference,dependency 是用 Object.is 去判斷一不一樣

Object.is([1,2,3], [1,2,3]) // false
Object.is({}, {}) // false
Object.is(() => {}, ()=>{}) // false
Object.is(1,1) // true

我要留言

立即登入留言