iT邦幫忙

2023 iThome 鐵人賽

DAY 17
0
Modern Web

從Vue學React!不只要會用,還要真的懂~系列 第 17

【Day 17】想要避免多餘的渲染就用它?了解useCallback的最終目的

  • 分享至 

  • xImage
  •  

今天接著延續昨天的主題,來看另一個跟useMemo這個hook一樣都在進行緩存動作的hook,也就是useCallback。今天一樣會把焦點放在我們男主角React身上,讓我們先從useCallback和useMemo的差異是什麼,再往下深入了解useCallback的用途吧!

useCallback和useMemo的差異

首先,我們先從定義useCallback和useMemo兩者的存在是為了解決什麼問題來看它們之間的差異。

  • useMemo:為了避免因重新渲染而導致進行了不必要的重新計算。
  • useCallback:為了避免因為重新渲染導致之函式被重新被建立,而使得有用到這個函式的子元件進行了不必要的重新渲染或是相依動作進行多餘的重新執行。

簡單來說useMemo是為了避免額外進行複雜的計算,而useCallback則是為了避免發生的多餘的渲染和避免執行多餘的動作。需要強調的部分是useCallback跟useMemo不一樣,它的最終目的並不單純只是在避免創建函式的這件事,雖然它的確有這個效果,跟useMemo一樣都會把東西緩存起來(useMemo會緩存計算過後的值,useCallback會緩存創建的函式),但useCallback最終想達到的目的其實是 避免發生多餘的渲染和避免執行多餘的動作

另外,從緩存對象來看的話,useMemo主要是用來緩存一個計算後的值,而useCallback則是用來緩存一個函式。

如果用useMemo來實作useCallback的話,也就會是這個樣子。

function useCallback(fn, dependencies) {
  return useMemo(() => fn, dependencies);
}

useCallback跟useMemo一樣,都會依照相依值有沒有變動而決定要不要回傳新的結果。使用方式也跟useMemo一樣需要傳兩個參數進去,一個是 想被緩存下來的函式,另一個則是 相依值的陣列

const handleClick = useCallback(() => {
  setCount(count + 1);
}, [count]); 

當一個普通的函式像這樣使用了useCallback之後,在相依值變動前,它都會是指向同一個記憶體位址的函式。

已經定義了useCallback的主要使用目的後,後面再讓我們進一步了解「避免後面產生的多餘的渲染和執行動作」中的 「避免發生多餘的渲染」以及「避免執行多餘的動作」 的部分究竟是怎麼什麼樣的情況。

緩存就能優化效能!?useCallback使用與不用的渲染差異

在定義完useCallback與useMemo的使用目的後,對於效能優化這件事很敏感的人,在看到「避免進行不必要的渲染」的這幾個字,應該都會覺得「讚!優化效能就靠useCallback了」,但是這裡需要注意的是在這個達到效能優化的情境中,useCallback的使用 並不是一個主要的手段,而是輔助手段。為什麼會說是輔助的手段呢?因為在達到避免不必要渲染的目的之前,還需要搭配React.memo的使用。

什麼樣的情境可以透過React.memo+useCallback的方式達到避免多餘的重新渲染?
這個情境就是「當函式透過props往下傳給子元件的時候」。
當我們沒有特別透過React.memo把元件包裝起來的時候,不論有沒有傳遞props到子元件,都會因為父元件被觸發重新渲染,連帶讓父元件內的子元件也重新渲染,而且即使子元件的state沒有變動,在這個情況下,子元件一樣會因為父元件重新渲染而導致自己也重新渲染。

但是就如同昨天我們看過的情況一樣,當我們使用react.memo把元件包裝起來後,memo就能去依照傳進來的props有沒有變動,來決定是否重新渲染這個元件,而不是只要父元件的state有變動,就會連同子元件也重新渲染。但是當透過props傳進來的東西是函式時,只要重新渲染,在父層宣告的函示就會被重新建立一次,意思也就是說「雖然肉眼看都是同一個函式,但是只要重新呼叫一次component function,函式就會重新被創建,導致指向的記憶體位址也就會不同」,也因為如此,就算用React.memo將子元件包裝起來,也還是會觸發子元件的重新渲染。

