读完这篇,你能分清 `memo`、`useCallback`、`useMemo` 各自解决什么问题,并且知道什么情况下"加了反而更慢"。
很多人优化 React 性能的方式是:看到组件就包 `memo`,看到函数就包 `useCallback`,看到对象就包 `useMemo`。结果代码变长了一倍,性能没变,还多了几个难查的 bug。这三个 API 都有明确的使用前提,先确认前提成立,再动手。
第一步:先证明这里真的慢
不要凭感觉优化。打开浏览器装好 React DevTools,切到 Profiler 面板 → 点左上角录制按钮 → 在页面上做那次"卡"的操作 → 停止录制。
你会看到一张火焰图,每根柱子是一次渲染,柱子颜色越黄/红代表耗时越长。重点看两件事:
- 哪些组件被渲染了,但 props 其实没变(白白渲染);
- 哪个组件的单次渲染时间特别长(真的算得贵)。
第一种情况用 `memo` + `useCallback`,第二种用 `useMemo`。如果两样都没有,说明瓶颈在别处——网络、列表没虚拟化、CSS 布局抖动都有可能。
注意:开发环境(StrictMode + 未压缩)的耗时普遍是生产环境的 3~5 倍,别拿 dev 的数字下结论。要用生产构建(`npm run build` 后本地起服务)再录一次。
第二步:记住三者的分工
一句话区分:
- `React.memo(Component)`:缓存组件。props 浅比较相等就跳过渲染。
- `useCallback(fn, deps)`:缓存函数。deps 不变就返回同一个函数引用。
- `useMemo(() => value, deps)`:缓存值。deps 不变就返回同一个对象/数组/计算结果。
看出来了吗——`useCallback` 和 `useMemo` 存在的很大一部分理由,就是为了配合 `memo`。因为 `memo` 做的是浅比较,父组件每次渲染时新建的箭头函数、新写的对象字面量,都会让浅比较直接失败。
第三步:memo 的正确姿势
const Row = React.memo(function Row({ item, onSelect }) {
return <div onClick={() => onSelect(item.id)}>{item.name}</div>;
});
适用条件:组件是纯展示的、props 简单、且会被频繁重渲染(比如长列表的每一项、动画期间每帧都更新的父组件下的子节点)。
三种常见失效场景:
// 失效 1:传了新对象
<Row style={{ color: 'red' }} />
// 失效 2:传了新函数
<Row onSelect={(id) => setActive(id)} />
// 失效 3:传了新 JSX 作为 children
<Row><span>abc</span></Row>
这三个每次渲染都是全新引用,浅比较必然为 false。
需要更精细的控制时可以传第二个参数:
React.memo(Row, (prev, next) => prev.item.id === next.item.id)
注意:自定义比较函数写错了比不写更糟——它会静默地阻止更新,表现为"数据变了但界面不动"。能用浅比较就尽量别手写。
第四步:useCallback 的正确姿势
const handleSelect = useCallback((id) => {
setActive(id);
}, []); // 没有依赖,引用永远不变
<Row key={item.id} item={item} onSelect={handleSelect} />
**只有把函数传给 `memo` 子组件,或者放进 `useEffect` 依赖数组时,`useCallback` 才有意义。**
如果这个函数只用在普通 DOM 元素上(`<button onClick={fn}>`),包不包 `useCallback` 对性能没有任何影响——DOM 元素不是组件,不会因为函数引用变了而重渲染。
依赖数组必须写全。别用 `// eslint-disable-next-line react-hooks/exhaustive-deps` 糊过去,那会导致闭包捕获旧值:
const [count, setCount] = useState(0);
// 错误:count 永远是 0
const log = useCallback(() => console.log(count), []);
正确做法是把 `count` 加进依赖,或者改用函数式更新 `setCount(c => c + 1)` 从根上消除依赖。
注意:`useCallback(fn, deps)` 等价于 `useMemo(() => fn, deps)`。如果你已经用了 `useMemo` 返回一个函数,就别再套一层 `useCallback`。
第五步:useMemo 的正确姿势
真实有效的场景只有三类:
1. 计算确实昂贵(大数组排序/过滤、复杂格式化):
const sorted = useMemo(
() => bigList.slice().sort((a, b) => b.score - a.score),
[bigList]
);
"昂贵"的判断标准:Profiler 里这段逻辑单次超过 1ms。几百个元素的 `map` 通常不值得。
2. 需要稳定的引用,传给 `memo` 子组件或作为其它 Hook 的依赖:
const config = useMemo(() => ({ page, size }), [page, size]);
useEffect(() => { fetchList(config); }, [config]);
3. 稳定 Context 的 value,这是收益最大的一处:
const value = useMemo(() => ({ user, logout }), [user, logout]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
不写这一行的话,Provider 每次渲染都会通知所有消费组件重渲染,哪怕 `user` 根本没变。
注意:`useMemo` 只是性能提示,不是语义保证。React 在内存紧张时可以丢弃缓存重新计算。所以千万不要把有副作用的代码、或者"必须只执行一次"的逻辑放进 `useMemo`——那属于 `useEffect` 的职责。
第六步:如果你在用 React 19
React Compiler 已经能自动完成上述大部分记忆化,开启后手写 `useCallback`/`useMemo` 的收益大幅下降。但在编译器覆盖不到的边界(比如跨组件传递的 Context value、第三方库回调)仍然需要手动处理。另外,编译器无法修好"组件拆分不合理""状态放得太高"这类结构问题——那才是性能问题的真正来源。
小结
- 先开 Profiler 测量,确认是"白渲染"还是"真算得贵",再选工具。
- `memo` 缓存组件、`useCallback` 缓存函数、`useMemo` 缓存值;后两者主要是为前者的浅比较服务。
- 函数只绑到 DOM 元素上时,`useCallback` 纯属多余。
- `useMemo` 值得写的三种情况:计算 >1ms、需要稳定引用、Context value。
- 依赖数组必须写全,不要用 eslint 注释绕过。
- `useMemo` 不保证执行,别在里面放副作用。
- 结构问题(状态位置、组件拆分、列表虚拟化)优先于这三个 API。