6.1 React.memo 与 useMemo / useCallback 的配合
直觉锚定
想象一个公司的审批流程。每个部门提交报告给总经理审批。如果某个部门的报告内容没有任何变化,聪明的做法是直接跳过这个部门,不用重新审批。
但这里有个陷阱:即使报告内容没变,如果换了新的封面(新的对象引用),总经理还是会认为"这是一份新报告",重新审批一遍。
映射:
- 部门 = 子组件
- 报告 = props
- 审批 = 重新渲染
- 跳过审批 = React.memo 的 bailout
- 换了封面但内容没变 = 传了新的对象/函数引用(如
style={{}}或onClick={() => ...}) - memo + useMemo/useCallback = 既保证内容不变,又保证封面不变(引用不变)
问题背景
React 的默认行为是:父组件重新渲染时,所有子组件都会重新渲染,不管子组件的 props 有没有变化。这在 React 的渲染模型里是合理的——React 不知道哪些 props 对子组件的输出有影响,所以必须全部跑一遍。
但对于以下场景,这种默认行为是浪费的:
function Parent({ data }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<ExpensiveList items={data} /> {/* props 完全没变,但每次 count 变都重渲染 */}
</div>
);
}
count 变化 → Parent 重渲染 → ExpensiveList 也跟着重渲染,但它的 props(items)完全没变。React.memo 就是为了解决这个问题。
⚠️ 常见先入为主的误解: 很多人以为
React.memo和useMemo是同一个东西的不同用法。实际上它们 memoize 的目标完全不同:
React.memo:memoize 组件(跳过渲染)useMemo:memoize 值(避免重复计算)useCallback:memoize 函数(保持引用稳定)三者解决的是不同层面的问题,但经常需要配合使用。
核心数据结构
React.memo 的内部表示
// react@18.3.1 · packages/react/src/memo.js
export function memo(type, compare) {
return {
$$typeof: REACT_MEMO_TYPE, // 标记这是一个 memo 组件
type: type, // 原始组件函数
compare: compare === undefined ? null : compare // 自定义比较函数
};
}
memo 返回的不是组件,而是一个描述对象(React Element 类型的一种)。这个对象的 type 指向原始组件,compare 存储自定义比较函数(默认为 null,使用浅比较)。
bailout 的判断路径
在 Fiber 的 beginWork 中,有一条 bailout 路径:
FiberNode {
pendingProps: object // 新 props
memoizedProps: object // 上次渲染的 props
type: REACT_MEMO_TYPE // memo 组件的标志
}
useMemo / useCallback 的 Hook 结构
Hook {
memoizedState: {
// useMemo: [value, deps]
// useCallback: [callback, deps]
[0]: cachedValue / cachedCallback
[1]: depsArray
}
}
每个 useMemo/useCallback 调用在 Hooks 链表上占一个节点,存储缓存的值和依赖数组。update 时比较新旧依赖。
运行流程
React.memo 的 bailout 判断
当 beginWork 遇到 memo 组件时(react@18.3.1 · ReactFiberBeginWork.js):
// 简化自 updateMemoComponent
function updateMemoComponent(current, workInProgress, nextProps, renderLanes) {
// ① 检查是否有 pending 的更新或 context 变化
if (updateDidRenderWhileSuspended || hasUnprocessedWork) {
// 有更新要处理,不能 bailout
} else {
// ② 比较 props
var prevProps = workInProgress.memoizedProps;
var compare = workInProgress.type.compare; // memo 的第二个参数
var shouldUpdate;
if (compare !== null) {
// 有自定义比较函数 → 调用它
shouldUpdate = compare(prevProps, nextProps);
} else {
// 默认浅比较
shouldUpdate = !shallowEqual(prevProps, nextProps);
}
if (!shouldUpdate) {
// ③ props 没变 → bailout!
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
}
// ④ props 变了 → 正常渲染
return updateFunctionComponent(null, workInProgress, ...);
}
shallowEqual 的实现(packages/shared/shallowEqual.js):
function shallowEqual(objA, objB) {
if (Object.is(objA, objB)) return true; // 同一引用直接返回
if (typeof objA !== 'object' || typeof objB !== 'object') return false;
var keysA = Object.keys(objA);
var keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
for (var i = 0; i < keysA.length; i++) {
if (!Object.is(objA[keysA[i]], objB[keysB[i]])) return false;
// ↑ 逐个属性用 Object.is 比较(=== + NaN 处理)
}
return true;
}
浅比较被"骗"的场景
const MemoChild = React.memo(function Child({ onClick, style, items }) {
return <div onClick={onClick} style={style}>{items.length}</div>;
});
function Parent() {
const [count, setCount] = useState(0);
return (
<MemoChild
onClick={() => console.log(count)} // 每次渲染新函数!
style={{ color: 'red' }} // 每次渲染新对象!
items={[1, 2, 3]} // 每次渲染新数组!
/>
);
}
每次 Parent 渲染:
() => console.log(count)→ 新函数引用 →Object.is失败{ color: 'red' }→ 新对象引用 →Object.is失败[1, 2, 3]→ 新数组引用 →Object.is失败
三个 props 全部"变了",memo 的 bailout 失效,Child 每次都重新渲染。
useMemo / useCallback 修复引用稳定性
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => console.log(count), [count]);
const style = useMemo(() => ({ color: 'red' }), []);
const items = useMemo(() => [1, 2, 3], []);
return <MemoChild onClick={handleClick} style={style} items={items} />;
}
useCallback 的 update 路径(简化自 react@18.3.1 · ReactFiberHooks.js):
function updateCallback(callback, deps) {
var hook = updateWorkInProgressHook();
var nextDeps = deps === undefined ? null : deps;
var prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
var prevDeps = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
// 依赖没变 → 返回缓存的函数
return prevState[0];
}
}
}
// 依赖变了 → 存新的函数
hook.memoizedState = [callback, nextDeps];
return callback;
}
function areHookInputsEqual(nextDeps, prevDeps) {
for (var i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) continue;
return false;
}
return true;
}
useMemo 的逻辑完全一样,只是存储的是计算结果而非函数。
完整的 bailout 判断路径
Parent 重渲染(count 变了)
│
▼
beginWork(MemoChild)
│
├── MemoChild.memoizedProps vs MemoChild.pendingProps
│
├── handleClick: 同一引用(useCallback 缓存)✓
├── style: 同一引用(useMemo 缓存)✓
├── items: 同一引用(useMemo 缓存)✓
│
└── shallowEqual → true → bailout!
└── 跳过 MemoChild 及其整个子树的渲染
设计动机与权衡
为什么默认浅比较而不是深比较?
深比较(递归比较对象的每一层)有两个问题:
- 性能不确定:如果 props 是一个深层嵌套的大对象,深比较本身可能比渲染还慢
- 语义模糊:深比较无法区分"需要引用稳定"的场景(如 DOM ref、event handler)
浅比较是 O(n)(n = props 的 key 数量),通常 key 数量少且比较都是 ===,非常快。代价是开发者需要手动稳定引用(useMemo/useCallback),这把性能优化的责任交给了开发者。
useCallback 和 useMemo 的依赖比较值得吗?
每次渲染都要执行 areHookInputsEqual(遍历依赖数组做 Object.is)。如果依赖数组有 5 个元素,就是 5 次 === 比较。这比重新创建函数/值要便宜得多,但不是免费的。
| 操作 | 开销 |
|---|---|
() => {} 创建新函数 | 极低(~纳秒级) |
areHookInputsEqual([a,b,c], [a,b,c]) | 极低(3次 ===) |
| 子组件完整渲染(含 diff + commit) | 高(毫秒级) |
所以 useCallback/useMemo 的优化是否值得,取决于子组件渲染的成本。如果子组件是轻量的,优化反而可能增加开销。
次级误解和边界
误解 1:"React.memo 只比较第一层 props"
严格说是对的,但更准确的说法是:React.memo 比较的是 props 对象的每个顶层属性的引用。如果一个 prop 是对象(如 user: { name: 'Alice' }),memo 比较的是 user 这个引用是否变了,不会深入比较 user.name。所以即使 user.name 没变,只要传了新的 user 对象,memo 就认为 props 变了。
误解 2:"useCallback(fn, []) 里的 fn 永远不会变"
函数体内的闭包变量会捕获创建时的值。如果 fn 内用了 count,而 useCallback 创建时 count 是 0,那么无论 count 怎么变,这个回调里的 count 永远是 0——除非你把 count 加到依赖数组里。但加了依赖,count 变时 useCallback 就返回新函数了。这是 useCallback 的根本矛盾:引用稳定 vs 闭包新鲜度。
误解 3:"所有子组件都应该包 memo"
不应该。memo 本身有开销(shallowEqual 比较),而且需要配合 useMemo/useCallback 才能真正生效。如果组件本身渲染很快(< 1ms),加 memo 反而增加代码复杂度和少量运行时开销。只在性能测量确认有瓶颈时才加 memo。
现在我们知道了 React.memo 通过浅比较 props 实现 bailout,但需要 useMemo/useCallback 配合稳定引用。但这里有一个问题:Context 的 consumer 组件,即使用了 memo,也会在 context 值变化时强制重渲染。这个性能陷阱怎么解决?
这就是 6.2 Context 性能问题与优化 要回答的事情。
6.2 Context 性能问题与优化
直觉锚定
想象一个公司通过公共广播系统发布通知。只要广播一响,所有员工都停下来听——即使广播的内容和你无关(比如"食堂今日菜单更新",你是自带午饭的)。
Context 就是这个公共广播系统:
- 广播 = Provider 的 value 变化
- 所有员工 = 所有 useContext 的消费者
- 无关内容也要停下来 = 即使你只用了 context 10 个字段中的 1 个,另外 9 个变了你也得重渲染
React.memo 在这里是拦不住的——因为 Context 的传播机制在 memo 的 bailout 之前就标记了消费者。
问题背景
先看一个典型的 Context 性能陷阱:
const AppContext = createContext({ user: null, theme: 'light', locale: 'zh' });
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
// 每次 state 变化 → 新对象 → 所有 consumer 重渲染
return (
<AppContext.Provider value={{ user, theme, locale: 'zh' }}>
<ThemeButton /> {/* 只用 theme */}
<UserProfile /> {/* 只用 user */}
<ExpensiveTree /> {/* 三者都不用,但在这里面 */}
</AppContext.Provider>
);
}
切换 theme 时:
ThemeButton需要重渲染 ✓(用了 theme)UserProfile不需要重渲染 ✗(只用了 user)→ 但也重渲染了ExpensiveTree不需要重渲染 ✗(没用 context)→ 也重渲染了
问题出在两处:
- Provider 的 value 每次都是新对象(
{ user, theme, locale: 'zh' }) - Context 的变化传播是全量广播,没有选择性订阅
核心数据结构
Context 在 Fiber 树中的传播涉及三个关键结构:
FiberNode {
dependencies: ContextDependency | null // 该节点订阅了哪些 context
firstContext: ContextDependency | null // dependencies 链表头
}
ContextDependency {
context: ReactContext // 指向哪个 context 对象
next: ContextDependency // 链表下一个
memoizedValue: any // 上次消费时的值
}
ReactContext {
_currentValue: any // Provider 提供的当前值
_currentValue2: any // 用于多渲染器场景
}
Provider 的 Fiber 节点:
FiberNode(Provider 类型){
type: ReactContext // 关联的 context 对象
pendingProps: { value } // 新的 value
memoizedProps: { value } // 旧的 value
}
运行流程
Context 变化传播的完整流程
① Provider 的 value 变了(新旧 props.value 不一致)
│ (在 beginWork 中检测,ReactFiberBeginWork.js · updateContextProvider)
│
▼
② propagateContextChange(workInProgress, context, changedBits)
│ (ReactFiberNewContext.js)
│
▼
③ 从 Provider 向下深度遍历子树
对每个 Fiber 节点:
├── 有 dependencies 且包含此 context?
│ └── 是 → 标记该 Fiber 需要更新(加入 workInProgress 的更新队列)
│
├── 是 memo / lazy 组件?
│ └── 是 → 标记其子树也需要传播(不能被 memo 拦截)
│ 重置 propagate = true
│
└── 继续向子节点传播
关键源码(简化自 react@18.3.1 · ReactFiberNewContext.js):
function propagateContextChange(
workInProgress, context, changedBits, renderLanes
) {
var fiber = workInProgress.child;
while (fiber !== null) {
var list = fiber.dependencies;
if (list !== null) {
// 检查这个 fiber 是否订阅了此 context
var dependency = list.firstContext;
while (dependency !== null) {
if (dependency.context === context) {
// 匹配到了!标记这个 fiber 需要处理
fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
// 同时向上冒泡 childLanes
var alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, renderLanes);
}
// 标记 scheduleUpdateOnFiber
scheduleWorkOnParentPath(fiber.return, renderLanes);
break;
}
dependency = dependency.next;
}
}
fiber = fiber.child; // 继续向下遍历
}
}
为什么 memo 拦不住?
关键在传播阶段:propagateContextChange 遍历到 memo 组件时,不会因为 memo 而停止。它会继续向下穿透,标记所有订阅了该 context 的消费者。
在后续的 beginWork 中:
// ReactFiberBeginWork.js
function updateMemoComponent(current, workInProgress, nextProps, renderLanes) {
// ...
// 检查 props
var shouldUpdate = !shallowEqual(prevProps, nextProps);
// 但如果 dependencies 被标记了 context 变化
if (hasContextChanged()) {
shouldUpdate = true; // ← 强制重渲染,忽略 props 比较
}
if (!shouldUpdate) {
return bailoutOnAlreadyFinishedWork(...);
}
// 正常渲染
}
即使 memo 的 props 比较通过了,只要 context 变了,消费者组件仍然会重渲染。
不订阅 context 的组件呢?
如果一个组件既没有 useContext,也没有 contextType,它的 dependencies 为 null。propagateContextChange 遍历到它时会发现 list === null,跳过标记。
但遍历本身还是要走的——React 必须走到每个 fiber 检查它是否订阅了 context。这是一次 O(n) 的遍历(n = Provider 子树的 fiber 节点数),虽然不触发渲染,但遍历有成本。
优化方案
方案 1:useMemo 稳定 Provider value
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
// ✅ 只在内容变化时才产生新对象
const value = useMemo(() => ({ user, theme, locale: 'zh' }), [user, theme]);
return (
<AppContext.Provider value={value}>
...
</AppContext.Provider>
);
}
这解决了"内容没变但引用变了"的问题。但没解决根本问题——theme 变了,只用了 user 的 UserProfile 还是会重渲染。
方案 2:拆分 Context
const ThemeContext = createContext('light');
const UserContext = createContext(null);
const LocaleContext = createContext('zh');
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
<LocaleContext.Provider value="zh">
<ThemeButton /> {/* 只订阅 ThemeContext */}
<UserProfile /> {/* 只订阅 UserContext */}
<ExpensiveTree /> {/* 不订阅任何 context,不重渲染 */}
</LocaleContext.Provider>
</UserContext.Provider>
</ThemeContext.Provider>
);
}
每个 Context 独立传播。theme 变化只影响 ThemeButton,不影响 UserProfile。
方案 3:children 模式(避免中间组件重渲染)
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
{/* children 作为 prop 传入,不会因为 Provider 重渲染而重建 */}
<Layout>
<ExpensiveTree />
</Layout>
</ThemeContext.Provider>
);
}
function Layout({ children }) {
const theme = useContext(ThemeContext);
// Layout 重渲染,但 children 是同一个 React Element 引用
// React 看到 children 没变 → 不重渲染 ExpensiveTree
return <div className={theme}>{children}</div>;
}
children 是在 App 中创建的 React Element,作为 prop 传给 Layout。Provider value 变化导致 Layout 重渲染时,children prop 的引用不变(因为 App 没有重渲染)。Layout 的 JSX 中 {children} 指向同一个 Element → ExpensiveTree 不重渲染。
方案 4:选择器模式
// 使用 useSyncExternalStore 实现选择器
function useTheme() {
return useContext(ThemeContext);
}
// 或者用第三方库如 zustand 的 selector
// const theme = useStore(state => state.theme);
React 的 Context 本身不支持 selector(不能只订阅 context 的某个字段)。如果需要这个能力,要借助状态管理库(Zustand、Jotai 等)或 useSyncExternalStore 自行实现。
设计动机与权衡
为什么 React 不内置 Context selector?
React 团队曾尝试实现(useContextSelector RFC),但发现以下问题:
- Selector 的比较本身有开销:每次 Provider 更新都要对所有 selector 执行比较函数
- 与并发模式冲突:并发渲染中,selector 可能在不同时间点读到不同的值(tearing)
- 复杂度不值得:拆分 Context 或用外部状态管理可以解决大部分场景
React 团队的建议是:Context 适合低频变化的全局状态(主题、语言、认证状态)。高频变化的共享状态应该用专门的状态管理库。
次级误解和边界
误解 1:"memo 组件内部的 useContext 不会被触发"
错。如果 memo 组件自己调用了 useContext,context 变化时它一定会重渲染,memo 的 props 比较不会生效。memo 只能跳过"props 变化"导致的重渲染,无法跳过"context 变化"导致的重渲染。
误解 2:"多套一层 Provider 就能隔离"
不完全对。Provider 隔离的是作用域——内层 Provider 会覆盖外层的值。但如果内层没有 Provider,context 变化仍然会穿透到最内层的消费者。
误解 3:"Context 性能问题在大型应用中很严重"
取决于 context 变化的频率。如果 context 值很少变(如用户登录状态),消费者偶尔重渲染完全可接受。如果 context 值频繁变(如搜索关键词),就应该拆分或用 selector。
现在我们知道了 Context 的变化传播是全量广播,memo 无法拦截,需要通过拆分 Context 或 children 模式来优化。但这里有一个更基础的问题:React 为什么一直强调列表要加 key?从性能角度,不加 key 到底会造成什么具体损失?
这就是 6.3 为什么列表必须加 key(性能角度) 要回答的事情。
6.3 为什么列表必须加 key(性能角度)
直觉锚定
想象一个教室里有 30 个学生按学号坐座位。老师有一份花名册(旧列表),现在新学期转来了一个学生,插到了第 3 位(新列表)。
没有 key(用 index) :老师按座位号一一对——第 1 个还是张三(没变),第 2 个还是李四(没变),第 3 个本来是王五,现在是新同学 → 王五及后面所有人的信息全部要重新登记,即使他们没有任何变化。
有 key(用学号) :老师按学号匹配——张三还是张三,李四还是李四,新同学是新增的,王五等人只需要换个座位。
映射:
- 学生 = Fiber 节点
- 花名册 = 旧 Fiber 列表
- 座位号 = index
- 学号 = key
- 重新登记 = 不必要的重渲染(processUpdateQueue + reconcile children)
- 换个座位 = DOM 节点移动(insertBefore),几乎零开销
问题背景
模块 4 已经详细讲过 Diff 算法。这一节专门从性能角度量化 key 的影响。
核心场景:列表顺序变化(插入、删除、移动)。这是 key 价值最大的场景,也是不用 key 时性能损失最严重的场景。
⚠️ 常见先入为主的误解: 很多人以为"不加 key 会报错"或者"不加 key 就不渲染"。实际上不加 key(或用 index 作为 key)React 不会报错(只有 console warning),列表仍然能正确显示,只是性能差。在小型列表(10-20 项)中几乎感知不到差异,但在大型列表(100+ 项)或频繁更新的列表中,差异会非常明显。
核心机制回顾
用 index 作为 key 时的匹配逻辑
旧列表: [A, B, C, D] key = [0, 1, 2, 3]
新列表: [X, A, B, C, D] key = [0, 1, 2, 3, 4]
第一轮遍历(按 index 对比):
index 0: 旧=A(key=0) vs 新=X(key=0) → key 相同,type 不同(假设不同组件)→ 删除 A,创建 X
index 1: 旧=B(key=1) vs 新=A(key=1) → key 相同,type 相同 → 复用 Fiber,但 props 从 B 变成 A
index 2: 旧=C(key=2) vs 新=B(key=2) → key 相同,type 相同 → 复用 Fiber,props 从 C 变成 B
index 3: 旧=D(key=3) vs 新=C(key=3) → key 相同,type 相同 → 复用 Fiber,props 从 D 变成 C
index 4: (无旧节点) vs 新=D(key=4) → 新增 D
结果:4 次更新 + 1 次创建
用唯一 id 作为 key 时的匹配逻辑
旧列表: [A, B, C, D] key = [a, b, c, d]
新列表: [X, A, B, C, D] key = [x, a, b, c, d]
第一轮遍历:
index 0: 旧=A(key=a) vs 新=X(key=x) → key 不同 → 退出第一轮
建立 Map:{ a→A, b→B, c→C, d→D }
第二轮遍历(新列表逐个在 Map 中查找):
X(key=x) → Map 中没有 → 新增
A(key=a) → Map 中找到,type 相同 → 复用,oldIndex=0,移动
B(key=b) → Map 中找到,type 相同 → 复用,oldIndex=1,移动
C(key=c) → Map 中找到,type 相同 → 复用,oldIndex=2,移动
D(key=d) → Map 中找到,type 相同 → 复用,oldIndex=3,移动
结果:4 次 DOM 移动(insertBefore)+ 1 次创建
性能差异的具体来源
操作类型对比
| 操作 | 开销 | 说明 |
|---|---|---|
| DOM 移动(insertBefore) | 极低 | 浏览器只需更新 DOM 树的指针,不涉及创建/销毁 |
| Fiber 复用 + props 更新 | 中等 | 需要执行组件函数、对子节点做 Diff、更新 DOM 属性 |
| Fiber 销毁 + 重建 | 高 | 需要卸载旧组件(执行 useEffect 清理)、创建新 Fiber、挂载新组件 |
| DOM 节点创建/销毁 | 高 | 涉及浏览器的 DOM API 调用、样式重新计算 |
用 index 时的开销链
A 的 Fiber 被复用(B 匹配到了 A 的位置)
│
├── processUpdateQueue(可能有 state 更新要处理)
├── 执行组件函数(重新计算 JSX)
├── beginWork 递归处理子节点(A 的子树全部要遍历)
├── completeWork 对比 props 变化 → 更新 DOM 属性
└── 如果子组件有 useEffect → 执行清理 + 重新注册
关键:即使 A 的实际内容完全没变,因为它的 Fiber 被 B 的 props "污染"了,整个子树都要重新走一遍 render 流程。
用 key 时的开销链
A 的 Fiber 被正确复用(key=a 匹配到了 key=a)
│
├── memoizedProps vs pendingProps 浅比较
├── 如果 props 没变 → bailout!整个子树跳过
└── 如果 props 变了 → 正常 render(但通常只有少量节点需要更新)
DOM 操作:只是把 A 对应的 DOM 节点 insertBefore 到新位置
最坏情况量化
假设一个列表有 N 个元素,在头部插入 1 个新元素:
| 场景 | Fiber 操作 | DOM 操作 | 组件执行次数 |
|---|---|---|---|
| index 做 key | N 次复用 + N 次 props 更新 | N 次属性更新 + 1 次创建 | N 次(所有组件重渲染) |
| 唯一 key | 1 次创建 + N 次移动 | N 次 insertBefore + 1 次创建 | 1 次(只执行新组件) |
N=100 时,index 方案多执行 99 次无意义的组件渲染。
特殊场景:用 index 做 key 会产生 bug
性能问题之外,还有一个正确性问题——带状态的组件用 index 做 key 会导致状态错乱:
function Item({ text }) {
const [checked, setChecked] = useState(false);
return (
<label>
<input type="checkbox" checked={checked} onChange={() => setChecked(!checked)} />
{text}
</label>
);
}
function List() {
const [items, setItems] = useState(['Apple', 'Banana', 'Cherry']);
return (
<div>
<button onClick={() => setItems(['Durian', ...items])}>
在头部插入
</button>
{items.map((item, index) => <Item key={index} text={item} />)}
</div>
);
}
操作步骤:
- 勾选 "Banana"(index=1 的 checkbox 变为 checked)
- 点击"在头部插入" → items 变为
['Durian', 'Apple', 'Banana', 'Cherry'] - 结果: "Apple"(新的 index=1)变成了 checked,而不是 "Banana"
原因:React 按 index 匹配 Fiber,index=1 的 Fiber 保留着之前的 state(checked=true),但这个 Fiber 现在渲染的是 "Apple" 而不是 "Banana"。
用唯一 key(如 item 本身)就不会有这个问题,因为 "Banana" 的 Fiber 会正确匹配到 "Banana" 的新位置。
设计动机与权衡
为什么 React 不自动用组件内容做 key?
React 在运行时无法确定哪些 props/内容能唯一标识一个组件实例。两个 <Item text="Apple" /> 可能是列表中的不同项(重复项),不能自动去重。key 是开发者告诉 React "这个实例的身份是什么"的唯一方式。
为什么不用内部自增 ID 做 key?
React 可以给每个 Element 自动分配一个自增 ID,但这和 index 本质相同——它不反映逻辑身份,只反映渲染顺序。列表顺序变化时,自增 ID 和 index 一样会导致错误匹配。
次级误解和边界
误解 1:"只要列表不变,用 index 做 key 也没问题"
对。如果列表是静态的(永远不会插入、删除、重排),index 做 key 和唯一 key 行为完全一致。React 官方也承认在这种场景用 index 是可以的。但问题是:列表的"不变"是一个隐含假设,未来维护者可能不知道这个假设,加了排序/过滤功能后 bug 就出现了。
误解 2:"key 只影响列表,不影响单个组件"
对也不对。key 的官方用途确实是列表中的节点匹配。但 key 有一个特殊行为:同一个位置的组件,如果 key 变了,React 会销毁旧实例、创建新实例。这个特性可以用来强制重置组件状态:
{showA ? <ComponentA key="a" /> : <ComponentA key="b" />}
// key 从 "a" 变成 "b" → 销毁旧的 ComponentA,创建新的
// 内部 state 全部重置
误解 3:"key 必须全局唯一"
不需要。key 只需要在同一次兄弟节点中唯一即可。不同列表中的元素可以有相同的 key,不会冲突,因为 React 在各自的 reconcileChildFibers 调用中分别处理。
现在我们知道了 key 通过正确匹配 Fiber 身份避免了不必要的组件重渲染和状态错乱。但这里有一个问题:如果一个组件很重(比如图表库),加载它需要几百 KB,有没有办法只在用到时才加载?
这就是 6.4 Lazy Loading:React.lazy + Suspense 要回答的事情。
6.4 Lazy Loading:React.lazy + Suspense
直觉锚定
想象一家餐厅,菜单上有 100 道菜,但不是每道菜都提前备好食材。聪明的做法是:常用的 20 道菜备好食材(首屏加载),其余 80 道菜只在客人点的时候才去采购(懒加载)。
客人点了第 73 道菜时,服务员说"请稍等"(fallback),后厨派人去买食材(import()),买回来后上菜(渲染真实组件)。
映射:
- 菜单 = 应用路由/组件树
- 提前备好的菜 = 首屏 bundle 中的组件
- 现点现买 = React.lazy + dynamic import()
- 请稍等 = Suspense fallback
- 后厨采购 = 浏览器发起网络请求加载 chunk
- 上菜 = Promise resolve 后重新渲染
问题背景
SPA 应用的 JS bundle 会随功能增长越来越大。一个包含图表库、富文本编辑器、视频播放器的应用,首屏 bundle 可能超过 1MB。但用户打开首页时,图表和编辑器根本用不到——白白下载了几百 KB。
代码分割(Code Splitting)的目标:首屏只加载必要的代码,其余按需加载。Webpack/Vite 的 import() 语法天然支持代码分割,但 React 需要一个组件层面的 API 来配合异步加载。这就是 React.lazy + Suspense 的组合。
⚠️ 常见先入为主的误解: 很多人以为
React.lazy做的是"延迟渲染"。实际上React.lazy做的是代码分割的声明——它告诉打包工具"这个组件要单独打成一个 chunk"。真正触发加载的是渲染时遇到未加载的组件抛出 Promise,Suspense 捕获这个 Promise 并显示 fallback。
核心数据结构
React.lazy 的返回值
// react@18.3.1 · packages/react/src/ReactLazy.js
function lazy(ctor) {
var payload = {
// 状态机:未加载 → 加载中 → 已加载
_status: Uninitialized, // -1 = 未初始化
_result: ctor // 动态 import 函数
};
return {
$$typeof: REACT_LAZY_TYPE,
_payload: payload,
_init: lazyInitializer // 初始化函数
};
}
payload 的状态流转
payload._status:
Uninitialized (-1) → 第一次渲染时触发初始化
│
▼
Pending (0) → import() 已调用,等待 resolve
│
├── _result = Promise
│
▼ (resolve)
Resolved (1) → 模块加载完成
│
├── _result = { default: Component }
│
▼ (reject)
Rejected (2) → 加载失败
│
└── _result = Error
Suspense 的 Fiber 结构
FiberNode(tag = SuspenseComponent){
memoizedState: {
alreadyLoaded: boolean // 子树是否已加载
}
pendingProps: {
fallback: ReactElement // 加载中显示的 UI
children: ReactElement // 真实内容
}
}
运行流程
lazyInitializer 的执行
// react@18.3.1 · packages/react/src/ReactLazy.js
function lazyInitializer(payload) {
if (payload._status === Uninitialized) {
var ctor = payload._result; // 动态 import 函数
var thenable = ctor(); // 调用 import(),返回 Promise
payload._status = Pending;
payload._result = thenable;
thenable.then(
function(moduleObject) {
if (payload._status === Pending) {
payload._status = Resolved;
payload._result = moduleObject; // 替换为模块对象
}
},
function(error) {
if (payload._status === Pending) {
payload._status = Rejected;
payload._result = error;
}
}
);
}
if (payload._status === Resolved) {
return payload._result.default; // 返回真实的组件
}
// Pending 或 Rejected → 抛出
throw payload._result; // 抛出 Promise 或 Error
}
完整的加载-渲染流程
① 首次渲染遇到 <LazyComponent />
│
▼
② beginWork 处理 LazyComponent
└── 调用 lazyInitializer(payload)
├── payload._status = Uninitialized
├── 调用 import() → 得到 Promise
├── payload._status = Pending
└── throw Promise ← 关键:抛出!
│
▼
③ 异常冒泡,找到最近的 Suspense 边界
│ (ReactFiberWorkLoop.js · throwException → createSuspenseBoundary)
│
▼
④ Suspense 的 beginWork 被触发
├── 发现子树抛出了 Promise
├── 渲染 fallback UI
└── 注册 Promise.then 回调(resolve 后触发重新渲染)
│
▼
⑤ commit 阶段:DOM 显示 fallback 内容
│
▼ (网络请求完成,Promise resolve)
│
▼
⑥ payload._status = Resolved
payload._result = { default: ChartComponent }
│
▼
⑦ Promise.then 回调触发 scheduleUpdateOnFiber
│
▼
⑧ 重新渲染 Suspense 子树
├── lazyInitializer → payload._status = Resolved
│ └── return payload._result.default → 得到 ChartComponent
├── 正常渲染 ChartComponent
└── commit 阶段:DOM 从 fallback 切换到真实内容
Suspense 捕获 throw 的源码路径
// 简化自 react@18.3.1 · ReactFiberWorkLoop.js
function renderRootConcurrent(root, lanes) {
// ...
do {
try {
workLoopConcurrent();
break;
} catch (thrownValue) {
// ← lazyInitializer 抛出的 Promise 在这里被捕获
handleError(root, thrownValue);
}
} while (true);
}
function handleError(root, thrownValue) {
// 向上找最近的 Suspense 边界
var suspenseBoundary = findNearestSuspenseBoundary(workInProgress);
if (suspenseBoundary !== null && thrownValue !== null && typeof thrownValue.then === 'function') {
// 是一个 Promise → 走 Suspense 路径
suspenseBoundary.flags |= ShouldCapture;
attachSuspenseRetryListeners(thrownValue, suspenseBoundary, root, lanes);
// ← 注册 Promise.then,resolve 后重新调度
}
}
已加载后的第二次渲染
当用户离开页面再回来时,payload._status 已经是 Resolved,lazyInitializer 直接返回组件,不再抛 Promise,不再走 Suspense fallback:
// 第二次渲染
lazyInitializer(payload)
// payload._status = Resolved
// 直接返回 payload._result.default → ChartComponent
// 无 throw → 无 Suspense 捕获 → 正常渲染
设计动机与权衡
为什么用 throw Promise 而不是 callback/async?
React 的渲染是同步的(从 render 到 return JSX 是一个同步函数调用链)。如果 lazy 组件返回一个 Promise,React 需要改造整个渲染管线支持 async——这是一个巨大的架构变更。
throw 是 JavaScript 原生的控制流中断机制。一个组件 throw 一个值,调用栈自动展开到最近的 catch 点(Suspense 边界)。这不需要改变 React 的渲染管线——workLoopConcurrent 本来就有 try-catch。
"用异常做控制流"的反模式争议:
这确实是 JavaScript 社区的争议点。throw 在传统语义中表示"错误",React 用它表示"还没准备好"。但 React 团队认为:
- 这不是"异常",而是"挂起"(suspend)——语义上是合理的
- 性能上 throw/catch 的开销远小于改造整个渲染管线为 async
- 这个模式被统一用于代码分割和数据获取(Suspense for Data Fetching)
牺牲了什么?
- Error Boundary 干扰:如果 Error Boundary 在 Suspense 外层,Promise 可能被错误地当作 Error 处理。React 内部通过检查
typeof thrownValue.then === 'function'来区分 - Server-Side Rendering 限制:SSR 环境下不能 throw Promise 等待异步加载,需要
React.lazy+ SSR 流式渲染配合 - 调试困难:throw 导致调用栈中断,DevTools 中不容易追踪
次级误解和边界
误解 1:"React.lazy 只能用在路由级别"
不是。React.lazy 可以包裹任何组件,不限于路由组件。但最常见的是路由级别,因为路由天然是"用户导航到才需要"的分割点:
// 路由级别
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
// 也可以用在组件级别
const HeavyChart = React.lazy(() => import('./components/HeavyChart'));
误解 2:"Suspense 的 fallback 会闪烁"
如果 lazy 组件的 chunk 已经被浏览器缓存(第二次访问),加载几乎是瞬时的,但 React 仍然会先显示 fallback 再切换到真实内容,造成闪烁。
React 18 的优化:如果 Suspense 内的子树内容已经在内存中(payload._status = Resolved),React 会跳过 fallback,直接显示真实内容。具体来说,beginWork 处理 Suspense 时会检查子树是否有 pending 的 Suspense 依赖,没有就直接渲染 children。
误解 3:"React.lazy 加载失败会白屏"
不会,前提是你配了 Error Boundary。如果 import() 失败(网络断开),payload._status = Rejected,lazyInitializer 会 throw error。这个 error 不是 Promise,所以 Suspense 不处理,而是冒泡到 Error Boundary:
<ErrorBoundary fallback={<p>加载失败</p>}>
<Suspense fallback={<Loading />}>
<LazyComponent />
</Suspense>
</ErrorBoundary>
现在我们知道了 React.lazy 通过 throw Promise 机制实现组件级代码分割,Suspense 捕获 Promise 并显示 fallback。但这里有一个隐含的代价:useMemo/useCallback 不是免费的,过度使用反而拖慢应用。这个"过度优化"的问题出在哪?
这就是 6.5 为何过度使用 useMemo 反而变慢 要回答的事情。
6.5 为何过度使用 useMemo 反而变慢
直觉锚定
想象你给办公室每样东西都贴标签——杯子、笔记本、笔、椅子……标签本身有成本(买标签、写标签、贴标签)。如果办公室只有 5 样东西,标签的成本可以忽略。但如果你有 500 样东西都贴标签,贴标签的时间可能比找东西省下的时间还多。
useMemo/useCallback 就是这些标签:
- 贴标签 = 每次渲染时比较依赖数组(areHookInputsEqual)
- 找东西省时间 = 避免子组件重渲染
- 500 样都贴 = 组件里几十个 useMemo/useCallback,但大部分根本没防止任何重渲染
问题背景
社区里有一种风气:"凡是函数就包 useCallback,凡是计算就包 useMemo"。这来源于对性能优化的过度恐慌。
React 团队成员 Dan Abramov 和 Sebastian Markbåge 多次公开表示:大部分 useMemo/useCallback 是不必要的。React 的官方文档也更新了建议,不再推荐"默认加 memo"的做法。
问题出在哪?useMemo/useCallback 有三个隐性成本。
三个隐性成本
成本 1:依赖比较的开销
// react@18.3.1 · ReactFiberHooks.js · areHookInputsEqual
function areHookInputsEqual(nextDeps, prevDeps) {
for (var i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) continue;
return false; // 依赖变了
}
return true; // 依赖没变
}
每次渲染,每个 useMemo/useCallback 都要执行一次 areHookInputsEqual。假设一个组件里有 20 个 useMemo:
每次渲染的固定开销:
20 × areHookInputsEqual(平均 3 个依赖)
= 20 × 3 = 60 次 Object.is 比较
60 次 === 比较本身微不足道(纳秒级)。但这只是直接开销。
成本 2:内存占用
function Component({ data }) {
// 这个 useMemo 永远持有对大数组的引用
const sorted = useMemo(() => expensiveSort(data), [data]);
// 即使 Component 卸载,如果 sorted 被其他地方引用,GC 无法回收
}
useMemo 缓存的值存储在 Hook 的 memoizedState 中,只要组件还在 Fiber 树中,缓存的值就不会被 GC 回收。
对比不用 useMemo:
function Component({ data }) {
const sorted = expensiveSort(data);
// sorted 在函数执行完后就是局部变量
// 如果没有其他引用,GC 可以在下一次回收
}
在大部分场景,expensiveSort 的计算结果很小(一个数字、一个短字符串),缓存占用的内存可以忽略。但如果缓存的是大数组、大对象、长字符串,每个实例都占着内存不放,就可能成为问题。
成本 3:最关键的——没有收益的开销
这才是最大的问题。看这个例子:
function Parent() {
const [count, setCount] = useState(0);
// "优化":用 useCallback 缓存函数
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
// "优化":用 useMemo 缓存样式
const style = useMemo(() => ({ color: 'red' }), []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{/* Child 不是 memo 组件! */}
<Child onClick={handleClick} style={style} />
</div>
);
}
function Child({ onClick, style }) {
// 每次 Parent 重渲染,Child 都会重渲染
// 因为 Child 不是 React.memo 包裹的
return <div onClick={onClick} style={style}>Child</div>;
}
useCallback 和 useMemo 完全白费了——因为 Child 没有 memo 包裹,不管 onClick 和 style 的引用变没变,Child 每次都会重渲染。
收益 = 0,成本 = 依赖比较 + 内存占用。
量化对比
假设组件有 20 个 useCallback/useMemo,每次渲染:
| 操作 | 有 useMemo/useCallback | 没有 |
|---|---|---|
| 依赖比较 | 20 × areHookInputsEqual | 0 |
| 创建函数/值 | 0(命中缓存) | 20 次创建 |
| 实际创建开销 | ~0(纳秒) | ~20 × 纳秒 ≈ 微秒 |
| 依赖比较开销 | ~60 × 纳秒 ≈ 微秒 | 0 |
两者都在微秒级别,基本持平。但如果这 20 个 useMemo/useCallback 都没有实际阻止任何重渲染,那它们就是纯浪费——既浪费了代码复杂度,又没有带来任何可测量的性能提升。
正确的决策框架
该用 useMemo 的场景
// 1. 计算确实昂贵( measurable > 1ms)
const sortedItems = useMemo(
() => [...items].sort((a, b) => complexCompare(a, b)),
[items]
);
// 2. 引用作为 memo 组件的 props
const MemoChild = React.memo(Child);
const config = useMemo(() => ({ color: theme }), [theme]);
return <MemoChild config={config} />;
// 3. 引用作为其他 Hook 的依赖
const data = useMemo(() => fetchData(id), [id]);
useEffect(() => {
// 如果 data 不用 useMemo,每次渲染都是新引用
// effect 会每次都重新执行
process(data);
}, [data]);
// 4. 作为 Context 的 value
const value = useMemo(() => ({ user, theme }), [user, theme]);
return <AppContext.Provider value={value}>...</AppContext.Provider>;
不该用 useMemo 的场景
// 1. 简单计算(比 useMemo 本身还快)
const name = `${firstName} ${lastName}`;
// ❌ const name = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
// 2. 传递给非 memo 组件
return <input onChange={(e) => setValue(e.target.value)} />;
// ❌ const handleChange = useCallback((e) => setValue(e.target.value), []);
// 3. 只在组件内部使用的值(不传给子组件、不作为 Hook 依赖)
const formattedDate = date.toLocaleDateString();
// ❌ const formattedDate = useMemo(() => date.toLocaleDateString(), [date]);
决策流程图
需要 useMemo/useCallback 吗?
│
├── 这个值/函数会传给 memo 组件吗?
│ ├── 是 → 需要(防止 memo 失效)
│ └── 否 ↓
│
├── 这个值/函数会作为其他 Hook 的依赖吗?
│ ├── 是 → 需要(防止 effect 无限触发)
│ └── 否 ↓
│
├── 这个值/函数会作为 Context value 吗?
│ ├── 是 → 需要(防止所有 consumer 重渲染)
│ └── 否 ↓
│
├── 计算成本 > 1ms 吗?
│ ├── 是 → 需要(节省计算时间)
│ └── 否 ↓
│
└── 不需要。直接写,更简洁更快。
设计动机与权衡
React 为什么不自动做 memoization?
React 的设计哲学是 "默认不做优化,优化是开发者的显式选择" 。原因:
- React 不知道哪些计算是"昂贵的"——字符串拼接和矩阵运算对 React 来说没有区别
- 自动 memoization 需要确定依赖,React 选择了让开发者声明(deps 数组),而不是做隐式追踪(如 Vue 的响应式系统)
- 自动 memoization 的内存成本不确定——React 不知道缓存的结果会占多少内存
React 19 的 React Compiler 方向:
React Compiler(之前叫 React Forget)的目标是自动插入 useMemo/useCallback。编译器分析组件代码,判断哪些值需要 memoization,自动添加。这样开发者写最简单的代码,编译器做优化。但截至 React 18,这个编译器还在开发中。
次级误解和边界
误解 1:"useCallback 防止函数重新创建"
技术上对,但创建函数的成本极低。() => {} 在 V8 中只需几纳秒。useCallback 的真正价值不是"避免创建函数",而是"保持引用稳定",让下游的 memo 组件或 useEffect 依赖能正确判断"没变"。
误解 2:"useMemo(fn, []) 里的 fn 只执行一次"
对,但不完全准确。fn 在** mount 时执行一次**,后续渲染依赖不变(空数组永远不变),所以永远返回缓存值。但如果组件卸载再挂载(如被移出 DOM 又加回来),mount 会重新执行 fn。useMemo 的缓存生命周期和组件实例绑定,不是全局的。
误解 3:"所有性能优化都应该用 useMemo"
性能优化有三个层面,useMemo 只解决其中一个:
| 层面 | 解决方案 | useMemo 能帮忙吗 |
|---|---|---|
| 减少不必要渲染 | React.memo + 正确 key | 间接(稳定 props 引用) |
| 减少渲染中的计算 | useMemo | 直接 |
| 减少 DOM 操作 | 虚拟列表、合理拆分组件 | 不能 |
useMemo 不是万能优化工具。先测量(React DevTools Profiler),再优化。
题目考核
题 1
React.memo 做浅比较时,发现 props 没变,就会 bailout 跳过渲染。但你说"即使 props 没变,memo 也可能拦不住"。
请举出两种不同原因导致 memo 失效的场景,并解释每种场景下 memo 为什么拦不住。
题 2
你有一个 UserContext,值是 { name, age, avatar }。三个组件分别只用了其中一个字段:
Header 只用 name
Profile 只用 age
Avatar只用 avatar
现在用户修改了 avatar。请描述:这三个组件哪些会重渲染?为什么?如果要优化,你会怎么改?
题 3
下面这段代码,count 每秒加 1。HeavyList 会被包在 React.memo 里。请问这个 useCallback 有没有实际优化效果?为什么?
const HeavyList = React.memo(function HeavyList({ onSelect }) {
// 1000 个元素的列表
});
function App() {
const [count, setCount] = useState(0);
const handleSelect = useCallback((id) => {
console.log('selected', id);
}, []);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<HeavyList onSelect={handleSelect} />
</div>
);
}
如果我把 useCallback 去掉,改成 const handleSelect = (id) => { console.log('selected', id); },会发生什么?