React中的useMemo和useCallback:何时真正需要
1. 概述
useMemo 和 useCallback 经常被感性地使用:要么把所有东西都包装“以防万一”,要么永远不用。两种方法都错了。本文解释这两个 hook 背后的机制,指出它们真正有用的三种情况,以及哪些时候应该跳过。你还将编写一个测试来证明 memoization 真正有效。
2. 根本问题:每次渲染都会重新创建
每当组件重新渲染时,整个函数体都会从头开始执行。在其中声明的所有对象、数组和函数都会被重新创建。
function Checkout({ customer }) {
const shipping = { city: customer.city, express: true };
const handlePay = () => console.log('为', customer.name, '进行支付');
return <PaymentForm shipping={shipping} onPay={handlePay} />;
}
每次 Checkout 重新渲染后,shipping 和 handlePay 都是新的引用,尽管内容没有变化。JavaScript 按引用比较对象和函数:
{ city: '顺化' } === { city: '顺化' } // false
(() => 1) === (() => 1) // false
通常这是无害的,因为创建对象很便宜。只有当某些东西比较引用时才会成为问题。那可能是 React.memo、useEffect 的依赖数组,或其他 hook。useMemo 和 useCallback 就是为了在多次渲染之间保持引用稳定而生的。
3. useMemo:记住一个值
useMemo(calculate, deps) 在第一次渲染时调用 calculate 并保存结果。在后续渲染中,React 仅在 deps 中的某个元素改变时才重新计算,使用 Object.is 进行比较。
3.1. 避免每次渲染都进行昂贵的计算
import { useMemo, useState } from 'react';
function OrderReport({ orders }) {
const [keyword, setKeyword] = useState('');
const [darkMode, setDarkMode] = useState(false);
const visibleOrders = useMemo(() => {
const kw = keyword.toLowerCase();
return orders
.filter((o) => o.customer.toLowerCase().includes(kw))
.sort((a, b) => b.total - a.total);
}, [orders, keyword]);
return (
<div className={darkMode ? 'dark' : ''}>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button onClick={() => setDarkMode((d) => !d)}>切换界面</button>
<ul>
{visibleOrders.map((o) => (
<li key={o.id}>{o.customer}: {o.total}</li>
))}
</ul>
</div>
);
}
说明:
filter和sort仅在orders或keyword改变时重新运行。- 当你点击“切换界面”时,
darkMode改变,组件重新渲染。此时useMemo返回已保存的数组,不重新过滤。 sort在这里是安全的,因为filter已经返回了一个新数组。没有任何操作直接修改 props 的orders。
如何知道计算是否“昂贵”?测量而不是猜测:
console.time('过滤订单');
const result = orders.filter((o) => o.customer.includes(keyword));
console.timeEnd('过滤订单');
过滤订单: 0.08ms
一个实用的参考:如果计算在开发机器上花费不到 1ms 左右,就几乎不需要 memoization。记得在生产构建中测量,更好的做法是在 DevTools 中启用 CPU 节流,因为用户的机器通常比你的慢。
3.2. 保持对象稳定以传递
第二种情况与计算速度无关。这里你需要一个引用稳定的对象作为依赖:
function CustomerOrders({ customerId, status }) {
const [orders, setOrders] = useState([]);
const query = useMemo(() => ({ customerId, status }), [customerId, status]);
useEffect(() => {
fetchOrders(query).then(setOrders);
}, [query]);
return <OrderTable orders={orders} />;
}
如果省略 useMemo,query 将在每次渲染后是一个新对象。那么 effect 会持续重新运行,而且因为 setOrders 会导致重新渲染,所以你可能陷入无限 API 调用的循环。
注意: 在这个例子中还有一个更简单的方法。你在 effect 内部创建对象,并将原始值放入依赖中:
useEffect(() => { fetchOrders({ customerId, status }).then(setOrders); }, [customerId, status]);。优先使用这种方法。仅当对象必须存在于 effect 外部时才使用useMemo,例如当它被传递到子组件时。
4. useCallback:记住一个函数
useCallback(fn, deps) 等价于 useMemo(() => fn, deps)。它返回你传入的函数本身,并保持其引用不变,直到 deps 改变。useCallback 不会让函数运行得更快。这个 Hook 只有在函数在某处被比较时才有意义。
4.1. 结合 React.memo
React.memo 会跳过子组件的渲染,如果 props 与上次相同(浅比较)。如果某个 prop 是每次都重新创建的函数,memo 就失效了。
import { memo, useCallback, useState } from 'react';
const OrderRow = memo(function OrderRow({ order, onSelect, onRender }) {
onRender?.(order.id);
return <li onClick={() => onSelect(order.id)}>{order.customer}</li>;
});
export function OrderList({ orders, onRender }) {
const [selectedId, setSelectedId] = useState(null);
const [note, setNote] = useState('');
const handleSelect = useCallback((id) => setSelectedId(id), []);
return (
<>
<input aria-label="note" value={note} onChange={(e) => setNote(e.target.value)} />
<ul>
{orders.map((o) => (
<OrderRow key={o.id} order={o} onSelect={handleSelect} onRender={onRender} />
))}
</ul>
<p>正在选择: {selectedId ?? '无'}</p>
</>
);
}
说明:
handleSelect的依赖为空,因为setSelectedId总是稳定的(React 为useState的 setter 保证这一点)。- 当你在备注框中输入时,
OrderList重新渲染。然而,每个OrderRow都接收相同的order、handleSelect和onRender,所以 memo 会跳过它们。 - 如果把
handleSelect改成普通的 arrow function,每一行都会在每次按键时重新渲染。
要记住的点:如果接收方没有 memo,useCallback 几乎没有用。 将已 memoized 的回调传递给 DOM 的 <button> 标签不会节省任何东西。
4.2. 将函数用作依赖
function useOrderPolling(customerId) {
const [orders, setOrders] = useState([]);
const refresh = useCallback(async () => {
const data = await fetchOrders({ customerId });
setOrders(data);
}, [customerId]);
useEffect(() => {
refresh();
const id = setInterval(refresh, 10_000);
return () => clearInterval(id);
}, [refresh]);
return { orders, refresh };
}
refresh 既在 effect 中使用,又被返回给外部组件。得益于 useCallback,interval 只在 customerId 改变时才重新创建。任何使用 refresh 作为依赖的组件都不会被无谓地重新运行。
这是编写自定义 hook时值得遵守的规则:返回出去的函数应该被 useCallback 包装。你无法提前知道 hook 的使用者是否会将其放入依赖中或传递给 memo。
5. 通过测试验证
与其相信 memo“肯定有效”,你可以为它编写测试。安装 Vitest 和 React Testing Library:
npm install -D vitest jsdom @testing-library/react
在 vite.config.js 中配置 jsdom 环境:
export default {
test: { environment: 'jsdom' },
};
4.1 章节中 OrderList 的测试:
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { OrderList } from './OrderList';
const orders = [
{ id: 1, customer: '兰' },
{ id: 2, customer: '明' },
];
describe('OrderList', () => {
it('仅输入备注时不重新渲染OrderRow', () => {
const onRender = vi.fn();
render(<OrderList orders={orders} onRender={onRender} />);
expect(onRender).toHaveBeenCalledTimes(2);
fireEvent.change(screen.getByLabelText('note'), { target: { value: '快递配送' } });
expect(onRender).toHaveBeenCalledTimes(2);
});
it('选择订单时仍然更新', () => {
render(<OrderList orders={orders} />);
fireEvent.click(screen.getByText('明'));
expect(screen.getByText('正在选择: 2')).toBeTruthy();
});
});
✓ OrderList > 仅输入备注时不重新渲染OrderRow
✓ OrderList > 选择订单时仍然更新
尝试删除 useCallback 并重新运行。第一个测试会失败,显示“expected 2 calls”…更确切地说是收到 4 次调用而不是 2 次。这是 hook 正在发挥作用的明确证据。在外部声明 onRender 并传递相同的引用,否则这个 prop 本身会破坏 memo。
除了测试,你应该使用 React DevTools 的 Profiler 标签,启用“Highlight updates”选项,以直观地看到哪些组件重新渲染。
6. 何时不应该使用
Memoization 不是免费的。React 必须保存旧值、保存依赖数组,并在每次渲染时比较它们。代码也变得更难理解。在以下情况下应该跳过它:
- 便宜的计算: 加几个数字,连接字符串,映射几十个元素。
- 原始值:
useMemo(() => a + b, [a, b])返回一个数字。数字已经按值比较,所以 memo 无法帮助。 - 将回调传递给 DOM 元素或未wrapped React.memo的子组件。
- 子组件仍然接收每次都改变的 prop。 例如
<OrderRow order={{ ...o }} />或传递 JSX 作为children。JSX 也是每次渲染时的新对象,所以 memo 总是会比较出差异。 - 可以通过重构而不是 memo 来解决。 将 state 移到更接近使用地方,或通过
children传递重组件,通常能更彻底地消除多余渲染。
注意: 不要依赖
useMemo来确保逻辑正确。React 有权清除缓存,例如在某些 Suspense 情况下或将来。你的代码必须正确运行,即使没有 memo。这个 Hook 只是性能优化。同样地,不要在useMemo中放置副作用,如 API 调用或写入localStorage。它们的位置应该在useEffect或事件处理程序中。
最常见的错误是缺少依赖,导致陈旧的闭包:
const handleSave = useCallback(() => {
saveOrder({ ...order, note });
}, [order]); // 缺少 `note`
handleSave 将始终使用第一次创建时的 note 值。启用 eslint-plugin-react-hooks 中的 react-hooks/exhaustive-deps 规则,并且不要关闭其警告。
7. React Compiler 改变了什么
React Compiler 从 2025 年底开始就已经稳定,它在构建时分析组件,并自动插入 memoization,其粒度比你手动编写的更细。在 Vite 或 Babel 项目中尝试:
npm install -D babel-plugin-react-compiler
当启用编译器时,你几乎不需要手动编写 useMemo、useCallback 或 memo。旧的 hook 仍然有效。当你需要精确控制 effect 的依赖时,它们很有用。编译器只能优化遵循 React 规则的代码:纯组件,不直接修改 props 或 state。因此,理解本文中的两个 hook 仍然很必要,以便阅读旧代码并在编译器跳过某个组件时进行调试。
8. 总结
useMemo 和 useCallback 解决同一个问题:在多次渲染之间保持引用稳定。它们在三种情况下真正值得使用:
- 计算被证明是昂贵的。
- 值或函数被传递到 wrapped React.memo 的组件。
- 值或函数在
useEffect或其他 hook 的依赖中,包括从自定义 hook 返回的函数。
在这些情况之外,保持代码简单。用 Profiler 测量,用测试证明,并考虑启用 React Compiler 让工具代替你做这部分工作。