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 不会调用你的函数。该函数将返回给你,以便你可以决定何时以及是否调用它。

  • dependenciesfn 代码中引用的所有响应式值的列表。响应式值包括 props、state 以及直接在组件主体中声明的所有变量和函数。如果你的 linter 已配置为 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 prop 会导致应用程序冻结一会儿,但是如果你从 JSX 中删除 <ShippingForm />,它就会变得很快。这告诉你,尝试优化 ShippingForm 组件是值得的。

默认情况下,当一个组件重新渲染时,React 会递归地重新渲染它的所有子组件。 这就是为什么当 ProductPage 使用不同的 theme 重新渲染时,ShippingForm 组件也会重新渲染。对于不需要太多计算来重新渲染的组件来说,这很好。但是如果你确认重新渲染很慢,你可以通过将 ShippingForm 包装在 memo: 中来告诉它在 props 与上一次渲染相同时跳过重新渲染。

import { memo } from 'react';

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

通过这种改变,如果 ShippingForm 的所有 props 都与上一次渲染相同,它将跳过重新渲染。 这就是缓存函数变得重要的时候!假设你在没有 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 中,你可以确保它在多次渲染之间是相同的函数(直到依赖项发生变化)。你不必将函数包装在 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 不同,它不会调用您提供的函数。相反,它会缓存您提供的函数,以便除非 productIdreferrer 发生变化,否则 handleSubmit *本身* 不会发生变化。这允许您向下传递 handleSubmit 函数,而无需不必要地重新渲染 ShippingForm。在用户提交表单之前,您的代码不会运行。

如果您已经熟悉 useMemo 您可能会发现将 useCallback 视为这样会有所帮助

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

阅读有关 useMemouseCallback 之间差异的更多信息。

深入探讨

您是否应该在所有地方都添加 useCallback?

如果您的应用程序像此站点一样,并且大多数交互都很粗略(例如替换页面或整个部分),则通常不需要记忆化。另一方面,如果您的应用程序更像是一个绘图编辑器,并且大多数交互都是细粒度的(例如移动形状),那么您可能会发现记忆化非常有用。

仅在少数情况下,使用 useCallback 缓存函数才有价值

  • 您将其作为 prop 传递给包装在 memo 中的组件。如果您不想在值未更改的情况下重新渲染。记忆化使您的组件仅在依赖项更改时才重新渲染。
  • 您传递的函数稍后将用作某些 Hook 的依赖项。例如,包装在 useCallback 中的另一个函数依赖于它,或者您从 useEffect 中依赖于此函数。

在其他情况下,将函数包装在 useCallback 中没有任何好处。这样做也没有什么重大危害,因此一些团队选择不去考虑个别情况,而是尽可能多地进行记忆化。缺点是代码变得难以阅读。此外,并非所有记忆化都有效:一个“始终是新”的值足以破坏整个组件的记忆化。

请注意,useCallback 不会阻止*创建*函数。您始终在创建函数(这很好!),但 React 会忽略它,并在没有任何变化的情况下返回缓存的函数。

在实践中,您可以通过遵循一些原则来避免许多不必要的记忆化

  1. 当组件在视觉上包装其他组件时,让它 接受 JSX 作为子组件。 然后,如果包装器组件更新了自身的状态,React 会知道其子组件不需要重新渲染。
  2. 首选本地状态,并且不要 将状态提升 超过必要的范围。不要将表单之类的瞬态状态以及某个项目是否悬停在树的顶部或全局状态库中。
  3. 保持您的 渲染逻辑纯净。 如果重新渲染组件会导致问题或产生一些明显的视觉伪像,那么这就是您组件中的错误!修复错误,而不是添加记忆化。
  4. 避免 不必要的更新状态的 Effect。 React 应用程序中的大多数性能问题都是由源自 Effect 的更新链导致的,这些更新会导致您的组件一遍又一遍地渲染。
  5. 尝试从您的 Effect 中 删除不必要的依赖项。 例如,与记忆化相比,将某些对象或函数移动到 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]);
// ...

您通常希望记忆函数的依赖项尽可能少。当您读取某个状态只是为了计算下一个状态时,可以通过传递更新器函数来删除该依赖项

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();
connection.connect();
// ...

这会产生一个问题。 每个响应式值都必须声明为 Effect 的依赖项。 但是,如果您将 createOptions 声明为依赖项,则会导致您的 Effect 不断重新连接到聊天室

useEffect(() => {
const options = createOptions();
const connection = createConnection();
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();
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();
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,而是在 memo 中包裹 Report 本身。如果 item 属性没有改变,Report 将跳过重新渲染,所以 Chart 也将跳过重新渲染。

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

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

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