React 性能优化三件套:useMemo / useCallback / memo
主题:React Hooks 难度:入门 → 进阶 阅读时间:约 45 分钟 依据版本:React 19.2 / React Compiler 1.0 更新:2026-09-19
1. 课程简介
很多人第一次学这三个东西,背下来的是三句话:「memo 包组件、useMemo 包值、useCallback 包函数」。背完之后依然不知道该不该写——于是走向两个极端:要么一个都不写,页面卡了不知道怎么查;要么见缝就包,代码里到处是依赖数组,反而更难维护。
根源在于:这三个 API 不是三个技巧,而是同一个问题的三种解法。那个问题叫做「重新渲染时,新造出来的东西算不算变了」。只要你真正理解 React 用什么标准判断「变了没」,什么时候用哪个、什么时候一个都不要用,就变成了可以推理的事情,而不需要靠记忆。
本讲义面向已经会写函数组件和基础 Hooks、但对性能优化还停留在「别人说加就加」阶段的开发者。阅读前你需要知道 useState 怎么用、什么是 props、什么是组件树。
💡 学习目标学完本讲义后,你将能够:
① 说清 React 判断「变了」的标准,并解释为什么「内容一样的对象」依然会引发重渲染;
② 针对具体场景判断该用 memo、useMemo 还是 useCallback,或者判断「这一个根本不用加」;
③ 避开依赖数组、自定义比较函数、过度缓存这三类高频坑;
④ 在 React Compiler 已经稳定的当下,决定自己的项目该手写还是交给编译器。
❗ 一句话先给你这三个 API 都是性能优化手段,不是功能实现手段。官方文档反复强调同一句话:「You should only rely on useMemo as a performance optimization.」如果你去掉
useMemo之后代码就跑不通了,那是代码本身有 bug,先修 bug,再加优化。
2. 为什么 React 会「白渲染」
先看一个很日常的现象:你在搜索框里敲一个字,列表重新筛选是应该的;但如果页面上那些跟搜索毫无关系的部分也跟着重算了一遍,那些计算就是白干的。
要搞清楚白干在哪,得先知道一次重新渲染里,React 到底按什么顺序做了什么。
2.1 一次重新渲染的四个环节
状态变更后,React 会走完下面四步。注意第 ② 步——这一步是整个讲义的关键。官方文档把这一过程归纳为三个阶段(Trigger 触发 / Render 渲染 / Commit 提交),这里把中间的 Render 拆成 ② ③ 两步写,是为了让你看清成本究竟产生在哪。

成本大头到底在哪一步
第 ② 步有两个代价,第二个常被忽略:
- 代价一:函数体里的计算真的会重跑。你在组件体内写的
rows.filter(...)、rows.sort(...)、复杂派生计算,每次渲染都会从头执行一遍。 - 代价二:函数体里新建的每一个对象、数组、函数,都是一个新身份。它们本身创建起来很便宜,但如果被当作 props 传下去,下游组件就会认为「东西变了」而跟着重渲染——这是 React 应用里绝大多数性能问题的真正来源。
下面这段代码把两个代价都暴露出来了。它在组件体内做了一次不便宜的统计,并在渲染时打一行日志:
import { useState } from 'react';
type Row = { id: number; amount: number };
// 假装这是一个"很贵"的派生计算:真实项目里 rows 可能有几千条
function computeStats(rows: Row[]) {
console.log('[compute] 开始统计');
return rows.reduce(
(acc, r) => {
acc.total += r.amount;
acc.max = Math.max(acc.max, r.amount);
return acc;
},
{ total: 0, max: 0 },
);
}
export default function Dashboard({ rows }: { rows: Row[] }) {
const [keyword, setKeyword] = useState('');
// ❌ 没有缓存:Dashboard 每重渲染一次(哪怕只是敲了一个字),这里都会重算
const stats = computeStats(rows);
console.log('[render] Dashboard 重新执行');
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<p>合计 {stats.total},最大 {stats.max}</p>
</div>
);
}
在搜索框里连敲两个字,控制台会是这样:
[render] Dashboard 重新执行
[compute] 开始统计
[render] Dashboard 重新执行 ← 只是敲了一个字,组件整个重跑了
[compute] 开始统计 ← 统计跟搜索框没有任何关系,白算了一遍
[render] Dashboard 重新执行
[compute] 开始统计 ← 又是白算的
💡 小技巧:先量,再优化「白渲染」不是靠读代码看出来的。给可疑组件加一行
console.log('[render] 组件名'),或者在 React DevTools 里打开 Highlight updates when components render,敲几下键盘看看谁在闪。先确认有浪费,再动优化——否则你是在猜。
2.2 引用相等:React 判断「变了没」的唯一标准
React 怎么知道一个值变了?答案很简单,也很容易被忽略:它用的是 Object.is 逐个比较。
Object.is 的规则可以浓缩成一句话:原始值比内容,对象、数组、函数比地址。
// 原始值:内容相同就是同一个
Object.is(3, 3); // true
Object.is('abc', 'abc'); // true
// 对象 / 数组 / 函数:比的是"引用地址",不是内容
Object.is({ a: 1 }, { a: 1 }); // false ← 内容一模一样!
Object.is([1, 2], [1, 2]); // false
Object.is(() => {}, () => {}); // false
const obj = { a: 1 };
Object.is(obj, obj); // true ← 同一个引用才算相等
关键在于:组件函数每重新执行一次,函数体里的对象字面量、数组字面量、箭头函数都会在内存里新建一份。内容完全一样,地址完全不同。

