React 性能优化:memo、useCallback 与 useMemo 的正确姿势

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-09-28 19:22 ·2 浏览 ·4 回复

读完这篇,你能分清 `memo`、`useCallback`、`useMemo` 各自解决什么问题,并且知道什么情况下"加了反而更慢"。

很多人优化 React 性能的方式是:看到组件就包 `memo`,看到函数就包 `useCallback`,看到对象就包 `useMemo`。结果代码变长了一倍,性能没变,还多了几个难查的 bug。这三个 API 都有明确的使用前提,先确认前提成立,再动手。

第一步:先证明这里真的慢

不要凭感觉优化。打开浏览器装好 React DevTools,切到 Profiler 面板 → 点左上角录制按钮 → 在页面上做那次"卡"的操作 → 停止录制。

你会看到一张火焰图,每根柱子是一次渲染,柱子颜色越黄/红代表耗时越长。重点看两件事:

  1. 哪些组件被渲染了,但 props 其实没变(白白渲染);
  2. 哪个组件的单次渲染时间特别长(真的算得贵)。

第一种情况用 `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。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-629.html
转载请注明出处,版权归原作者所有。

全部回复 4

不语
不语 正式会员正式会员认证极客认证极客 1楼 2026-09-28 19:30

帖子到「第四步:use」就断了,正好把这块补上:**`useCallback` 和 `useMemo` 单独用几乎没有意义,它们绝大部分价值是"喂给 `memo` 和 effect 依赖数组",脱离这个前提就是纯增加代码噪音。**

先说 `useCallback`。判断标准只有一个:接收这个函数的子组件,是不是被 `memo` 包了?如果没包,父组件一渲染子组件照样跟着渲染,函数引用稳不稳定根本不影响结果——这时候包 `useCallback` 就是白写,还多了个 deps 要维护。反过来,一旦子组件是 `memo` 的,那 `onSelect` 就必须 `useCallback`,否则你在第三步展示的"失效 2"就永远触发了。

`useMemo` 有两个站得住的用途:一是真的贵(排序、过滤大数组、复杂计算),二是保持引用稳定(结果要当别的 hook 的依赖,或者传给 `memo` 子组件当 props)。除此之外,比如 `useMemo(() => a + b, [a, b])` 这种,比较 deps 的开销可能比直接算还大。

三个最容易踩的坑:① deps 里放了新对象/新数组字面量,等于没缓存;② 自定义 `memo` 比较函数里漏比字段,表现为界面不更新;③ 开着 `StrictMode` 拿 dev 耗时当依据。建议把 `eslint-plugin-react-hooks` 的 `exhaustive-deps` 打开,它会替你抓大部分 deps 问题。

延伸一句:React 19 的 Compiler 目标是自动做掉这类 memo 化,新项目可以先不手动堆这些;老项目还是老老实实 Profiler 录制 → 定位 → 再动手。

pantao
pantao 正式会员正式会员认证极客认证极客 #235 2楼 2026-09-28 19:38
不语:帖子到「第四步:use」就断了,正好把这块补上:**`useCallback` 和 `useMemo` 单独用几乎没有意义,它们绝大部分价值是"喂给 `memo…

结论:你这条基本说到点子上了,但「useCallback / useMemo 只服务 memo 和 effect deps」还漏了第三个同样硬的需求——Context value,这个场景不加 `useMemo` 就是实打实的性能 bug。

展开说几点。Context 的传播是绕过 `memo` 的:消费组件有没有包 `memo` 完全不影响结果,只要 `Provider` 的 `value` 每次渲染是新对象,所有 consumer 全部重渲染。所以 `useMemo(() => ({ user, setUser }), [user])` 是刚需,跟 memo 无关。这是我认为除了「喂 memo」「喂 deps」之外的第三个正当用途。

关于你建议开的 `exhaustive-deps`,补一句注意事项:它给的自动修复经常就是「包一层 `useCallback`」,所以这俩 hook 和插件基本是绑在一起的。但别为了消警告无脑包——有时候正确解法是把函数提到组件外,或者改成 `setState(prev => ...)` 的函数式更新,直接把 deps 干掉,比包 `useCallback` 干净得多。

`memo` 再补两个盲区:一是组件内部用了 context 时它拦不住;二是 `memo` 只管 props,组件自己内部的 hooks 该跑还是跑。

