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、state 以及在组件主体内部直接声明的所有变量和函数。如果你的代码检查工具已针对 React 配置,它将验证每个反应式值是否都正确指定为依赖项。依赖项列表必须具有恒定的项数,并且必须像[dep1, dep2, dep3]
一样内联编写。React 将使用Object.is
比较算法将每个依赖项与其之前的 value 进行比较。
返回值
在初始渲染中,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
传递两样东西
- 你想要在重新渲染之间缓存的函数定义。
- 一个依赖项列表,其中包括组件内函数中使用的每个值。
在初始渲染时,你将从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
当其props与上次渲染时相同的时候跳过重新渲染,方法是将其包装在memo
:中
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
的原因,在本页后面会进一步介绍。
深入探讨
你经常会看到 useMemo
与 useCallback
一起使用。当你想优化子组件时,它们都非常有用。它们允许你对传递下去的内容进行 记忆化(或者换句话说,缓存)
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
本身不会改变,除非productId
或referrer
发生变化。这允许你传递handleSubmit
函数而无需不必要地重新渲染ShippingForm
。你的代码只有在用户提交表单后才会运行。
如果你已经熟悉 useMemo
, 你可能会发现将 useCallback
理解为以下这样很有帮助:
// Simplified implementation (inside React)
function useCallback(fn, dependencies) {
return useMemo(() => fn, dependencies);
}
深入探讨
如果你的应用像这个网站一样,大多数交互比较粗粒度(例如替换页面或整个部分),那么记忆化通常是不必要的。另一方面,如果你的应用更像绘图编辑器,大多数交互比较细粒度(例如移动形状),那么你可能会发现记忆化非常有用。
使用 useCallback
缓存函数只有在少数情况下才有价值。
- 你把它作为 prop 传递给用
memo
包装的组件。如果值没有改变,你想跳过重新渲染。记忆化允许你的组件只有在依赖项改变时才重新渲染。 - 你传递的函数随后被用作某个 Hook 的依赖项。例如,另一个用
useCallback
包装的函数依赖于它,或者你从useEffect
依赖于这个函数。
在其他情况下,用 useCallback
包装函数没有任何好处。这样做也没有什么明显的坏处,因此一些团队选择不考虑个别情况,而尽可能多地进行记忆化。缺点是代码的可读性降低。此外,并非所有记忆化都是有效的:单个“总是新的”值足以破坏整个组件的记忆化。
请注意,useCallback
并不会阻止 *创建* 函数。你总是创建一个函数(这是可以的!),但是 React 会忽略它,如果没有任何改变,它会给你返回一个缓存的函数。
在实践中,你可以通过遵循以下几个原则来避免很多不必要的记忆化。
- 当一个组件在视觉上包裹其他组件时,让它 接受 JSX 作为子元素。 然后,如果包装组件更新其自身状态,React 就知道其子元素不需要重新渲染。
- 优先使用局部状态,不要 提升状态 到比必要更高的层级。不要将表单和项目是否悬停等短暂状态保存在树的顶部或全局状态库中。
- 保持你的 渲染逻辑纯净。 如果重新渲染组件导致问题或产生一些明显的视觉瑕疵,那就是你组件中的错误!修复错误而不是添加记忆化。
- 避免 更新状态的不必要 Effects。 React 应用中的大多数性能问题都是由源自 Effects 的更新链引起的,这些更新链导致你的组件一遍又一遍地渲染。
- 尝试 从你的 Effects 中移除不必要的依赖项。 例如,与记忆化相比,通常将某个对象或函数移到 Effect 内部或组件外部更简单。
如果特定的交互仍然感觉很卡顿,使用 React 开发者工具中的性能分析器 来查看哪些组件最受益于记忆化,并在需要的地方添加记忆化。这些原则使你的组件更容易调试和理解,因此无论如何都应该遵循它们。从长远来看,我们正在研究 自动进行记忆化 来一劳永逸地解决这个问题。
示例 1的 2: 使用useCallback
和 memo
跳过重新渲染
在这个例子中,ShippingForm
组件被人为地放慢了速度,这样你就可以看到当你渲染的 React 组件真正变慢时会发生什么。尝试递增计数器并切换主题。
递增计数器感觉很慢,因为它迫使放慢速度的 ShippingForm
重新渲染。这是预期的,因为计数器已经改变,所以你需要在屏幕上反映用户的新选择。
接下来,尝试切换主题。由于 useCallback
与 memo
结合使用,尽管人为地放慢了速度,它仍然很快! ShippingForm
跳过了重新渲染,因为 handleSubmit
函数没有改变。 handleSubmit
函数没有改变,因为 productId
和 referrer
(你的 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
作为依赖项并在内部读取它,而是向 React 传递关于如何更新状态的指令(todos => [...todos, newTodo]
)。 阅读更多关于更新函数的信息。
防止 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>
);
});