这就是所有困惑的源头。memo 会失效、useCallback 写了没用、useEffect 疯狂重跑——十有八九都是「新引用」在作祟。
❗ 重点把这句话刻进脑子里:React 眼里没有「内容一样的对象」,只有「同一个对象」和「不是同一个对象」。三个 API 做的事情,本质都是「把一个每次都是新的东西,变成只要没变就还是同一个」。
2.3 三个 API 的共同目标
既然问题只有一个,为什么需要三个 API?因为它们作用在三个不同的层次上:
| 你想稳定的东西 | 用哪个 | 它缓存的对象 | 典型信号 |
|---|---|---|---|
| 一整个组件的渲染结果 | memo |
组件本身(比较它的 props) | 父组件频繁重渲染,子组件很贵但 props 没变 |
| 一个计算结果(对象 / 数组 / 数字) | useMemo |
函数返回的值 | 计算很贵;或结果要作为 props / Effect 依赖传给下游 |
| 一个函数本身 | useCallback |
函数(不调用它) | 函数要传给 memo 子组件,或要放进 Effect 依赖数组 |
三者是配合关系,不是三选一。最典型的组合是:memo 负责「不重渲染」,useMemo 和 useCallback 负责「让 props 保持不变,从而使 memo 真的生效」。缺了后者,前者往往形同虚设。
⚠️ 常见坑只在子组件外面套一层
memo,父组件里照样传style={ { color: 'red' }}、onClick={() => doSomething()}——memo会 100% 失效,一次也不会跳过。第 3.3 节会用一张表把这种情况讲透。
3. memo:让组件跳过重渲染
先解决最容易理解的一个。默认情况下,父组件重渲染,所有子组件都会跟着重渲染,不管 props 有没有变。memo 就是用来打断这个传播的。
3.1 它是什么?怎么写?
memo 不是 Hook,它是一个高阶组件:吃进去一个组件,吐出来一个「记住版」组件。
import { memo } from 'react';
// 普通组件
function Greeting({ name }: { name: string }) {
console.log('[render] Greeting 执行了');
return <h3>Hello, {name}!</h3>;
}
// 记住版:父组件重渲染时,只要 name 没变,React 就跳过它
const MemoGreeting = memo(Greeting);
export default MemoGreeting;
官方更推荐的写法是就地包一层,省掉中间变量:
import { memo } from 'react';
const MemoGreeting = memo(function Greeting({ name }: { name: string }) {
return <h3>Hello, {name}!</h3>;
});
⚠️ 常见坑用就地包裹写法时,不要写成匿名函数。写
memo(() => { ... })会让这个组件在 DevTools 里显示为Anonymous,报错堆栈里也看不出是哪个组件。memo(function 名字(){...})里的函数名会被 React 用作品组件的 displayName。
它的工作机制:父组件重渲染时,React 拿到 MemoGreeting 的新旧两批 props,逐个用 Object.is 比较。全部相等 → 这次渲染直接跳过,连组件函数体都不会执行(console.log 不会打印)。只要有一个不等 → 照常重渲染。
3.2 判定规则:浅比较 + 两个例外
判定规则只有两条,但第二条经常被忘掉:
- 逐个比较 props,用
Object.is,而且是浅比较——只看第一层,不会递归比较对象内部。Object.is({}, {})是false,所以传新对象、新数组、新函数都会让memo失效。 - props 没变,也不代表一定不重渲染。下面两条路径
memo拦不住。

memo 拦不住的两种重渲染
这一条一定要记住,否则你会对着一个「明明包了 memo 却在重渲染」的组件排查半天:
const Greeting = memo(function Greeting({ name }: { name: string }) {
// ① 组件自己的 state 变了 —— memo 拦不住,一定会重渲染
const [greeting, setGreeting] = useState('Hello');
// ② 组件读取的 context 变了 —— memo 也拦不住
const theme = useContext(ThemeContext);
return (
<h3 className={theme}>
{greeting}, {name}!
<button onClick={() => setGreeting('Hi')}>换招呼语</button>
</h3>
);
});
❗ 重点
memo只负责回答一个问题:「

1780

被折叠的 条评论
为什么被折叠?



