useMemo 与 useCallback:何时优化,何时过度
第8章:useMemo 与 useCallback:何时优化,何时过度
useMemo 和 useCallback 是精准的性能工具,不是默认的编程习惯。过度使用比不用更有害。
本章核心问题:记忆化的真正价值在哪里?什么时候使用反而有害? 读完本章你将理解:
- 记忆化不是免费的——它用内存换 CPU,缓存命中率低时反而增加开销
- useMemo/useCallback 只有与 React.memo 或 useEffect 依赖配合时才有意义
- React Compiler 代表了自动记忆化的未来方向
Level 1 · 你需要知道的(1-3年经验)
记忆化的本质:用内存换 CPU
useMemo 和 useCallback 都是**记忆化(memoization)**技术:保存上一次计算的结果,下次调用时如果输入没变就直接返回缓存值,跳过重新计算。
// 没有 useMemo:每次渲染都重新过滤
const filteredList = items.filter(item => item.active);
// 有 useMemo:只有 items 改变时才重新过滤
const filteredList = useMemo(
() => items.filter(item => item.active),
[items]
);
// 没有 useCallback:每次渲染都创建新函数
const handleClick = () => doSomething(id);
// 有 useCallback:只有 id 改变时才创建新函数
const handleClick = useCallback(() => doSomething(id), [id]);
关键问题:记忆化不是免费的。它消耗内存来缓存结果,消耗 CPU 来比较依赖。如果缓存命中率低(依赖频繁变化),记忆化不仅没有收益,还增加了开销。
JavaScript 的引用相等性
要理解为什么 useMemo 和 useCallback 有时必要,必须先理解 JavaScript 的引用相等性。
// 原始值:值相等即相等
5 === 5 // true
'hello' === 'hello' // true
// 对象:引用相等才相等
{} === {} // false(两个不同的对象)
[] === [] // false(两个不同的数组)
(() => {}) === (() => {}) // false(两个不同的函数)
每次 React 渲染组件,函数体内的对象字面量、数组字面量和函数表达式都会创建全新的引用。即使内容完全相同,上次和这次创建的对象也不相等。
这在两个场景下造成问题:
场景一:React.memo 的子组件。React.memo 通过浅比较 props 来决定是否跳过渲染。如果一个 prop 是每次渲染都新建的函数或对象,浅比较永远不相等,React.memo 失效。
场景二:useEffect 的依赖数组。如果依赖是每次渲染都新建的对象,Effect 每次渲染都会重新运行,即使数据内容没变。
useMemo 真正有效的场景
场景一:稳定引用供 React.memo 子组件使用
function Parent({ userId }: { userId: string }) {
// 没有 useMemo:每次渲染都创建新对象,Child 的 memo 失效
const config = { theme: 'dark', locale: 'zh-CN' };
// 有 useMemo:config 引用稳定,Child 可以跳过渲染
const config = useMemo(() => ({ theme: 'dark', locale: 'zh-CN' }), []);
return <ExpensiveChild config={config} />;
}
const ExpensiveChild = React.memo(function ExpensiveChild({ config }) {
// 只有 config 引用改变时才重渲染
return <div>{/* 昂贵的渲染逻辑 */}</div>;
});
关键:useMemo 只有在和 React.memo(或 useEffect 依赖)配合时才有意义。单独使用 useMemo 来"防止重渲染"是无效的,因为父组件重渲染时子组件仍然会重渲染,不管 prop 是否变化(除非子组件被 React.memo 包裹)。
场景二:真正昂贵的纯计算
function DataAnalytics({ dataset, filters }) {
// 如果 dataset 有数万条记录,这个计算可能需要几百毫秒
const aggregated = useMemo(() => {
return dataset
.filter(row => matchesFilters(row, filters))
.reduce((acc, row) => aggregateRow(acc, row), createEmptyAggregation());
}, [dataset, filters]);
return <Chart data={aggregated} />;
}
如何判断计算是否"足够昂贵"? 用 console.time 测量:
console.time('aggregation');
const result = expensiveCompute(data);
console.timeEnd('aggregation');
// 如果超过 1ms,值得考虑 useMemo
// 如果超过 10ms,几乎肯定应该 useMemo
Level 2 · 它是怎么运行的(3-5年经验)
useCallback 真正有效的场景
useCallback(fn, deps) 等价于 useMemo(() => fn, deps)——它只是记忆化函数的语法糖。
场景一:传给 React.memo 子组件的回调
function TodoList({ todos, onToggle }) {
return todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle} // 如果 onToggle 每次都是新函数,TodoItem 的 memo 失效
/>
));
}
function Parent() {
const [todos, setTodos] = useState(initialTodos);
// 没有 useCallback:每次 Parent 重渲染,onToggle 都是新函数
const handleToggle = (id) => {
setTodos(prev => prev.map(t => t.id === id ? {...t, done: !t.done} : t));
};
// 有 useCallback:onToggle 引用稳定,TodoItem 能正确 memo
const handleToggle = useCallback((id) => {
setTodos(prev => prev.map(t => t.id === id ? {...t, done: !t.done} : t));
}, []); // 使用函数式更新,不依赖 todos
return <TodoList todos={todos} onToggle={handleToggle} />;
}
场景二:作为其他 Hook 的依赖
function useSearch(query: string) {
const [results, setResults] = useState([]);
// 如果 fetchResults 在每次渲染时都是新函数,Effect 每次都会重新运行
const fetchResults = useCallback(async (q: string) => {
const data = await api.search(q);
setResults(data);
}, []); // api 是稳定的外部引用
useEffect(() => {
fetchResults(query);
}, [fetchResults, query]);
return results;
}
React Compiler:自动记忆化时代来临
React 19 附带了 React Compiler(前称 React Forget),这是一个在编译期自动为 React 代码添加记忆化的 Babel/SWC 插件。
// 你写的代码
function TodoList({ todos, filter }) {
const filtered = todos.filter(t => t.category === filter);
const handleToggle = (id) => markDone(id);
return filtered.map(t => (
<TodoItem key={t.id} todo={t} onToggle={handleToggle} />
));
}
// React Compiler 自动转换后(概念示意,非实际输出)
function TodoList({ todos, filter }) {
const filtered = useMemo(() => todos.filter(t => t.category === filter), [todos, filter]);
const handleToggle = useCallback((id) => markDone(id), []);
return filtered.map(t => (
<TodoItem key={t.id} todo={t} onToggle={handleToggle} />
));
}
React Compiler 是否让手动记忆化过时?
理论上是的,实践中还不是。
React Compiler 正确工作的前提是代码遵循 React 的规则(Hooks 规则、纯渲染、不可变 state)。如果你的代码库有以下情况,Compiler 会保守地跳过优化:
- 直接修改 state 或 props(mutation)
- 不纯的渲染函数(有副作用)
- 违反 Hooks 规则
截至 2024 年底,React Compiler 处于 Release Candidate 阶段,Meta 在生产环境使用,但仍有不少边缘情况。建议:
- 新项目可以启用 React Compiler,同时减少手动记忆化
- 老项目逐步启用,用
"use no memo"指令排除问题组件 - 不要完全停止理解记忆化——Compiler 不是黑盒,理解原理有助于调试
性能优化的正确方法论
先测量,再优化。
// 1. 用 React DevTools Profiler 找出真正慢的组件
// 2. 用 why-did-you-render 找出不必要的重渲染
// 3. 确认重渲染确实影响用户体验(而不只是控制台日志)
// 4. 才考虑记忆化
决策框架
是否有性能问题?
├── 否 → 不要记忆化
└── 是 → 找出慢在哪里(Profiler)
├── 组件渲染太频繁 → 考虑 React.memo + useCallback
├── 单次渲染太慢 → 考虑 useMemo(确认计算确实昂贵)
├── 两者都有 → 先解决渲染频率,再优化单次渲染
└── 都没有 → 可能是网络、图片、字体等其他问题
真正有效的性能优化优先级
- 减少 state 提升:state 越高,重渲染范围越大
- 合理拆分组件:把变化的部分和稳定的部分分离
- 使用 key 优化列表渲染
- 代码分割和懒加载(
React.lazy、动态 import) - 虚拟列表(对长列表用
react-virtual、react-window) - 才是 useMemo/useCallback(通常影响有限)
Level 3 · 规范怎么定义的(资深)
一个完整的正确示例
// 这是一个合理使用记忆化的组件
function ProductCatalog({ products, categoryFilter, onAddToCart }) {
// ✓ 值得 memo:数据处理可能涉及大量数据
const filteredAndSorted = useMemo(() => {
return products
.filter(p => p.category === categoryFilter)
.sort((a, b) => b.rating - a.rating);
}, [products, categoryFilter]);
// ✓ 值得 callback:传给被 React.memo 包裹的 ProductCard
const handleAddToCart = useCallback((productId) => {
onAddToCart(productId);
}, [onAddToCart]);
// ✗ 不值得 memo:简单计算
const totalCount = filteredAndSorted.length; // 不需要 useMemo
return (
<div>
<p>共 {totalCount} 件商品</p>
{filteredAndSorted.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
))}
</div>
);
}
// 被 React.memo 包裹,handleAddToCart 的稳定性才有意义
const ProductCard = React.memo(function ProductCard({ product, onAddToCart }) {
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => onAddToCart(product.id)}>加入购物车</button>
</div>
);
});
Level 4 · 边界与陷阱(所有人)
常见的过度优化
过度优化一:给没有 memo 的子组件用 useCallback
function Parent() {
// 完全没有意义:Child 没有被 React.memo 包裹,
// 不管 handleClick 是不是稳定的,Parent 重渲染时 Child 都会重渲染
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <Child onClick={handleClick} />;
}
function Child({ onClick }) {
return <button onClick={onClick}>Click</button>;
// 没有 React.memo 包裹,每次 Parent 渲染 Child 都渲染
}
过度优化二:记忆化简单计算
// 完全没必要:数组长度是 O(1) 操作,比依赖比较本身还快
const count = useMemo(() => items.length, [items]);
// 同样没必要:简单的字符串拼接
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
// 直接在渲染期间计算就好
const count = items.length;
const fullName = `${firstName} ${lastName}`;
记忆化有开销——它需要存储上一次的结果和依赖,并在每次渲染时比较依赖。如果被记忆的计算比依赖比较更便宜,useMemo 反而拖慢了性能。
过度优化三:到处用 memo 和 callback
"既然可能有用,不如都加上" 是一种危险的心态。过度的记忆化:
- 增加代码噪音,降低可读性
- 增加维护成本(依赖数组需要随逻辑变化而更新)
- 在小组件上可能实际上更慢(记忆化开销 > 重渲染开销)
- 掩盖了真正的性能问题(应该用 profiler 找根因)