useCallback 和 useMemo 使用場景

天然呆☆☆發表於2024-11-15

一切為了效能,無論是 useCallback 還是 useMemo 還是 memo,都是為了讓不該渲染的元件不去渲染

在學習 useCallback、useMemo 之前,我們需要知道一點,React 的渲染是自頂而下,如果父元件渲染了,那麼子元件也會渲染,其子孫元件“世世代代”都要渲染

但如果父元件的渲染與子元件的 props 無關呢?設想一下父元件改變樣式顏色,關我子元件的資料請求什麼關係?

而這正是 useCallback 、useMemo、memo 的作用所在,將子元件用 memo 包住,如果 props 和上次渲染時相同,那麼子元件就跳過了重新渲染,useCallback 快取父元件傳給子元件的函式,useMemo 快取父元件傳給子元件的(計算)值

如今 React19 提出 Compiler,不需要再寫 useCallback 、useMemo、memo 擾亂心智了

 React Compiler

在講 useMemo 是什麼之前,我們先了解下什麼是 Memo

Memo

官方定義:React.memo 是一個高階元件(HOC),其主要目的是最佳化函式元件的效能。它透過記憶元件的渲染輸出,使得當元件的 props 沒有變化時,可以跳過該元件的重新渲染,從而提升效能

它會進行淺層對比,即對比兩個物件或陣列時只檢查其第一層屬性的相等性方法,在此比較中,僅比較物件的直接屬性值,而不遞迴地深入到鑲嵌的物件或陣列中

const obj1 = { a: 1, b: { c: 2 } };
const obj2 = { a: 1, b: { c: 2 } };
const obj3 = obj1;

console.log(obj1 === obj2); // false,因為 obj1 和 obj2 指向不同的記憶體地址
console.log(obj1 === obj3); // true,因為 obj3 引用同一個 obj1

和淺複製一個道理,只考察第一層屬性,不遞迴更深層的物件和陣列

舉個例子🌰

jsx
...
function App() {
    const [n, setN] = useState(0)
    const [m, setM] = useState(0)
    const onClick = () => {
        setN(n + 1)
    }
    
    return (
    	<div className="App">
			<div>
            	<button onClick={onClick}>update n {n}</button>
            </div>	
            <Child data={m} />
        </div>
    )
}

function Child(props) {
    console.log("child 執行了")
    return <div>child: {props.data}</div>
}
...

當我們什麼都不做時,點選 button,Child 元件會重新 re-render,Child 元件為什麼會重新 re-render,因為 button 點選後,n變化,導致 App re-render,所以 App 中的 Child 元件也跟著重新渲染

但這不對,因為 Child 的依賴項是 m,m沒變化,Child 不需要重新渲染才對,所以我們需要加上 React.memo

jsx
...
function App() {
    const [n, setN] = useState(0)
    const [m, setM] = useState(0)
    const onClick = () => {
        setN(n + 1)
    }
    
    return (
    	<div className="App">
			<div>
            	<button onClick={onClick}>update n {n}</button>
            </div>	
            <Child2 data={m} />
        </div>
    )
}

function Child(props) {
    console.log("child 執行了")
    return <div>child: {props.data}</div>
}

const Child2 = React.memo(Child)

如此一來,Child2 就不會重新 re-render 了

也就是說,如果 props 不變,就沒必要再此執行一個函式元件了

但這個做法有個 bug,加上監聽函式後,一秒破功

jsx
...
function App() {
    const [n, setN] = useState(0)
    const [m, setM] = useState(0)
    const onClick = () => {
        setN(n + 1)
    }
    
    const onChildClick = () => {
        console.log('點選 child')
    }
    
    return (
    	<div className="App">
			<div>
            	<button onClick={onClick}>update n {n}</button>
            </div>	
            <Child2 onClick={onChildClick} data={m} />
        </div>
    )
}

function Child(props) {
    console.log("child 執行了")
    return <div onClick={props.onClick}>child: {props.data}</div>
}

const Child2 = React.memo(Child)

當加上函式後,Child2 就會重新 re-render,理由也很簡單

  • button 點選後,觸發 App re-render
  • 生成的 onChildClick 是一個新的函式
  • 函式屬於物件,物件都是引用型別,所以心的 onChildClick 和之前的 onChildClick 不是同一個引用
  • 故 Child 即使用 memo 包裹了,但是會因為 props(onClick)變化而重新渲染

這時就要用到 useMemo

jsx
 
...
const onChildClick = useMemo(() => {
    return () => {
        console.log('點選 child')
    }
}, [m])
 ...

如此就快取住了 onChildClick

useMemo

