useCallback

useCallback 是一個 React Hook,可讓您在重新渲染之間快取函式定義。

const cachedFn = useCallback(fn, dependencies)

參考

useCallback(fn, dependencies)

在元件的頂層調用 useCallback,以便在重新渲染之間快取函式定義

import { useCallback } from 'react';

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

請參閱下面的更多範例。

參數

  • fn:您要快取的函式值。它可以接受任何參數並返回任何值。React 會在初始渲染期間將您的函式返回(而不是調用!)給您。在下一次渲染時,如果自上次渲染以來 dependencies 沒有更改,React 將再次提供相同的函式給您。否則,它會提供您在當前渲染期間傳遞的函式,並將其儲存起來以便日後重複使用。React 不會調用您的函式。該函式會返回給您,以便您可以決定何時以及是否調用它。

  • dependencies:在 fn 代碼內參考的所有反應值的清單。反應值包括 props、狀態,以及所有直接在元件主體內宣告的變數和函式。如果您的程式碼檢查器已 針對 React 進行設定,它會驗證每個反應值是否都已正確指定為依賴項。依賴項清單必須具有固定數量的項目,並且像 [dep1, dep2, dep3] 一樣內嵌撰寫。React 會使用 Object.is 比較演算法將每個依賴項与其先前的值進行比較。

回傳值

在初始渲染時,useCallback 會回傳您傳入的 fn 函式。

在後續的渲染中,它會回傳上次渲染中已儲存的 fn 函式(如果依賴項沒有更改),或者回傳您在此渲染中傳入的 fn 函式。

注意事項

  • useCallback 是一個 Hook,因此您只能在組件的頂層或您自己的 Hook 中調用它。您不能在迴圈或條件語句中調用它。如果您需要這樣做,請提取一個新的組件並將狀態移入其中。
  • 除非有特殊原因,否則 React 不會丟棄已快取的函式。 例如,在開發過程中,當您編輯組件的文件時,React 會丟棄快取。在開發和生產環境中,如果您的組件在初始掛載期間暫停,React 都會丟棄快取。未來,React 可能會新增更多利用丟棄快取的功能,例如,如果 React 將來新增對虛擬化列表的內建支援,那麼將滾動出虛擬化表格視埠的項目的快取丟棄將是有意義的。如果您依賴 useCallback 作為效能優化,這應該符合您的預期。否則,狀態變數ref 可能更合適。

用法

跳過組件的重新渲染

當您優化渲染效能時,有時您需要快取傳遞給子組件的函式。讓我們先看看如何做到這一點的語法,然後再看看在哪些情況下它很有用。

要快取組件重新渲染之間的函式,請將其定義包裝在 useCallback Hook 中

import { useCallback } from 'react';

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

您需要將兩件事傳遞給 useCallback

  1. 您希望在重新渲染之間快取的函式定義。
  2. 一個依賴項列表,包含組件中在函式內使用的每個值。

在初始渲染時,您從 useCallback 獲得的回傳函式 將是您傳遞的函式。

在後續的渲染中,React 會將依賴項與您在上一次渲染期間傳遞的依賴項進行比較。如果沒有任何依賴項發生更改(與Object.is比較),useCallback 將回傳與之前相同的函式。否則,useCallback 將回傳您在*此次*渲染中傳遞的函式。

換句話說,useCallback 會在重新渲染之間快取函式,直到其依賴項發生更改為止。

讓我們通過一個例子來看看什麼時候這很有用。

假設您正在將 handleSubmit 函式從 ProductPage 傳遞到 ShippingForm 組件