// 父元件
import { useState } from 'react';
import ChildComponent from './components/ChildComponent';

export default function ParentComponent() {
  const [count, setCount] = useState(0);
  const [content, setContent] = useState('');
  
  // 傳入子元件的函式
  const handleClick = () => {
    setCount(count + 1);
  };
  const handleInput = (event) => {
    setContent(event.target.value);
  };
  console.log('parent render');

  return (
    <div className='container'>
      <input type="text" onChange={handleInput}  value={content}/>
      <p>Count: {count}</p>
      <ChildComponent onClick={handleClick} />
    </div>
  );
}
// 子元件
const ChildComponent = React.memo(({ onClick }) => {
  console.log('child render');
  return (
    <button onClick={onClick}>Increase Count</button>
  );
});

export default ChildComponent;

從這個範例的情境可以發現到雖然這個子元件已經用React.memo包起來了,但是在父元件set state觸發重新渲染時,子元件還是連帶著也進行了重新渲染,這是因為這個例子中透過props傳入的東西,不是一般的state,而是一個函式
https://i.imgur.com/wzxUnAl.gif

想要避免這樣的狀況,就可以使用useCallback把函式包裝起來,讓這個會傳到子元件的函式變成一個只有在相依值更新時,才會被重新建立的函式。

// 把handleClick用useCallback包起來,當有改動的state是沒有相依的state時,就不會回傳新創建的函式
const handleClick = useCallback(() => {
  setCount(count + 1);
}, [count]);

在這樣的調整過後,如果更新的是與子元件無關的state,就不會再觸發子元件進行不必要的渲染了。
https://i.imgur.com/WXZaF78.gif

上述這個小小的範例只有很小、很單純的幾個子元件,可是如果今天顯示的畫面沒有那麼的單純,還內含許多子元件的話,那耗的效能可能就會非常高。

這裡分享一個身邊因為這樣類似的情境而造成的效能問題的實境案例。
這個情境是「有個頁面每3秒固定會透過api拉一次資料(資料為內含多組物件的陣列),但是不一定每次拉的資料都有變動,另外,這個從API取得的一大組資料,會依照物件數量渲染畫面中的子元件,子元件中除了有從父元件透過props帶進去的state,還有操作這些state的函式」。
以文字來看這樣的一個情境,可能會覺得沒什麼異常的地方,但是它其實會造成很多不必要的渲染,先來直接說一下這樣的情境會發生什麼樣的狀況!

首先是「每3秒拉一次資料,不管資料有沒有相同,都會set state」的部分,因為React進行set state的動作,會透過object.is來檢查新舊state有無變動,這時候更新的state又是物件型別,object.is也就會去檢查state的記憶體位置是否相同。雖然API給的資料肉眼看是相同的,但是記憶體位置卻是不同的,這也就會觸發元件重新渲染。重新渲染感覺不是什麼大問題,但是「當一個畫面中,有多個子元件,那些子元件又內含一些子元件自己的邏輯或資料時」,若資料其實根本就沒有變動,但還是因為父元件重新渲染使得底下的多個子元件也重新渲染的話,就會造成所謂「不必要的重新渲染」,這時候就很適合使用React.memo搭配useCallback的方式把函式緩存起來。

緩存能避免執行多餘的動作又是怎麼一回事?

前面已經說明了useCallback怎麼避免多餘的渲染,以及適用在什麼樣的情境,接著也來看看前面提到的用useCallback避免執行多餘動作的部分。在這裡的情境中,useCallback一樣是輔助的手段,需要搭配useEffect這個做為主要手段的hook一起使用,才有能達到想要效果。

在某些實務的情境中,我們可能會透過useEffect去處理打API的動作,但是這個打API的動作也許因為某些需求會使用在別的地方,所以會另外被包成一個函式,這種情況下使用useEffect時,就如同前面提到過的一樣因為useEffect主要是在處理副作用,所以需要依照相依的內容有無變動,來決定是否再次處理這個副作用,也就需要把這個函式寫於useEffect的第二的參數的陣列內。

