React 的渲染机制是:当组件的状态(state)或 props 变化时,组件函数会重新执行,生成新的虚拟 DOM。在大多数情况下,这种重新执行的开销可以忽略不计。但当组件树庞大、计算逻辑复杂或 props 频繁变化时,不必要的重渲染和重复计算可能成为性能瓶颈。React 提供了 useMemo 和 useCallback 两个 Hook,专门用于缓存计算结果和函数引用,避免不必要的开销。
8.2.1 useMemo:缓存计算结果
useMemo 用于记忆化(memoize)一个值。它接收一个“创建函数”和一个依赖项数组,只有在依赖项发生变化时才会重新计算该值;否则返回之前缓存的值。
基本语法:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
适用场景:
- 复杂的计算逻辑(如大量数据的排序、过滤、聚合)。
- 传递给子组件的引用类型数据(对象、数组),避免子组件因引用变化而触发不必要的重渲染(通常与
React.memo配合)。
示例:过滤大列表
function ProductList({ products, category }) {
// 只有当 products 或 category 变化时才重新过滤
const filteredProducts = useMemo(() => {
return products.filter(p => p.category === category);
}, [products, category]);
return (
<ul>
{filteredProducts.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
如果 products 包含数千条数据,每次渲染都进行过滤显然浪费。useMemo 确保只有在相关依赖变化时才重新执行过滤函数。
需要注意: useMemo 本身也有开销(存储缓存、比较依赖项)。如果计算本身很简单(如简单的数学运算或字符串拼接),使用 useMemo 反而可能降低性能。优化的第一原则是先度量,再优化,仅在确认为瓶颈时才使用。
8.2.2 useCallback:缓存函数引用
useCallback 本质上是 useMemo 的语法糖,专门用于记忆化函数。它返回一个记忆化的回调函数,该函数只有在依赖项变化时才会更新。
基本语法:
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
为什么需要缓存函数引用?
在 JavaScript 中,每次渲染时在组件内部定义的函数都会是一个全新的引用。如果这个函数作为 prop 传递给使用 React.memo 包裹的子组件,子组件会因 prop 的引用变化而重新渲染,即使函数逻辑完全没变。
示例:避免子组件无意义重渲染
const ChildButton = React.memo(({ onClick, label }) => {
console.log('Child render');
return <button onClick={onClick}>{label}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// 不使用 useCallback:每次 Parent 渲染,handleClick 都是新函数
// 即使 count 没变,ChildButton 也会因为 onClick prop 变化而重渲染
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []); // 依赖项为空,函数引用永远不变
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<ChildButton onClick={handleClick} label="增加" />
<p>计数:{count}</p>
</div>
);
}
这里 handleClick 通过 useCallback 缓存,因此即使在 text 变化导致 Parent 重渲染时,handleClick 的引用仍然保持不变,ChildButton 不会因 onClick 引用变化而无效重渲染。但请注意:这种优化只有在子组件使用 React.memo 或类似机制进行浅比较时才有效,否则子组件依然会正常重渲染。
8.2.3 useMemo 与 useCallback 的适用场景与误区
适用场景
| 场景 | 推荐 Hook | 说明 |
|------|-----------|------|
| 计算开销大的值(如复杂过滤、转换) | useMemo | 避免每次渲染都进行大量计算 |
| 传递给子组件的对象/数组 | useMemo | 配合 React.memo 避免子组件因引用变化重渲染 |
| 传递给子组件的回调函数 | useCallback | 同上 |
| 作为其他 Hook 的依赖(如 useEffect) | useCallback | 保持函数引用稳定,避免副作用频繁触发 |
常见误区
误区一:随处滥用 useMemo / useCallback
许多开发者为了“以防万一”在所有函数和对象上都包裹 useMemo / useCallback。这实际上增加了代码复杂度,且本身的内存和比较成本可能超过优化收益。React 官方建议:仅在确实遇到性能问题时才使用这些优化。大多数应用中,组件的重渲染开销是微不足道的。
误区二:忽略依赖项或依赖项不完整
依赖项数组必须包含在回调中用到的所有响应式值(state、props)。如果遗漏依赖项,闭包中会捕获过期的变量,导致难以发现的 bug。
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log(count); // 总是打印初始值 0,因为依赖项为空
}, []); // 缺少 count
ESLint 的 react-hooks/exhaustive-deps 规则可以自动检查依赖项完整性,务必启用。
误区三:认为 useCallback 总是阻止子组件渲染
useCallback 本身并不阻止子组件渲染,它只是提供一个稳定的函数引用。子组件必须被 React.memo 包裹,且没有其他变化的 props,才会跳过渲染。否则,即使函数引用不变,其他 props 或 state 的变化仍会触发子组件更新。
误区四:在不需要记忆化的场景中使用
例如,如果子组件本身很简单(如一个原生 <button>),没有用 React.memo 包裹,那么传递稳定的 onClick 引用不会带来任何性能提升,因为子组件无论如何都会随父组件重渲染。
最佳实践
- 先写清晰代码,再考虑优化:先确保代码正确、可读,然后通过 React DevTools Profiler 或浏览器性能面板定位真正的渲染瓶颈,再针对性地添加记忆化。
- 传递引用类型给
React.memo子组件时:使用useMemo处理对象/数组,useCallback处理函数。 - 利用
useMemo做昂贵计算的缓存:如果计算过程耗时明显(可以通过console.time确认),再包装useMemo。 - 严格遵循依赖项规则:不要欺骗 Hook 的依赖检查,确保所有变量都出现在依赖项数组中。
性能优化 Hooks 是 React 性能工具箱里的精确手术刀,但不是日常开发的万金油。合理使用它们可以让应用保持流畅,但过度使用只会让代码“过度工程化”。牢记:测量、定位、再优化。