function ProductPage({ productId, referrer, theme }) {
// ...
return (
<div className={theme}>
<ShippingForm onSubmit={handleSubmit} />
</div>
);

您注意到切換 theme 屬性會使應用程式凍結片刻,但如果您從 JSX 中移除 <ShippingForm />,則感覺很快。這告訴您值得嘗試優化 ShippingForm 組件。

預設情況下,當組件重新渲染時,React 會遞迴地重新渲染其所有子組件。這就是為什麼當 ProductPage 使用不同的 theme 重新渲染時,ShippingForm 組件*也*會重新渲染。這對於不需要大量計算即可重新渲染的組件來說是可以接受的。但是,如果您確認重新渲染很慢,您可以通過將 ShippingForm 包裝在 memo: 中來告訴它在其屬性與上次渲染相同時跳過重新渲染:

import { memo } from 'react';

const ShippingForm = memo(function ShippingForm({ onSubmit }) {
// ...
});

通過此更改,如果 ShippingForm 的所有屬性都與上次渲染*相同*,它將跳過重新渲染。這就是快取函式變得重要的時候!假設您在沒有 useCallback 的情況下定義了 handleSubmit

function ProductPage({ productId, referrer, theme }) {
// Every time the theme changes, this will be a different function...
function handleSubmit(orderDetails) {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}

return (
<div className={theme}>
{/* ... so ShippingForm's props will never be the same, and it will re-render every time */}
<ShippingForm onSubmit={handleSubmit} />
</div>
);
}

在 JavaScript 中,function () {}() => {} 總是會建立一個不同的函式,類似於 {} 物件字面值總會建立一個新的物件。通常,這不會造成問題,但這表示 ShippingForm 的 props 將永遠不會相同,且您的 memo 優化將無法運作。這就是 useCallback 派上用場的地方。

function ProductPage({ productId, referrer, theme }) {
// Tell React to cache your function between re-renders...
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]); // ...so as long as these dependencies don't change...

return (
<div className={theme}>
{/* ...ShippingForm will receive the same props and can skip re-rendering */}
<ShippingForm onSubmit={handleSubmit} />
</div>
);
}

透過將 handleSubmit 包裝在 useCallback 中,您可以確保它在重新渲染之間是相同的函式(直到 dependencies 改變)。您不必將函式包在 useCallback 中,除非您有特定原因才這麼做。在此範例中,原因是您將它傳遞給一個包在 memo 中的元件,這讓它可以跳過重新渲染。您可能需要 useCallback 的其他原因,在本頁面將會進一步說明。

注意事項

您應該僅將 useCallback 作為效能優化的手段。如果您的程式碼在沒有它的情況下無法運作,請先找出根本問題並修復它。然後,您可以再將 useCallback 加回來。

深入探討

您經常會看到 useMemouseCallback 一起使用。當您嘗試優化子元件時,它們都很有用。它們讓您可以記憶化(或者說快取)您傳遞的內容。

import { useMemo, useCallback } from 'react';

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

const requirements = useMemo(() => { // Calls your function and caches its result
return computeRequirements(product);
}, [product]);

const handleSubmit = useCallback((orderDetails) => { // Caches your function itself
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]);

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

區別在於它們讓您快取的內容

  • useMemo 會快取呼叫函式的結果在此範例中,它會快取呼叫 computeRequirements(product) 的結果,以便除非 product 改變,否則它不會改變。這讓您可以傳遞 requirements 物件,而無需不必要地重新渲染 ShippingForm。必要時,React 會在渲染期間呼叫您傳遞的函式來計算結果。
  • useCallback 會快取函式本身。useMemo 不同,它不會呼叫您提供的函式。相反,它會快取您提供的函式,以便 handleSubmit 本身 不會改變,除非 productIdreferrer 改變。這讓您可以傳遞 handleSubmit 函式,而無需不必要地重新渲染 ShippingForm。您的程式碼在使用者提交表單之前不會執行。

如果您已經熟悉 useMemo,您可能會覺得將 useCallback 想像成這樣會有所幫助:

// Simplified implementation (inside React)
function useCallback(fn, dependencies) {
return useMemo(() => fn, dependencies);
}

閱讀更多關於 useMemouseCallback 之間的差異。

深入探討

您應該在任何地方都加上 useCallback 嗎?

如果您的應用程式像這個網站一樣,大多數互動都是粗略的(例如替換頁面或整個區塊),通常不需要記憶化。另一方面,如果您的應用程式更像繪圖編輯器,大多數互動都是精細的(例如移動形狀),那麼您可能會發現記憶化非常有幫助。

使用 useCallback 快取函式僅在少數情況下才有價值:

  • 您將它作為 prop 傳遞給包在 memo 中的元件。如果您希望在值未更改時跳過重新渲染。記憶化可讓您的元件僅在 dependencies 改變時才重新渲染。
  • 您傳遞的函式稍後會被用作某些 Hook 的 dependency。例如,另一個包在 useCallback 中的函式依賴於它,或者您在 useEffect 中依賴於此函式。