import { useState, useEffect } from "react";

export default function App() {
  console.log('render');
  const [data, setData] = useState({});

  const getApiData = () => {
    fetch("https://fakestoreapi.com/products/1")
      .then((res) => res.json())
      .then((json) => setData(json));
  };

  useEffect(() => {
    getApiData();
  }, [getApiData]);

  return (
    <div className="App">
      <h1>{data.title}</h1>
      <p>{data.description}</p>
    </div>
  );
}

如果以程式碼呈現的話,就會是上述的程式碼內容。單看程式碼可能不覺得哪裡奇怪,但是我們可以實際來執行一下會發生什麼狀況!
這邊可以看到畫面雖然有正常顯示,但是console卻一直印出render,也就代表component function一直被重新呼叫。
https://i.imgur.com/2wkLzzI.gif

這到底是怎麼一回事??
還記得是什麼會造成元件的重新渲染嗎?那就是set state的時後。再仔細看一下上面的那一段程式碼,就會發現這個打API的動作有進行set state的動作,當React執行set state時,會檢查新舊值是否有更新,在這個情境中,更新的值又是物件型別,在檢查state有無更動時,也就會檢查它的記憶體位址是否相同,來觸發元件的重新渲染,而set state都是以immutable的特性下去更新,也就是說雖然肉眼看到的內容一樣,記憶體位址卻不同,也就會觸發重新渲染。而所謂的重新渲染,就是重新呼叫一次component function,也就是說元件中的函式也會被重新建立,那就會造成useEffect覺得「相依的函式有變動了,要再重新進行副作用」,進而導致沒完沒了的無限迴圈。

整個動作的流程再次用文字說明的話也就是這樣:
首次render -> 打API的函式新建立 -> 打API拉取資料 -> set state -> 重新渲染 -> 打API的函式建立 -> 打API拉取資料......

如果想要避免這樣的狀況出現,重點就在於「讓函式不要因為重新渲染就被重新建立」,所以也就可以使用useCallback來當輔助,把這個函式先緩存下來,除非相依的值有變動(當然如果這個函式沒有相依的資料,就可以留空),我們再重新建立一次函式,這樣也就可以「避免執行多餘的動作」。

// 改成使用useCallback的寫法
const getApiData = useCallback(() => {
  fetch("https://fakestoreapi.com/products/1")
    .then((res) => res.json())
    .then((json) => setData(json));
}, []);;

在這樣修改後,就可以避免執行副作用重複執行,進行避免無限迴圈的狀況出現。
https://i.imgur.com/RaBPy8X.gif

小補充!這裡會印出6次render是因為現在是StrictMode的開發模式,會模擬mount -> unmount -> mount的過程,useEffect也會執行兩次.來排除一些預期外的錯誤。

使用useCallback一定能對效能優化有幫助?

在大多數情況下,其實並不需要特別使用useCallback,因為創建函式的會耗的效能並不會太大,所以如果只是單純想避免函式被重複創建,就使用useCallback的話,反而會需要額外耗費React去比較相依值有沒有改動的工以及緩存函式的記憶體。但是如果是想要解決「因為函式被重新創建,而產生的不必要的重新渲染,或進行多餘的動作」,useCallback的確是很適合的輔助手段。所以使用useCallback一定對效能優化有幫助嗎?嚴格來說它本人單獨存在時,比較難達到優化的效能,但在與其他hooks搭配使用時,某些情境的確有助於避免一些不必要的渲染而導致的效能問題。在使用useCallback時,還是必須思考自己為何而用和目前的情境適不適合用,因為並不是只要用了useCallback,就一定能達到避免多餘重新渲染和避免進行不必要動作的效果。

參考資料

How is useCallback related to useMemo?
什麼時候該使用 useMemo 跟 useCallback
[Day 27] useCallback 與 useMemo 的正確使用時機


上一篇
【Day 16】不要再重新計算啦!把計算複雜的值緩存起來 - computed & useMemo
下一篇
【Day 18】優化頁面效能的另一個方向-lazy & Suspense
系列文
從Vue學React!不只要會用,還要真的懂~30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言