useMemo 是一個 React Hook,它在每次重新渲染時都能快取計算的結果

jsx
const cachedValue = useMemo(calculateValue, dependencies)

第一個引數是 ()=> value

第二個引數是依賴項 [m,n]

只有當依賴項變化時,才會計算出新的 value

如果依賴項不變,那就用之前的 value

如果你的 value 是個函式,那麼你就要寫成 useMemo(() => x => console.log(x))

使用場景

跳過元件的重新渲染

某些情況下,useMmeo 可以幫助你最佳化重新渲染子元件的效能。假設這個 TodoList 元件將 visibleTodos 作為 props 傳遞給子 List 元件:

jsx
export default function TodoList({ todos, tab, theme }) {
  const visibleTodos = filterTodos(todos, tab);
  return (
    <div className={theme}>
      <List items={visibleTodos} />
    </div>
  );
}

將 List 用 memo 包裹住後,保持 props 不變就能實現 List 不渲染

所以我們使用 useMemo 快取 filterTodos 的值

jsx
export default function TodoList({ todos, tab, theme }) {
  // 告訴 React 在重新渲染之間快取你的計算結果...
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab] // ...所以只要這些依賴項不變...
  );
  return (
    <div className={theme}>
      {/* ... List 也就會接受到相同的 props 並且會跳過重新渲染 */}
      <List items={visibleTodos} />
    </div>
  );
}

透過將 visibleTodos 的計算函式包裹在 useMemo 中,你就可以確保它在重新渲染之間具有相同值,直到依賴項發生變化。

如果 useMemo 是個值還好說,如果是返回函式的函式,如useMmeo(()=>(x) => console.log(x)) 不僅難用,而且難以理解,於是 React 團隊就寫了語法糖——useCallback

最開始的案例我們修改下:

jsx
 
...
const onChildClick = useCallback(() => {
    console.log('點選 child')
}, [m])
 ...

它的作用和 useMemo 一模一樣,只是針對的是函式

useCallback

useCallback 是一個允許你在多次渲染中快取函式的 React Hook

jsx
const cachedFn = useCallback(fn, dependencies)

用法

它和 useMemo 出自一脈

useCallback( x => log(x), [m]) 等價於 useMemo(() => x => log(x), [m])

跳過元件的重新渲染

當你最佳化渲染效能時,有時需要快取傳遞給子元件的函式。為了快取元件中多次渲染的函式,你需要將其定義在 useCallback Hook 裡:

jsx
import { useCallback } from 'react';

function ProductPage({productId, referer, theme}) {
    const handleSubmit = useCallback((orderDetails) => {
        post('/product/' + productId + '/buy', {
            referer,
            orderDetails
        })
    }, [productId, referer])
}

你需要傳遞兩個引數給 useCallback:

  1. 在多次渲染中需要快取的函式
  2. 函式內部需要使用的所有元件內部值的依賴值

初次渲染時,在 useCallback 處接受的 返回函式 將會是已經傳入的函式

在之後的渲染中,React 將會使用 Object.is 把當前的依賴和已傳入之前的依賴進行比較。如果沒有任何依賴改變,useCallback 在多次渲染中快取一個函式,直到這個函式的依賴發生變化

簡而言之,useCallback 在多次渲染中快取一個函式,直至這個函式的變化

useCallback 與 useMemo 有何關係?

useCallback 經常和 useMemo 一同出現。當嘗試最佳化子元件時,它們都很有用。因為它們會記住(或者說:快取)正在傳遞的東西:

jsx
import { useMemo, useCallback } from 'react';

function ProductPage({ productId, referrer }) {
  const product = useData('/product/' + productId);

  const requirements = useMemo(() => { //呼叫函式並快取結果
    return computeRequirements(product);
  }, [product]);

  const handleSubmit = useCallback((orderDetails) => { // 快取函式本身
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

  return (
    <div className={theme}>
      <ShippingForm requirements={requirements} onSubmit={handleSubmit} />
    </div>
  );
}

區別在於你需要快取什麼:

  • useMemo 快取函式呼叫的結果。在這裡,它快取了呼叫 computeRequirements(product) 的結果。
  • useCallback 快取函式本身。不像 useMemo,它不會呼叫你傳入的函式。相反,它快取此函式。

如果你已經熟悉了 useMemo,你會發現 useCallback 就是 useMemo 所實現:

js
// 在 React 內部的簡化實現
function useCallback(fn, dependencies) {
  return useMemo(() => fn, dependencies);
}

是否應該在任何地方新增 useCallback?

大部分業務程式碼不需要使用快取,但如果你的應用更像是一個繪製編輯器,需要用到大量互動,快取會很有用

相關文章