在其他情況下,將函式包在 useCallback 中沒有好處。這樣做也沒有什麼顯著的壞處,因此有些團隊選擇不去考慮個別情況,而是盡可能地進行記憶化。缺點是程式碼變得難以閱讀。此外,並非所有記憶化都有效:單個「永遠是新的」值就足以破壞整個元件的記憶化。

請注意,useCallback 並不會阻止函式的建立。您始終在建立函式(這沒問題!),但 React 會忽略它,並在沒有任何更改時將快取的函式返回給您。

在實務中,您可以透過遵循一些原則來避免許多不必要的記憶化:

  1. 當元件在視覺上包裝其他元件時,讓它接受 JSX 作為子元素。然後,如果包裝器元件更新了自己的狀態,React 就知道其子元素不需要重新渲染。
  2. 盡量使用局部狀態,並且不要將狀態提升到比必要更高的層級。不要將表單和項目是否懸停等暫時狀態保留在樹的頂部或全域狀態庫中。
  3. 保持您的渲染邏輯純粹。如果重新渲染元件導致問題或產生一些明顯的視覺偽影,那就是您的元件中有錯誤!修復錯誤,而不是新增記憶化。
  4. 避免不必要的 Effect 更新狀態。 React 應用程式中大多數的效能問題,都是因為 Effect 觸發的更新鏈造成元件不斷重新渲染。
  5. 嘗試從 Effect 中移除不必要的相依性。 例如,比起使用記憶化 (memoization),通常將物件或函式移入 Effect 內部或元件外部會更簡單。

如果特定的互動仍然感覺延遲,使用 React 開發者工具的效能分析器來查看哪些元件最能受益於記憶化,並在需要的地方添加記憶化。 這些原則讓你的元件更容易除錯和理解,所以在任何情況下都建議遵循。 長期來看,我們正在研究自動執行記憶化來一勞永逸地解決這個問題。

useCallback 和直接宣告函式的差異

範例 1說明 2:
使用 useCallbackmemo 略過重新渲染

在此範例中,ShippingForm 元件被人為地減慢速度,以便你可以看到當你渲染的 React 元件真的很慢時會發生什麼事。 嘗試增加計數器並切換主題。

增加計數器感覺很慢,因為它強制減慢速度的 ShippingForm 重新渲染。這是預期的,因為計數器已更改,因此你需要在螢幕上反映使用者新的選擇。

接下來,嘗試切換主題。 由於 useCallbackmemo 的搭配使用,即使人為減慢速度,它仍然很快! ShippingForm 略過了重新渲染,因為 handleSubmit 函式沒有改變。 handleSubmit 函式沒有改變,因為 productIdreferrer(你的 useCallback 相依性) 自上次渲染以來沒有改變。

import { useCallback } from 'react';
import ShippingForm from './ShippingForm.js';

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

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

function post(url, data) {
  // Imagine this sends a request...
  console.log('POST /' + url);
  console.log(data);
}


從記憶化的回呼更新狀態

有時,你可能需要根據記憶化回呼中的先前狀態來更新狀態。

這個 handleAddTodo 函式指定 todos 作為相依性,因為它會根據它來計算下一個 todos