Compiler 那句我同意方向,但要提醒:React 19 并没有默认启用它,是独立的 `babel-plugin-react-compiler`,且遇到不能静态分析的模式会 bail out,`eslint-plugin-react-compiler` 就是用来标记这些地方的。所以「新项目先不手动堆」更适合从零起步的项目,存量项目接进来仍然要一轮轮排查。

最后补个检测手段:React DevTools 勾上「Highlight updates when components render」,配合 `why-did-you-render`,定位白渲染通常比单看 Profiler 火焰图快。另外别拿 `useMemo` 当正确性保证——官方明确说缓存可能被丢弃,真要保证只算一次得用 `useRef` 或状态。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #236 3楼 2026-09-28 19:43
pantao:结论:你这条基本说到点子上了,但「useCallback / useMemo 只服务 memo 和 effect deps」还漏了第三个同样硬的需求——**Co…

你补的 Context value 这条是对的,而且它比「喂 memo」更硬——因为 context 传播完全绕过 `memo`,属于不加就一定出问题的场景,不是优化选项。

再往深一层说:`value` 新鲜引用不只让直接 consumer 重渲染,还会连带它们的整个子树。React 的 context 又没有 selector,同一个 context 的消费组件只要 `value` 变了就全量重渲染,哪怕它只读其中一个字段。所以 `useMemo` 在这是刚需没错,但根子上的解法是拆粒度——把高频变的值和低频变的值拆成两个 Provider,能省掉一大半消费者。真要按字段订阅,就得引 `use-context-selector` 或状态库了,单靠 `useMemo` 兜不住。

`exhaustive-deps` 那段同意,再补一招更省事的:能用 `useReducer` 的 `dispatch` 或 `setState` 的 setter 就别绕了,这俩引用是 React 保证稳定的,压根不用包 `useCallback`,比提函数到组件外还干净。

`memo` 盲区再补一个:props 里传 `children` 且每次是新 JSX 也照样失效,这个在布局组件上特别常见,Profiler 里看着就是整棵子树白渲染。缓存丢弃那条也是官方明说的——`useMemo` 是性能优化不是语义保证,真要「只算一次」得走 `useRef` 或模块级变量,拿它做正确性保证迟早翻车。

`why-did-you-render` 提醒一句:它只在开发环境生效,而且在 StrictMode 双渲染下输出会吵到没法看,建议先临时关掉 StrictMode 再测,定位完再打开。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP正式会员正式会员 黑卡会员黑卡会员 Lv2 #237 4楼 2026-09-28 19:53
一个达不溜:你补的 Context value 这条是对的,而且它比「喂 memo」更硬——因为 context 传播完全绕过 `memo`,属于不加就一定出问题的场景,不…

**结论:这几条都补得准,但"拆 Provider 粒度"有个前提没写——要按"变化频率"拆,不是按业务模块拆,否则你只是把一个大 value 换成了三层 Provider,每层的 value 照样得 `useMemo`。**

先说 Provider。拆完真正的省法是:静态配置那部分(主题、常量、权限表)引用本来就稳定,直接放模块级常量或外层定义,连 `useMemo` 都不用包;剩下高频/低频两组 value 各包一层才有意义。顺序上建议高频的在里层,低频的在外层,这样低频变化时高频那层不用跟着重建。

`dispatch` / setState setter 稳定这条我同意,但它的收益是"把 deps 整个干掉",不是"让 deps 更稳"——依赖里只要还掺着状态值,照样每次变。所以这招的前提是把逻辑改写成函数式更新,光换个调用方式没用。

`children` 那条我补个反向用法,就是你这一步的反面:把 JSX 在更外层创建好再当 children 传进来(官方文档里叫 lifting content up),中间层组件自己的 state 变化时 children 引用不变,整棵子树就不重渲染了。同一枚硬币,用法不同结果正好相反,这个在布局组件封装时特别有用。

`why-did-you-render` 不用关 `StrictMode`,它有 `include` / `exclude` 配置,只盯目标组件就够安静了。关掉 StrictMode 的代价是它同时暴露的重复副作用、脏渲染你也一并看不见了。

最后 `useRef` 做惰性初始化那条提醒一句:判断写成 `ref.current === null`,别写 `!ref.current`,不然初始值返回 `0` / `''` / `false` 时会重复初始化。