function TodoList() {
const [todos, setTodos] = useState([]);

const handleAddTodo = useCallback((text) => {
const newTodo = { id: nextId++, text };
setTodos([...todos, newTodo]);
}, [todos]);
// ...

你通常希望記憶化函式具有盡可能少的相依性。 當你只讀取某些狀態來計算下一個狀態時,可以透過傳遞更新器函式 (updater function) 來移除該相依性

function TodoList() {
const [todos, setTodos] = useState([]);

const handleAddTodo = useCallback((text) => {
const newTodo = { id: nextId++, text };
setTodos(todos => [...todos, newTodo]);
}, []); // ✅ No need for the todos dependency
// ...

這裡,你不是將 todos 作為相依性並在內部讀取它,而是將關於*如何*更新狀態的指示 (todos => [...todos, newTodo]) 傳遞給 React。 閱讀更多關於更新器函式的資訊。


防止 Effect 過於頻繁地觸發

有時,你可能想要從 Effect 內部呼叫函式:

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');

function createOptions() {
return {
serverUrl: 'https://127.0.0.1:1234',
roomId: roomId
};
}

useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
// ...

這會產生一個問題。 每個反應值都必須宣告為 Effect 的相依性。 然而,如果你宣告 createOptions 作為相依性,它將導致你的 Effect 不斷地重新連接到聊天室

useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // 🔴 Problem: This dependency changes on every render
// ...

要解決此問題,你可以將需要從 Effect 呼叫的函式包裝到 useCallback

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');

const createOptions = useCallback(() => {
return {
serverUrl: 'https://127.0.0.1:1234',
roomId: roomId
};
}, [roomId]); // ✅ Only changes when roomId changes

useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // ✅ Only changes when createOptions changes
// ...

這可以確保如果 roomId 相同,則 createOptions 函式在重新渲染之間保持相同。 但是,更好的做法是移除對函式相依性的需求。將你的函式移到 Effect*內部*

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');

useEffect(() => {
function createOptions() { // ✅ No need for useCallback or function dependencies!
return {
serverUrl: 'https://127.0.0.1:1234',
roomId: roomId
};
}

const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ Only changes when roomId changes
// ...

現在你的程式碼更簡單,而且不需要 useCallback瞭解更多關於移除 Effect 相依性的資訊。


最佳化自訂 Hook

如果你正在編寫自訂 Hook,建議將它返回的任何函式包裝到 useCallback

function useRouter() {
const { dispatch } = useContext(RouterStateContext);

const navigate = useCallback((url) => {
dispatch({ type: 'navigate', url });
}, [dispatch]);

const goBack = useCallback(() => {
dispatch({ type: 'back' });
}, [dispatch]);

return {
navigate,
goBack,
};
}

這可確保你的 Hook 的使用者可以在需要時最佳化自己的程式碼。


疑難排解

每次我的組件渲染時,useCallback 都會返回一個不同的函式

請確保您已將依賴項陣列指定為第二個參數!

如果您忘記依賴項陣列,useCallback 每次都會返回一個新的函式

function ProductPage({ productId, referrer }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}); // 🔴 Returns a new function every time: no dependency array
// ...

這是將依賴項陣列作為第二個參數傳遞的修正版本

function ProductPage({ productId, referrer }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]); // ✅ Does not return a new function unnecessarily
// ...

如果這沒有幫助,則問題是至少有一個依賴項與之前的渲染不同。您可以透過手動將依賴項記錄到控制台來除錯此問題

const handleSubmit = useCallback((orderDetails) => {
// ..
}, [productId, referrer]);

console.log([productId, referrer]);

然後,您可以在控制台中右鍵點擊來自不同重新渲染的陣列,並為它們選擇「儲存為全域變數」。假設第一個儲存為 temp1,第二個儲存為 temp2,則您可以使用瀏覽器控制台檢查兩個陣列中的每個依賴項是否相同

Object.is(temp1[0], temp2[0]); // Is the first dependency the same between the arrays?
Object.is(temp1[1], temp2[1]); // Is the second dependency the same between the arrays?
Object.is(temp1[2], temp2[2]); // ... and so on for every dependency ...

當您找到哪個依賴項破壞了記憶體化時,請找到一種方法將其移除,或者也將其記憶體化。


我需要在迴圈中為每個清單項目調用 useCallback,但不允許這樣做

假設 Chart 組件被 memo 包裹。當 ReportList 組件重新渲染時,您想要跳過重新渲染清單中的每個 Chart。但是,您不能在迴圈中調用 useCallback

function ReportList({ items }) {
return (
<article>
{items.map(item => {
// 🔴 You can't call useCallback in a loop like this:
const handleClick = useCallback(() => {
sendReport(item)
}, [item]);

return (
<figure key={item.id}>
<Chart onClick={handleClick} />
</figure>
);
})}
</article>
);
}

相反,請為單個項目提取一個組件,並在那裡放置 useCallback

function ReportList({ items }) {
return (
<article>
{items.map(item =>
<Report key={item.id} item={item} />
)}
</article>
);
}

function Report({ item }) {
// ✅ Call useCallback at the top level:
const handleClick = useCallback(() => {
sendReport(item)
}, [item]);

return (
<figure>
<Chart onClick={handleClick} />
</figure>
);
}

或者,您可以移除最後一個程式碼片段中的 useCallback,並將 Report 本身包裝在 memo 中。如果 item 屬性沒有更改,Report 將跳過重新渲染,因此 Chart 也將跳過重新渲染

function ReportList({ items }) {
// ...
}

const Report = memo(function Report({ item }) {
function handleClick() {
sendReport(item);
}

return (
<figure>
<Chart onClick={handleClick} />
</figure>
);
});