React 19 内核探秘
第5章 Reconciliation:Diff 算法的真相
第5章 Reconciliation:Diff 算法的真相
本章要点
- Reconciliation 的本质:将树的 O(n³) 比较降低到 O(n) 的工程权衡
- React 的三条 Diff 启发式假设及其背后的统计学依据
- 单节点 Diff:
type和key的双重匹配策略- 多节点 Diff:两轮遍历算法的完整实现
key的真正作用:不只是消除警告,而是 Diff 算法的核心线索beginWork中各类组件的协调策略差异- Diff 算法的局限性与常见的性能陷阱
每当你调用 setState 触发一次更新,React 都会面临一个看似简单实则极其复杂的问题:如何高效地找出新旧两棵树之间的差异?
在计算机科学中,这个问题被称为”树的编辑距离”(Tree Edit Distance),它的最优通用解法的时间复杂度是 O(n³)——其中 n 是树中的节点数。对于一个拥有 1000 个节点的 React 组件树(这在实际应用中并不罕见),O(n³) 意味着需要进行 10 亿次比较。在 60fps 的帧预算内,这显然是不可接受的。
React 的解决方案不是找到一个更好的通用算法,而是改变问题本身。通过引入三条基于 UI 开发实践的启发式假设,React 将 O(n³) 的问题降低为了 O(n)——代价是在极少数违反假设的场景下,更新不是最优的。但在 99.9% 的实际场景中,这些假设都是成立的。
这就是 Reconciliation——React 最核心的算法。
5.1 三条启发式假设
假设一:不同类型的元素产生不同的树
// 假设一:类型改变 = 整棵子树重建
// 当 type 从 div 变为 span,React 不会尝试复用任何子节点
// 更新前
<div>
<Counter />
<UserProfile name="Alice" />
</div>
// 更新后(div → section)
<section>
<Counter />
<UserProfile name="Alice" />
</section>
// React 的处理:
// 1. 销毁整个 <div> 子树(包括 Counter 和 UserProfile 的状态)
// 2. 从零开始创建 <section> 子树
// 3. Counter 的 state 被重置,UserProfile 被重新挂载
为什么不尝试复用?因为在实际开发中,类型改变几乎总是意味着 UI 结构发生了本质变化。<div> 变成 <article> 可能只是标签换了,但 <Input> 变成 <Select> 则意味着完全不同的行为和状态模型。React 选择用”偶尔多做一点工作”来换取”算法实现的简单性和可预测性”。
假设二:同一层级的子元素通过 key 区分
// 假设二:React 只在同一层级内进行 Diff,不跨层级比较
// 更新前
<div>
<A />
<B />
<C />
</div>
// 更新后
<div>
<A />
<C />
<B />
</div>
// React 不会发现"B 和 C 交换了位置"(没有 key 的情况下)
// 它会按位置逐个比较:
// 位置 0: A → A ✓ 复用
// 位置 1: B → C ✗ 类型不同,销毁 B,创建 C
// 位置 2: C → B ✗ 类型不同,销毁 C,创建 B
// 但如果有 key:
<div>
<A key="a" />
<C key="c" />
<B key="b" />
</div>
// React 通过 key 识别出这是顺序变化:
// key="a": A → A ✓ 复用
// key="c": C 移到位置 1 → 复用
// key="b": B 移到位置 2 → 复用
// 没有任何组件被销毁和重建
假设三:同类型组件的子树结构通常相似
这条假设是隐含的:如果两个元素的 type 和 key 都相同,React 假设它们的子树结构大致相同,值得递归进去做 Diff。这避免了在发现节点匹配后还要评估”是否值得复用”的额外开销。
graph TD
subgraph "通用树 Diff: O(n³)"
G1["每个节点可能匹配<br/>另一棵树的任意节点"]
G2["需要考虑所有可能的<br/>插入、删除、移动组合"]
G3["动态规划求最优解"]
end
subgraph "React Diff: O(n)"
R1["只比较同层级节点"]
R2["type 不同 → 直接替换"]
R3["用 key 标识节点身份"]
R4["线性扫描 + Map 查找"]
end
G1 -.->|"三条假设<br/>简化为"| R1
style G1 fill:#ff6b6b,stroke:#333,color:#fff
style G2 fill:#ff6b6b,stroke:#333,color:#fff
style G3 fill:#ff6b6b,stroke:#333,color:#fff
style R1 fill:#69db7c,stroke:#333
style R2 fill:#69db7c,stroke:#333
style R3 fill:#69db7c,stroke:#333
style R4 fill:#69db7c,stroke:#333
图 5-1:通用 Diff vs React Diff 的复杂度对比
5.2 Diff 算法的入口:reconcileChildren
在 Fiber 架构中,Diff 算法发生在 beginWork 阶段。每个 Fiber 节点在处理时会调用 reconcileChildren 来比较新旧 children:
// packages/react-reconciler/src/ReactFiberBeginWork.js
function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any, // ReactElement | ReactElement[] | string | number | ...
renderLanes: Lanes
) {
if (current === null) {
// 首次挂载:没有旧 Fiber,所有子节点都是新建
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes
);
} else {
// 更新:有旧 Fiber,需要 Diff
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child, // 旧的第一个子 Fiber
nextChildren, // 新的 children(ReactElement)
renderLanes
);
}
}
mountChildFibers 和 reconcileChildFibers 实际上是同一个函数 createChildReconciler 的两个实例,区别在于是否标记副作用(side effects):
// packages/react-reconciler/src/ReactChildFiber.js
export const reconcileChildFibers = createChildReconciler(true); // 标记副作用
export const mountChildFibers = createChildReconciler(false); // 不标记副作用
function createChildReconciler(shouldTrackSideEffects: boolean) {
// 返回一个闭包,内部包含所有 Diff 相关的函数
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes
): Fiber | null {
// 根据 newChild 的类型分发到不同的处理逻辑
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 单个 ReactElement
return placeSingleChild(
reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes)
);
case REACT_PORTAL_TYPE:
// Portal
return placeSingleChild(
reconcileSinglePortal(returnFiber, currentFirstChild, newChild, lanes)
);
case REACT_LAZY_TYPE:
// Lazy 组件
// ...
}
if (isArray(newChild)) {
// 多个子节点(数组)
return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}
if (getIteratorFn(newChild)) {
// 可迭代对象
return reconcileChildrenIterator(returnFiber, currentFirstChild, newChild, lanes);
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 文本节点
return placeSingleChild(
reconcileSingleTextNode(returnFiber, currentFirstChild, '' + newChild, lanes)
);
}
// 其他情况(null, undefined, boolean):删除所有旧子节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
return reconcileChildFibers;
}
5.3 单节点 Diff
单节点 Diff 处理的是 children 为单个 ReactElement 的情况。这是最简单的场景,但其中的细节依然值得深究。
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes
): Fiber {
const key = element.key;
let child = currentFirstChild;
// 遍历旧的所有同级 Fiber,尝试找到可复用的
while (child !== null) {
if (child.key === key) {
// key 匹配
const elementType = element.type;
if (child.elementType === elementType) {
// key 和 type 都匹配 → 找到了!
// 删除剩余的旧兄弟节点(因为新的只有一个)
deleteRemainingChildren(returnFiber, child.sibling);
// 复用这个 Fiber,更新 props
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
}
// key 匹配但 type 不匹配
// 说明这个位置的节点类型变了,旧节点和它的所有兄弟都不可能被复用
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key 不匹配,标记这个旧节点为删除,继续查找下一个
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 没有找到可复用的 Fiber,创建新的
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
这段代码的逻辑可以用一个决策树来表示:
flowchart TD
A["开始:单节点 Diff"] --> B["遍历旧 Fiber 链表"]
B --> C{当前旧 Fiber<br/>key === 新元素 key?}
C -->|否| D["deleteChild(旧 Fiber)<br/>标记删除"]
D --> E{还有下一个<br/>sibling?}
E -->|是| B
E -->|否| F["创建新 Fiber"]
C -->|是| G{type 相同?}
G -->|是| H["✅ 复用!<br/>deleteRemainingChildren<br/>useFiber 更新 props"]
G -->|否| I["❌ 不可复用<br/>deleteRemainingChildren<br/>创建新 Fiber"]
style H fill:#69db7c,stroke:#333
style I fill:#ff6b6b,stroke:#333,color:#fff
style F fill:#ffa94d,stroke:#333,color:#fff
图 5-2:单节点 Diff 的决策流程
key 匹配但 type 不匹配的特殊处理
注意一个重要的细节:当 key 匹配但 type 不匹配时,React 会删除所有旧的子节点(包括尚未遍历到的兄弟节点)。为什么?
// 场景:key 匹配但 type 不同
// 旧的 children
<>
<div key="main">Hello</div>
<span>World</span>
</>
// 新的 children(单个元素)
<section key="main">Hi</section>
// React 的推理:
// 1. 新的只有一个元素,key="main"
// 2. 旧的第一个 child key="main",匹配!
// 3. 但 type 从 div → section,不匹配
// 4. 既然 key 相同但 type 变了,说明这个节点被"就地替换"了
// 5. 那么旧的其他兄弟节点(<span>)也一定不再需要了
// 6. 所以 deleteRemainingChildren 删除所有剩余旧节点
如果 key 不匹配,则只删除当前节点并继续查找——因为可能后面的兄弟节点有匹配的 key。
5.4 多节点 Diff:两轮遍历算法
多节点 Diff 是 Reconciliation 算法中最复杂的部分。当 children 是数组时,React 使用一个巧妙的两轮遍历算法来处理。
为什么需要两轮遍历?
在实际的 React 应用中,列表更新有一个重要的统计特征:大多数更新只涉及节点属性的变化,而不涉及节点的增删或重排。React 的两轮算法正是基于这个观察设计的:
- 第一轮:假设只有属性更新,线性扫描比较(最常见场景)
- 第二轮:处理第一轮未能处理的情况(增删、重排)
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<any>,
lanes: Lanes
): Fiber | null {
// 结果链表的头和尾
let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0; // 最后一个不需要移动的旧节点索引
let newIdx = 0;
let nextOldFiber = null;
// ========== 第一轮遍历 ==========
// 从左到右同时遍历新旧两个数组
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
// 旧 Fiber 的位置超前,说明中间有节点被删除了
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
// 尝试用旧 Fiber 更新新元素
const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
// key 不匹配,第一轮提前结束
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
// 新 Fiber 是全新创建的(不是复用的),删除旧的
deleteChild(returnFiber, oldFiber);
}
}
// 标记节点位置
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 构建结果链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
// ========== 第一轮结束后的三种情况 ==========
// 情况 1:新数组遍历完了 → 删除剩余旧节点
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// 情况 2:旧链表遍历完了 → 剩余新元素都是插入
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) continue;
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
// 情况 3:都没遍历完,第一轮因 key 不匹配中断 → 进入第二轮
// ========== 第二轮遍历 ==========
// 将剩余的旧 Fiber 放入 Map(key → Fiber)
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 遍历剩余的新元素
for (; newIdx < newChildren.length; newIdx++) {
// 从 Map 中查找匹配的旧 Fiber
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// 复用了旧 Fiber,从 Map 中移除
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key
);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
// 删除 Map 中剩余的旧 Fiber(它们在新数组中不存在了)
if (shouldTrackSideEffects) {
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
updateSlot:第一轮的核心
function updateSlot(
returnFiber: Fiber,
oldFiber: Fiber | null,
newChild: any,
lanes: Lanes
): Fiber | null {
const key = oldFiber !== null ? oldFiber.key : null;
// 文本节点没有 key
if (typeof newChild === 'string' || typeof newChild === 'number') {
if (key !== null) {
// 旧节点有 key 但新节点是文本 → key 不匹配
return null;
}
return updateTextNode(returnFiber, oldFiber, '' + newChild, lanes);
}
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
if (newChild.key === key) {
// key 匹配,尝试复用
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
// key 不匹配 → 返回 null,第一轮结束
return null;
}
}
// ... Portal, Lazy 等类型
}
}
return null;
}
placeChild:移动检测的核心
placeChild 负责判断一个复用的节点是否需要移动。它的逻辑基于一个关键观察:如果一个复用节点在旧数组中的位置大于 lastPlacedIndex,说明它相对于前面已处理的节点是”往后的”,不需要移动。
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// 首次挂载,不需要标记
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// 这个节点在旧数组中的位置在 lastPlacedIndex 之前
// 说明它需要向右移动
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
// 不需要移动,更新 lastPlacedIndex
return oldIndex;
}
} else {
// 全新节点,需要插入
newFiber.flags |= Placement;
return lastPlacedIndex;
}
}
图解多节点 Diff 的完整过程
让我们通过一个具体的例子来理解整个过程:
// 旧列表
['A', 'B', 'C', 'D', 'E'].map(item => <Item key={item} />)
// 新列表(D 移到了最前面,删除了 B)
['D', 'A', 'C', 'E'].map(item => <Item key={item} />)
graph TD
subgraph "第一轮遍历"
R1["newIdx=0: D vs A (旧)"] --> R1R["key 不匹配<br/>第一轮结束 ❌"]
end
subgraph "构建 Map"
M1["Map = { A→FiberA, B→FiberB, C→FiberC, D→FiberD, E→FiberE }"]
end
subgraph "第二轮遍历"
S1["newIdx=0: D → Map.get('D') ✓<br/>oldIndex=3, lastPlaced=0<br/>3 >= 0 → 不移动<br/>lastPlaced=3"]
S2["newIdx=1: A → Map.get('A') ✓<br/>oldIndex=0, lastPlaced=3<br/>0 < 3 → 需要移动 📍"]
S3["newIdx=2: C → Map.get('C') ✓<br/>oldIndex=2, lastPlaced=3<br/>2 < 3 → 需要移动 📍"]
S4["newIdx=3: E → Map.get('E') ✓<br/>oldIndex=4, lastPlaced=3<br/>4 >= 3 → 不移动<br/>lastPlaced=4"]
S1 --> S2 --> S3 --> S4
end
subgraph "清理"
CL["Map 中剩余: { B→FiberB }<br/>标记 B 为删除 🗑️"]
end
R1R --> M1 --> S1
S4 --> CL
style R1R fill:#ff6b6b,stroke:#333,color:#fff
style S2 fill:#ffa94d,stroke:#333,color:#fff
style S3 fill:#ffa94d,stroke:#333,color:#fff
style CL fill:#ff6b6b,stroke:#333,color:#fff
图 5-3:多节点 Diff 的完整过程示例
最终的 DOM 操作:
- D:不移动
- A:移动到 D 后面
- C:移动到 A 后面
- E:不移动
- B:删除
移动算法的局限性
React 的移动检测算法是从左到右单向的。这意味着它在某些场景下会产生非最优的移动操作:
// 场景:将最后一个元素移到最前面
// 旧: [A, B, C, D]
// 新: [D, A, B, C]
// React 的处理:
// D: oldIndex=3, lastPlaced=0 → 3 >= 0, 不移动, lastPlaced=3
// A: oldIndex=0, lastPlaced=3 → 0 < 3, 移动 ❌
// B: oldIndex=1, lastPlaced=3 → 1 < 3, 移动 ❌
// C: oldIndex=2, lastPlaced=3 → 2 < 3, 移动 ❌
// 结果:移动了 A, B, C 三个节点
// 最优解:只移动 D 到最前面(1次移动 vs 3次移动)
graph LR
subgraph "React 的方案(3次移动)"
direction TB
D1["D (不动)"] ~~~ A1["A → 移动"] ~~~ B1["B → 移动"] ~~~ C1["C → 移动"]
end
subgraph "最优方案(1次移动)"
direction TB
D2["D ← 移动到最前"] ~~~ A2["A (不动)"] ~~~ B2["B (不动)"] ~~~ C2["C (不动)"]
end
style A1 fill:#ff6b6b,stroke:#333,color:#fff
style B1 fill:#ff6b6b,stroke:#333,color:#fff
style C1 fill:#ff6b6b,stroke:#333,color:#fff
style D2 fill:#ff6b6b,stroke:#333,color:#fff
图 5-4:React Diff 的非最优移动场景
React 为什么不使用最优移动算法(如最长递增子序列,LIS)?因为 LIS 算法的实现更复杂,而且在绝大多数实际场景中,列表变化是局部的(插入/删除一两个元素),React 的简单算法已经足够高效。Vue 3 选择了 LIS 算法,这是一个不同的工程权衡。
5.4.1 源码核对:React 官方自己承认这个算法不是最优
打开 packages/react-reconciler/src/ReactChildFiber.js:841——reconcileChildrenArray 的真实源码注释把设计决策和局限性都写得非常坦率:
// This algorithm can't optimize by searching from both ends since we
// don't have backpointers on fibers. I'm trying to see how far we can get
// with that model. If it ends up not being worth the tradeoffs, we can
// add it later.
// Even with a two ended optimization, we'd want to optimize for the case
// where there are few changes and brute force the comparison instead of
// going for the Map. It'd like to explore hitting that path first in
// forward-only mode and only go for the Map once we notice that we need
// lots of look ahead. This doesn't handle reversal as well as two ended
// search but that's unusual. Besides, for the two ended optimization to
// work on Iterables, we'd need to copy the whole set.
// In this first iteration, we'll just live with hitting the bad case
// (adding everything to a Map) in for every insert/move.
// If you change this code, also update reconcileChildrenIterator() which
// uses the same algorithm.
这段注释比 §5.4 讲的”React 不用 LIS”要具体 10 倍,几条值得单独拆开:
1、“No backpointers on fibers”——Fiber 是单向链表。每个 Fiber 只有 sibling 指向下一个兄弟,没有指向前一个的 backpointer。这决定了双端搜索(Vue 2/3 的 O(n) 核心优化之一)在 React 里直接走不通——你不能从右往左扫。注释说 “I’m trying to see how far we can get with that model”——React 是刻意接受这个数据结构约束去探索算法能走多远。不是”想不到更好的”,是”先用这个看够不够好”。
2、“for Iterables, we’d need to copy the whole set”——React 的 children 可以是迭代器(Symbol.iterator)。双端搜索要求随机访问,对迭代器意味着必须先完整拷贝一份——和 React 的”一次遍历、增量”哲学冲突。单端扫描既能吃迭代器、又能吃数组,用同一个 updateSlot 逻辑——这是一个因为迭代器兼容性而做的架构折衷。
3、“we’ll just live with hitting the bad case”——React 承认”每次 insert/move 都走 Map 路径”不是最优的。为什么还是这样?因为Map 的构造 + lookup 本身是 O(n) 摊销 O(1) 每次——整体还是 O(n),比起”优化成最坏情况也不走 Map”带来的代码复杂度,多一次 Map 构造的成本可接受。React 在工程上选”简单的 O(n)“压过”复杂的 O(n) with smaller constants”。
4、“If you change this code, also update reconcileChildrenIterator()“——这是 React 团队自己给自己留的维护提示,因为数组和迭代器两套逻辑必须保持语义一致。这一条对读者的价值是:如果你在 debugging React 的 list 行为时发现两条路径结果不一样,优先怀疑数组路径和迭代器路径没有同步修改——这是 React 已知的维护痛点,不是你的代码错了。
这四段注释让”算法的选择 + 局限”从”我们说的”变成”React 团队自己记录的”。写教学材料时最有说服力的方式,就是让源码的注释和作者的坦率自己说话。
5.4.2 源码核对:第一轮里一个隐藏的分支——oldFiber.index > newIdx
§5.4 给出的”第一轮遍历”教学版代码忽略了一段关键逻辑。真实源码(ReactChildFiber.js:876)有个预处理:
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null; // ← 这一步在干什么?
} else {
nextOldFiber = oldFiber.sibling;
}
const newFiber = updateSlot(
returnFiber,
oldFiber, // ← 传进去的可能是 null!
newChildren[newIdx],
lanes,
);
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber; // ← 补回来
}
break;
}
// ...
}
这段”oldFiber.index > newIdx 就先把 oldFiber 变 null”是大部分教学资料没写的。它存在的理由是:旧 Fiber 链表里可能有 “稀疏” 的 index 跳跃。
举例:如果上一轮渲染里有 <div>{null}{null}<A/></div>——null 和 undefined 不会产生 Fiber,所以 <A/> 的 Fiber.index 是 2(不是 0)。下一轮渲染变成 <div><B/><A/></div>——新 children 第 0 项是 B、第 1 项是 A。循环进入 newIdx=0:oldFiber 是那个 index=2 的 A,oldFiber.index (2) > newIdx (0)——意味着位置 0 在旧树里没有 Fiber(是个 null/undefined 槽)。
这时 React 临时把 oldFiber 设为 null 传给 updateSlot——updateSlot(null, newChildren[0], ...) 会识别出”这里新元素是 B、旧位置是空”——返回”新建 Fiber”。循环继续,oldFiber 保留在 nextOldFiber 里没丢,下一轮循环恢复。
这是一个应付”null 儿童让 index 跳跃”的补偿机制。没有它,一旦用户在 children 里混入条件渲染的 {cond && <X/>},整个第一轮 Diff 会因为 index 对不上位跌进第二轮 Map 慢路径——大部分真实应用写 JSX 时都会有这种 null 子元素,没这个补偿的话性能会大打折扣。
读到这一层你就能回答一个常见疑问——“我 children 里偶尔 return null 会不会让 React diff 变慢?” 答案是不会,因为第一轮里有这段 index 跳跃补偿;只有当补偿失败(比如新旧树在同 index 上类型完全不同)时才进入第二轮 Map。
5.4.3 源码核对:Fragment 在单节点 Diff 里的特殊路径
§5.5 讲单节点 updateElement 用 child.elementType === elementType 判断复用。真实的 reconcileSingleElement(ReactChildFiber.js:1228)有个额外分支专门处理 Fragment:
while (child !== null) {
if (child.key === key) {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
if (child.tag === Fragment) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props.children);
existing.return = returnFiber;
return existing;
}
} else {
if (
child.elementType === elementType ||
// Lazy types should reconcile their resolved type.
(typeof elementType === 'object' &&
elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === child.type)
) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
}
}
// Didn't match.
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}
三条值得读者注意的路径:
1、Fragment 复用用 element.props.children、普通元素用 element.props。<>...</> 的 Fragment 没有 props 对象——它只是一个”容器”,真实的子节点在 children 里。所以 useFiber(child, element.props.children)——pendingProps 直接就是 children 数组,不是 {children: [...]} 的包装。这个差别会影响你在自定义 reconciler 里处理 Fragment 时的 props 访问方式。
2、Lazy 类型的早期解析。elementType.$$typeof === REACT_LAZY_TYPE && resolveLazy(elementType) === child.type——React 19 在单节点 Diff 这一层就会尝试 resolve Lazy 类型,看解析后的真实类型能不能匹配已有 Fiber。这让 <React.lazy(LoadA)> → <LoadA/>(已经解析完的)这种”从 Lazy 过渡到非 Lazy”的场景能复用 Fiber 而不是重建——对代码分割场景很重要。
3、key 不匹配时直接 deleteChild 而不 break。注意最外层的 else 分支:child.key === key 为 false 时 deleteChild(returnFiber, child) 后继续循环到下一个 sibling。这和 §5.4 描述的多节点第一轮遇到 key mismatch 就 break 不同——单节点 Diff 会扫完所有 sibling 寻找 key 匹配、没匹配到的全删掉。单节点场景下这个 O(n) 扫描是必要的——因为你没法知道用户可能在哪一位上写了匹配的 key。
5.5 updateElement:Fiber 复用的细节
当 Diff 算法确定一个旧 Fiber 可以复用时,会调用 useFiber 来创建一个 workInProgress Fiber:
function updateElement(
returnFiber: Fiber,
current: Fiber | null,
element: ReactElement,
lanes: Lanes
): Fiber {
const elementType = element.type;
if (current !== null) {
if (current.elementType === elementType) {
// type 匹配,复用
const existing = useFiber(current, element.props);
existing.ref = coerceRef(returnFiber, current, element);
existing.return = returnFiber;
return existing;
}
}
// 不能复用,创建新的
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, current, element);
created.return = returnFiber;
return created;
}
function useFiber(fiber: Fiber, pendingProps: any): Fiber {
// 创建或复用 workInProgress Fiber
// 注意:这里复用的是 Fiber 对象本身(避免 GC 压力),
// 但 props 是全新的
const clone = createWorkInProgress(fiber, pendingProps);
clone.index = 0;
clone.sibling = null;
return clone;
}
“复用”在这里的含义是:
- 复用 Fiber 对象本身(避免创建新对象产生的 GC 压力)
- 复用对应的 DOM 节点(Fiber 的
stateNode指向实际的 DOM 元素) - 保留组件状态(函数组件的 hooks 链表、类组件的 state)
- 更新 props(pendingProps 是新的)
5.5.1 源码核对:updateSlot 里隐藏的五种新 child 类型
§5.4 给的 updateSlot 教学版代码只展示了 “text + REACT_ELEMENT + Portal” 三种 newChild。真实 updateSlot(ReactChildFiber.js:604)处理 7 种输入类型,让人印象深刻:
function updateSlot(returnFiber, oldFiber, newChild, lanes) {
const key = oldFiber !== null ? oldFiber.key : null;
// 1. 文本节点(string/number)
if ((typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number') {
if (key !== null) return null; // 旧有 key 但新是文本 → 不匹配
return updateTextNode(...);
}
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: // 2. 普通 React element
return newChild.key === key ? updateElement(...) : null;
case REACT_PORTAL_TYPE: // 3. Portal
return newChild.key === key ? updatePortal(...) : null;
case REACT_LAZY_TYPE: // 4. Lazy——init 后递归自己
return updateSlot(returnFiber, oldFiber, init(payload), lanes);
}
// 5. 子数组或迭代器——作为 "隐式 Fragment" 处理
if (isArray(newChild) || getIteratorFn(newChild)) {
if (key !== null) return null;
return updateFragment(returnFiber, oldFiber, newChild, lanes, null);
}
// 6. Promise/Thenable——use() 风格,unwrap 后递归
if (typeof newChild.then === 'function') {
return updateSlot(returnFiber, oldFiber, unwrapThenable(thenable), lanes);
}
// 7. Context——作为儿童读 context 值、再递归
if (newChild.$$typeof === REACT_CONTEXT_TYPE || ...) {
return updateSlot(returnFiber, oldFiber,
readContextDuringReconcilation(returnFiber, context, lanes), lanes);
}
throwOnInvalidObjectType(returnFiber, newChild);
}
if (__DEV__) {
if (typeof newChild === 'function') warnOnFunctionType(returnFiber);
}
return null;
}
几个不广为人知的事实:
1、空字符串 "" 被特判:typeof newChild === 'string' && newChild !== ''。为什么?因为在 React 里 <span>{""}</span> 的 "" 不应该产生文本节点(它没内容);但 <span>{0}</span> 的 0 应该产生文本节点”0”——所以 typeof === 'number' 不做非零判断。这个细节在”为什么 {false && <X/>} 不渲染任何东西但 {0 && <X/>} 渲染 0” 这种经典 React 面试题背后。
2、Lazy 类型的 init 递归。case REACT_LAZY_TYPE: return updateSlot(returnFiber, oldFiber, init(payload), lanes);——直接 resolve Lazy 并递归自己。init(payload) 可能在第一次调用时 throw Suspense 异常,React 会 catch 并进 Suspense 边界;如果已经 resolve 完成了,就返回真实的 element,updateSlot 递归一次拿到 REACT_ELEMENT_TYPE 走标准路径。这个优雅的递归让 Lazy 完全透明——子树 Diff 代码不用为 Lazy 写任何特殊分支。
3、子数组被当作隐式 Fragment。<div>{[<A/>, <B/>]}</div> 里的数组字面量会被当成无 key 的 Fragment——updateFragment(...) 复用一个 Fragment Fiber 包装这个数组。但注意数组不能有 key(if (key !== null) return null;)——如果旧 Fiber 有 key 但新是数组,视为不匹配。这也是为什么你写 <div>{[<A key="a"/>]}</div> 的外层数组不能用 key(内部元素要 key,外层数组本身不要)。
4、Thenable 的 unwrap。React 19 的 use() 之外,children 位置也可以直接放 Promise——<div>{fetchData()}</div>。updateSlot 检查 newChild.then === 'function',调 unwrapThenable 同步拿值或抛 Suspense 异常。这是 use() 机制在 Diff 层的另一个入口。
5、Context 直接作为 child。<div>{MyContext}</div>——字面地把一个 Context 对象当 child!React 会读当前 context 的值作为实际 child 递归下去。这个用法极其少见但官方支持——用来实现 “把 context 当 slot” 的高级模式,比如 Suspense 的 shared cache。
§5.4 简化版的 3 种类型覆盖了日常开发中最常见的场景,但这 7 种的全貌能帮你回答”React 到底能把什么当成 child”——答案比想象的宽得多。
5.5.2 源码核对:deleteChild 的 “lazy effect list”——为什么不立刻删
§5.4 说第二轮 Diff 结束后 “existingChildren.forEach(child => deleteChild(returnFiber, child))”。打开 deleteChild(ReactChildFiber.js:292):
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
if (!shouldTrackSideEffects) {
return; // ← mount 时直接什么都不做
}
const deletions = returnFiber.deletions;
if (deletions === null) {
returnFiber.deletions = [childToDelete];
returnFiber.flags |= ChildDeletion; // ← 在 return fiber 上打 flag
} else {
deletions.push(childToDelete);
}
}
这段 10 行代码揭示了 React 删除的真实时机:deleteChild 不删任何东西。它只是把 “要删的 Fiber” 塞进 returnFiber.deletions 数组,然后在 returnFiber 上打一个 ChildDeletion flag。真实的删除发生在 commit 阶段——第 6 章会讲 commit mutation 怎么走 returnFiber.deletions 数组执行 DOM 移除、调 ref cleanup、触发 unmount effect。
这种”Diff 阶段只标记、commit 阶段才执行”的 lazy effect 模式是 React 全局一致的架构选择。§8(并发模式)讲过 React 的可中断渲染——如果 Diff 阶段真的删了 DOM,中断重试会让 DOM 处于”删了一半”的状态;lazy 标记确保”要么 commit 前的渲染全部丢弃、要么全部应用”的原子性。这条不变量在本章的 Diff 层是’只打 flag 不操作 DOM’、在第 6 章会展开为’effect list + 单次 commit’——两章看成一个完整的故事。
5.6 beginWork 中的协调策略
不同类型的 Fiber 节点在 beginWork 中有不同的协调策略:
// packages/react-reconciler/src/ReactFiberBeginWork.js
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null {
// 优化路径:如果 props 和 context 都没变,可以跳过
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (oldProps !== newProps || hasContextChanged()) {
didReceiveUpdate = true;
} else {
// 检查是否有待处理的更新
const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
current,
renderLanes
);
if (!hasScheduledUpdateOrContext) {
didReceiveUpdate = false;
// 🎯 bailout: 直接复用旧的子树,跳过 Diff
return attemptEarlyBailoutIfNoScheduledUpdate(
current,
workInProgress,
renderLanes
);
}
}
}
// 根据 tag 分发到不同的处理函数
switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(current, workInProgress, renderLanes);
case ClassComponent:
return updateClassComponent(current, workInProgress, renderLanes);
case HostComponent: // div, span 等 DOM 元素
return updateHostComponent(current, workInProgress, renderLanes);
case HostText: // 文本节点
return updateHostText(current, workInProgress);
case Fragment:
return updateFragment(current, workInProgress, renderLanes);
case MemoComponent:
return updateMemoComponent(current, workInProgress, renderLanes);
// ... 更多类型
}
}
Bailout 优化
bailout 是 React 最重要的性能优化之一。当一个 Fiber 节点的 props、state、context 都没有变化时,React 可以完全跳过这个节点及其子树的 Diff:
function attemptEarlyBailoutIfNoScheduledUpdate(
current: Fiber,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null {
// 复制旧的 children
cloneChildFibers(current, workInProgress);
return workInProgress.child;
}
graph TD
A["Root"] --> B["App"]
B --> C["Header<br/>(无变化)"]
B --> D["Content<br/>(有更新)"]
B --> E["Footer<br/>(无变化)"]
D --> F["List<br/>(有更新)"]
D --> G["Sidebar<br/>(无变化)"]
style C fill:#69db7c,stroke:#333
style E fill:#69db7c,stroke:#333
style G fill:#69db7c,stroke:#333
style D fill:#ff6b6b,stroke:#333,color:#fff
style F fill:#ff6b6b,stroke:#333,color:#fff
C -.->|"bailout ✓<br/>跳过子树"| C
E -.->|"bailout ✓<br/>跳过子树"| E
G -.->|"bailout ✓<br/>跳过子树"| G
图 5-5:Bailout 优化示意——绿色节点被完整跳过
React.memo 与 Diff
React.memo 在 Diff 之前增加了一层 props 浅比较:
function updateMemoComponent(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null {
if (current !== null) {
const prevProps = current.memoizedProps;
const nextProps = workInProgress.pendingProps;
// 使用自定义或默认的比较函数
const compare = Component.compare || shallowEqual;
if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
// props 没有变化,bailout
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
}
// props 变了,继续 Diff
// ...
}
// 浅比较的实现
function shallowEqual(objA: any, objB: any): boolean {
if (Object.is(objA, objB)) return true;
if (typeof objA !== 'object' || objA === null ||
typeof objB !== 'object' || objB === null) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
!Object.is(objA[keysA[i]], objB[keysA[i]])
) {
return false;
}
}
return true;
}
5.5.3 源码核对:updateElement 的三种复用条件——包括热更新隐藏路径
真实的 updateElement(ReactChildFiber.js:413)判断是否复用的条件不止 “elementType 相同” 这一条——有三条或关系:
if (current !== null) {
if (
current.elementType === elementType ||
// Keep this check inline so it only runs on the false path:
(__DEV__
? isCompatibleFamilyForHotReloading(current, element)
: false) ||
// Lazy types should reconcile their resolved type.
(typeof elementType === 'object' &&
elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === current.type)
) {
// Move based on index
const existing = useFiber(current, element.props);
existing.ref = coerceRef(returnFiber, current, element);
existing.return = returnFiber;
return existing;
}
}
// Insert
const created = createFiberFromElement(element, returnFiber.mode, lanes);
三条复用条件展开:
条件 A:elementType 全等。最常见的路径。“你上次和这次渲染用的是同一个组件引用”——复用 Fiber、复用 state、复用 DOM 节点。
条件 B:isCompatibleFamilyForHotReloading(current, element)(仅 DEV)。这是 React Refresh / Fast Refresh 的入口——Webpack/Vite 的 HMR 能在修改 function MyButton 后保持 state 不丢失,靠的就是这条路径:
- React Refresh 在 HMR 时注册”新 MyButton 是旧 MyButton 的同家族成员”;
- Diff 到新元素时,
elementType === elementType失败(因为 HMR 换了新函数引用); - 但
isCompatibleFamilyForHotReloading返回 true,让 React 继续复用 Fiber 和 state; - 结果是开发者看到”改代码立刻生效,但 state 没被重置”。
注释里的 “Keep this check inline so it only runs on the false path” 是一条微优化提醒——只在 elementType !== elementType 不成立时才做 HMR 检查,生产路径完全绕过。这让 Fast Refresh 的成本只存在于开发环境。
条件 C:Lazy 类型的解析匹配。element.type 是 LazyComponent 对象(还没 resolve)、但 current.type 是已经 resolve 好的真实组件——同时满足 resolveLazy(elementType) === current.type 说明”语义上是同一个组件、只是一个是 Lazy 壳、一个是解析后的身体”。这条路径让 React.lazy(LoadA) → <A/> 的过渡能复用 Fiber 而不是重建——对代码分割场景至关重要。
§5.5 的教学版代码只讲了条件 A——足以让读者理解基本原理;但生产级理解必须包含 B 和 C——否则你解释不了 “为什么 HMR 不丢 state”、“为什么 Lazy 组件 resolve 后没被重建”。
5.6.1 源码核对:warnOnInvalidKey 只在 DEV 下扫重复 key
§5.7 会提到 “没加 key 触发 warning”。真实的 warning 来源是 warnOnInvalidKey(ReactChildFiber.js:788)——它在 reconcileChildrenArray 入口被调用:
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
if (__DEV__) {
// First, validate keys.
let knownKeys: Set<string> | null = null;
for (let i = 0; i < newChildren.length; i++) {
const child = newChildren[i];
knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
}
}
// ... 真正的 Diff
}
function warnOnInvalidKey(child, knownKeys, returnFiber) {
if (__DEV__) {
if (typeof child !== 'object' || child === null) return knownKeys;
switch (child.$$typeof) {
case REACT_ELEMENT_TYPE:
case REACT_PORTAL_TYPE:
warnForMissingKey(child, returnFiber);
const key = child.key;
if (typeof key !== 'string') break;
if (knownKeys === null) {
knownKeys = new Set();
knownKeys.add(key);
break;
}
if (!knownKeys.has(key)) {
knownKeys.add(key);
break;
}
console.error(
'Encountered two children with the same key, `%s`. ...'
);
break;
case REACT_LAZY_TYPE:
const payload = child._payload;
const init = child._init;
warnOnInvalidKey(init(payload), knownKeys, returnFiber);
break;
}
}
return knownKeys;
}
三条工程细节:
1、只在 __DEV__ 下扫。生产构建里这段代码被 dead-code eliminate——不花任何 CPU 在”检查 key 合法性”。这对性能很关键——假如生产也扫 key,每个列表渲染都要多一趟 O(n) 遍历。
2、“有 key 但不是 string” 跳过检查。if (typeof key !== 'string') break;——React 允许传 number 作为 key(会被 toString),但对 number key 的重复检测不做,理由可能是 Set<string> 的纯字符串约束 + 向后兼容考量。这是一个教学资料很少提及的细节:“数字 key”在 React 的重复性校验里是盲区。
3、Lazy 递归解包校验。如果儿童是 React.lazy(() => import(...))——会调 init(payload) 拿到真实 element 再递归校验。这意味着 Lazy 组件如果 throw suspense,warning 扫描也会一起挂起——不过因为是 DEV 警告,这个语义不重要。
关键的业务影响在”警告长什么样”——Encountered two children with the same key, 'foo'. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.——这条警告文本值得背诵,用户复制粘贴去搜类似问题时、搜得到最权威的根因描述。
5.6.2 源码核对:updateFromMap 里 key 的两种键——null 时回退到 index
§5.4 里”第二轮 Map 查找”的教学版代码写的是 Map.get(newChild.key)。真实 updateFromMap(ReactChildFiber.js:695)在 key 为 null 时回退到 newIdx:
case REACT_ELEMENT_TYPE: {
const matchedFiber =
existingChildren.get(
newChild.key === null ? newIdx : newChild.key, // ← 关键
) || null;
return updateElement(returnFiber, matchedFiber, newChild, lanes);
}
为什么不直接拒绝”没 key 的元素走第二轮”?因为 key 只是一个可选优化——即使用户没写 key,React 也要让 Diff 正常工作。这时 React 用 newIdx 作为伪 key——查 existingChildren.get(newIdx) 就是”旧列表的第 newIdx 个元素”(对应的那个 Fiber)。
这解释了陷阱一(index 作为 key)为什么行为符合直觉但不是你想要的——React 本来就是用 index 做兜底,用户显式写 key={index} 等价于复现了框架的默认回退行为,完全没带来新信息。只有当 key 是”随元素内容/身份不变”的属性(key={user.id}、key={post.uuid})时,才真正帮 React 区分”这是同一个节点”。
这也是 mapRemainingChildren(ReactChildFiber.js:325)的构造逻辑一致体:
let existingChild = currentFirstChild;
while (existingChild !== null) {
if (existingChild.key !== null) {
existingChildren.set(existingChild.key, existingChild); // 有 key 用 key
} else {
existingChildren.set(existingChild.index, existingChild); // 无 key 用 index
}
existingChild = existingChild.sibling;
}
写入端和读取端都用 “key || index” 这条规则——前后一致才能保证查表正确。理解这条能让你用 React.Children.toArray 的自动索引 key(.1、.2)和显式 key 的混用场景不再迷茫——它们都会被这套”有 key 用 key、没 key 用 index”的规则统一处理。
5.7 常见的 Diff 性能陷阱
陷阱一:使用 index 作为 key
// ❌ 错误:用 index 作为 key
function BadList({ items }: { items: string[] }) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>
<input defaultValue={item} />
</li>
))}
</ul>
);
}
// 当 items 从 ['A', 'B', 'C'] 变为 ['B', 'C'](删除了 A)时:
// React 的 Diff 过程:
// key=0: oldFiber(A) → newElement(B) → type 相同,复用!props 从 A 更新为 B
// key=1: oldFiber(B) → newElement(C) → type 相同,复用!props 从 B 更新为 C
// key=2: oldFiber(C) → 无对应新元素 → 删除 C
//
// 结果:React 认为是"修改了前两个,删除了最后一个"
// 而不是"删除了第一个"
// input 的 defaultValue 不会更新(因为是不受控的),
// 导致 UI 显示错误
陷阱二:在渲染中创建新的组件引用
// ❌ 错误:每次渲染都创建新的组件
function Parent() {
// 每次 render 都会创建一个新的 MemoizedChild 引用
// React.memo 在这里完全无效!
const MemoizedChild = React.memo(({ value }: { value: number }) => {
return <div>{value}</div>;
});
return <MemoizedChild value={42} />;
}
// ✅ 正确:在组件外部定义
const MemoizedChild = React.memo(({ value }: { value: number }) => {
return <div>{value}</div>;
});
function Parent() {
return <MemoizedChild value={42} />;
}
陷阱三:条件渲染改变组件树结构
// ❌ 问题代码:条件渲染导致非预期的状态丢失
function Page({ isAdmin }: { isAdmin: boolean }) {
return (
<div>
{isAdmin && <AdminBanner />}
<UserProfile /> {/* 当 isAdmin 变化时,这个组件的位置会变 */}
</div>
);
}
// 当 isAdmin 从 true 变为 false:
// 位置 0: AdminBanner → UserProfile (type 不同,销毁 + 重建)
// 位置 1: UserProfile → null (删除)
// UserProfile 的状态丢失了!
// ✅ 解决方案 1:使用 key 固定身份
function Page({ isAdmin }: { isAdmin: boolean }) {
return (
<div>
{isAdmin && <AdminBanner />}
<UserProfile key="profile" />
</div>
);
}
// ✅ 解决方案 2:保持结构稳定
function Page({ isAdmin }: { isAdmin: boolean }) {
return (
<div>
{isAdmin ? <AdminBanner /> : null}
<UserProfile />
</div>
);
// 注意:这其实和上面一样有问题,因为 null 不占位置
// 真正的解决方案是用 CSS 隐藏或者用 key
}
陷阱四:过深的组件树
// ❌ Diff 的时间与树的深度成正比
function DeepTree({ depth }: { depth: number }) {
if (depth === 0) return <div>Leaf</div>;
return (
<div>
<DeepTree depth={depth - 1} />
</div>
);
}
// 即使只有叶子节点变化,React 也需要遍历整条路径
// 解决方案:使用 memo + 扁平化结构 + 状态提升
5.8 Diff 算法的完整时间线
让我们追踪一次完整的 Diff 过程,从用户交互到 DOM 更新:
sequenceDiagram
participant User as 用户
participant React as React
participant Scheduler as Scheduler
participant Reconciler as Reconciler
participant DOM as DOM
User->>React: 点击按钮 → setState
React->>React: 标记更新 Lane
React->>Scheduler: ensureRootIsScheduled
Scheduler->>Scheduler: scheduleCallback(priority, callback)
Note over Scheduler: 等待时间切片...
Scheduler->>Reconciler: performConcurrentWorkOnRoot
Reconciler->>Reconciler: beginWork (Root)
Reconciler->>Reconciler: beginWork (App) → bailout?
alt 需要更新
Reconciler->>Reconciler: reconcileChildren
Note over Reconciler: 单节点 or 多节点 Diff
Reconciler->>Reconciler: 比较 key + type
Reconciler->>Reconciler: 复用 / 创建 / 删除 Fiber
Reconciler->>Reconciler: 标记 Placement / Deletion flags
else 可以 bailout
Reconciler->>Reconciler: cloneChildFibers
Note over Reconciler: 跳过子树
end
Reconciler->>Reconciler: completeWork
Note over Reconciler: 收集 effect list
Reconciler->>DOM: commitWork
DOM->>User: 用户看到更新
图 5-6:从 setState 到 DOM 更新的完整 Diff 时间线
5.6.3 源码核对:createChild 与 updateSlot 的镜像——插入时”不比较”
§5.5.1 讲了 updateSlot 能消费 7 种 child 类型。插入路径走的是 createChild(ReactChildFiber.js:512)——一个 updateSlot 的几乎完全镜像,只是没有比较逻辑:
function createChild(returnFiber, newChild, lanes) {
// 1. 文本——直接 createFiberFromText
if ((typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number') {
const created = createFiberFromText('' + newChild, returnFiber.mode, lanes);
created.return = returnFiber;
return created;
}
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return createFiberFromElement(...);
case REACT_PORTAL_TYPE:
return createFiberFromPortal(...);
case REACT_LAZY_TYPE:
return createChild(returnFiber, init(payload), lanes); // 递归
}
if (isArray(newChild) || getIteratorFn(newChild)) {
return createFiberFromFragment(...);
}
if (typeof newChild.then === 'function') {
return createChild(returnFiber, unwrapThenable(thenable), lanes);
}
// Context 同上递归
}
// function/其他 → 无效、返回 null
return null;
}
和 updateSlot 对比的三点观察:
1、没有 key 检查——新建不需要匹配旧 Fiber,key 只是”存到新 Fiber 上”而不用于决策。这让 createChild 的执行更快。
2、同样 7 种类型、同样的递归结构——React 保证 updateSlot 和 createChild 支持同一组 newChild 输入。如果未来新增一种 child 类型(比如某种新的 Usable),两个函数必须同步更新——这是本章开头源码注释 “If you change this code, also update reconcileChildrenIterator()” 警告的另一种体现。
3、createChild 返回 null 表示”这个 slot 跳过”——对应 reconcileChildrenArray 里 if (newFiber === null) continue;(§5.4 快速插入路径)。这意味着数组里的 null/false/undefined child 不会产生 Fiber——它们只是占位不渲染。读者经常困惑的 {condition && <X/>} 在 false 时为什么不渲染但 key 又不会错位——答案就在这条:根本没给这个 slot 产生 Fiber。
5.7.1 源码核对:SimpleMemoComponent 的隐形升级
§5.6 讲了 React.memo 的基本机制。真实的 updateMemoComponent(ReactFiberBeginWork.js:470)在首次渲染时会做一个隐形的 fiber 类型升级:
if (current === null) {
const type = Component.type;
if (
isSimpleFunctionComponent(type) &&
Component.compare === null &&
// SimpleMemoComponent codepath doesn't resolve outer props either.
Component.defaultProps === undefined
) {
let resolvedType = type;
if (__DEV__) {
resolvedType = resolveFunctionForHotReloading(type);
}
// If this is a plain function component without default props,
// and with only the default shallow comparison, we upgrade it
// to a SimpleMemoComponent to allow fast path updates.
workInProgress.tag = SimpleMemoComponent;
workInProgress.type = resolvedType;
return updateSimpleMemoComponent(...);
}
// ...
}
三条触发隐形升级的条件(isSimpleFunctionComponent + compare === null + defaultProps === undefined)同时满足时,React 把这个 Fiber 的 tag 从 MemoComponent 改成 SimpleMemoComponent——走一条更快的专用路径。
什么是 “SimpleMemoComponent fast path”?它直接在 Fiber tag 层内联了 memo 的比较逻辑,跳过常规 MemoComponent 的一层包装调用开销。代价是只支持默认浅比较 + 不支持 defaultProps——这两个都是 React 19 里”不推荐继续使用”的特性,所以大部分新代码能命中这条路径。
对业务代码的影响:
1、在函数组件上用 defaultProps 会让你失去 fast path——React.memo(Component) 如果 Component 还挂了 defaultProps,就走不了 SimpleMemoComponent。React 19 里函数组件的 defaultProps 已经被 deprecated,性能上也有实际惩罚,不只是”未来会删”。
2、自定义 compare 函数同样失去 fast path——React.memo(Component, customCompare) 走常规 MemoComponent 路径。这是一个你写自定义比较时需要权衡的成本:精确控制 re-render 条件、代价是多一层函数调用和分发。
3、大部分 React.memo(() => ...) 自动落入 SimpleMemoComponent——React 默认让最简单的使用形态获得最快的代码路径。这是一个工程原则:对默认用法做特殊优化,对高级用法保持正确性——和 §9 serde_derive 的 “SimpleMemoComponent = memo fast path、带 compare/defaultProps 走 slow path” 是同一种哲学。
5.7.1-bis 源码核对:shouldTrackSideEffects 是 createChildReconciler 的闭包参数
§5.2 一笔带过”mount 和 update 是同一函数的两个闭包实例”。真实 createChildReconciler 返回的所有函数都共享 shouldTrackSideEffects 这个闭包变量——散落在 6 个关键位置:
deleteChild(line 292):if (!shouldTrackSideEffects) return;——mount 时根本不建立 deletion listdeleteRemainingChildren(line 306):同上placeChild(line 361):mount 时不打 Placement flag、只打一个 Forked flag(给 useId 算法看)placeSingleChild(line 385):if (shouldTrackSideEffects && newFiber.alternate === null) flags |= PlacementreconcileChildrenArray(line 899):mount 时不删旧 FiberupdateFromMap(line 968):mount 时不清 Map
为什么 mount 不打这些 flag? 因为 mount 整棵树的挂载路径已经由父 Fiber 的 Placement flag 代表了——挂载父容器时一次性把整棵子树都放进 DOM,不需要每个子 Fiber 都打”请挂载我”的 flag。这是 React 的一条老优化:commit 阶段的 DOM 操作成本 ∝ 打了 flag 的 Fiber 数量,mount 时如果每个子 Fiber 都打 flag,10000 节点的初次渲染就要做 10000 次 DOM 操作;让父 Fiber 代表整棵树、commit 阶段一次性 appendChild 整棵树——把 N 次 DOM 操作压缩到 1 次。
placeSingleChild(line 385)专门处理单节点 Diff 的 Placement 决策:
function placeSingleChild(newFiber: Fiber): Fiber {
// This is simpler for the single child case. We only need to do a
// placement for inserting new children.
if (shouldTrackSideEffects && newFiber.alternate === null) {
newFiber.flags |= Placement | PlacementDEV;
}
return newFiber;
}
两个判断同时成立才打 Placement:
shouldTrackSideEffects——update 而非 mountnewFiber.alternate === null——新建的 Fiber(没有复用旧的)
只有”用户把 <A/> 换成了 <B/>”(单节点 Diff 不复用)这种场景才打 Placement。如果 alternate 存在(复用了旧 Fiber),说明是原地更新——不需要移动 DOM,只需要更新 props——这种情况由 completeWork 阶段打 Update flag 负责,Diff 阶段不管。
读懂这条让你能清楚地回答 “React 的 Placement、Update、Deletion 三类 flag 分别在什么阶段被打?”:
- Placement:
reconcileChildren里的placeChild/placeSingleChild——Diff 决策时 - Update:
completeWork阶段 diff props 时 - Deletion:
deleteChild——Diff 决策时
这张分工表把 Diff 和 Commit 的职责边界画得极清——Diff 负责决定”做什么”、Commit 负责”真的去做”。这条不变量到下一章(Commit)会再次出现。
5.7.2 源码核对:bailoutHooks + bailoutOnAlreadyFinishedWork 的组合
§5.6 讲 attemptEarlyBailoutIfNoScheduledUpdate 是 “props 和 context 都没变” 时跳过。真实 updateFunctionComponent(ReactFiberBeginWork.js:455)在执行完函数体后还有一次 bailout 机会:
if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes);
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
这段”渲染后 bailout”和入口处的”渲染前 bailout”不一样——渲染函数已经调用过了,所有 hooks 也都执行过了。为什么还能 bailout?
答案是 React 的一个机制叫 “eager state reducer”——useState 在 set 时如果检测到新 state 和旧 state Object.is 相等,会提前标记不更新,让 didReceiveUpdate 保持 false。这种情况下:
- 函数组件被调用了(因为 React 不知道用户代码内部结构,只能先跑一遍)
- 但产出的 JSX 和上一次完全一样(state 没变 + props 没变 + context 没变)
- 可以跳过下面的
reconcileChildren→ 直接复用子树
bailoutHooks(current, workInProgress, renderLanes) 做的是”回收这次渲染产生的 hook 相关副作用”——effect 已经 schedule 了的要撤回、未 schedule 的保持——让 hook 状态干净地回到上一次渲染后的状态。
这解释了一条经常被忽略的性能实践:setState(sameValue) 不是完全没代价的——React 会调用函数组件一次(跑一次整个函数体),但不会调子组件 + 不会更新 DOM。大多数场景”调用一次函数”成本可接受;但如果你的函数组件内部做了昂贵的 useMemo 依赖计算或复杂的 state selector,这一次多余调用可能会被观察到。解决方案是在 set 之前用 if (a !== b) setState(b) 显式短路——或者用 useSyncExternalStore 这种天生带 “值不变就不 schedule” 语义的 API。
5.7.3 源码核对:非法 child 的两条错误路径——object/function 的截然不同对待
当你不小心把一个普通 object(不是 ReactElement、不是 Portal)或一个 function 放到 JSX 的 children 位置时,React 的反应是一硬一软:
object 走硬路径——抛 Error(ReactChildFiber.js:240):
function throwOnInvalidObjectType(returnFiber: Fiber, newChild: Object) {
const childString = Object.prototype.toString.call(newChild);
throw new Error(
`Objects are not valid as a React child (found: ${
childString === '[object Object]'
? 'object with keys {' + Object.keys(newChild).join(', ') + '}'
: childString
}). ` +
'If you meant to render a collection of children, use an array ' +
'instead.',
);
}
错误消息的设计值得品:
Object.keys(newChild).join(', ')列出所有键名——让用户能立刻识别”哦是那个 response 对象”- “If you meant to render a collection of children, use an array instead.”——假设最常见的使用错误是”把 state 对象当成 children”
这条硬抛错是因为object 没有任何合理渲染方式——React 没办法把一个 {a: 1, b: 2} 对象”渲染”成任何 DOM。抛 Error 让用户立刻知道哪里写错了。
function 走软路径——只在 DEV 下 console.error(ReactChildFiber.js:255):
function warnOnFunctionType(returnFiber: Fiber) {
if (__DEV__) {
const componentName = getComponentNameFromFiber(returnFiber) || 'Component';
if (ownerHasFunctionTypeWarning[componentName]) return;
ownerHasFunctionTypeWarning[componentName] = true;
console.error(
'Functions are not valid as a React child. This may happen if ' +
'you return a Component instead of <Component /> from render. ' +
'Or maybe you meant to call this function rather than return it.',
);
}
}
函数不抛错、只警告,理由是React Server Components 允许函数作为 children——SC 场景下函数会被运行时求值。生产路径(非 SC)静默把函数当 null 处理、不渲染任何东西——在 SC 路径里函数被 unwrap 成真实 JSX。
这两种错误处理风格的对比让读者明白一条深层道理:React 的错误严格性分级——
- 能完全确认是 bug 的(object as child):throw,中断渲染
- 可能是 bug 也可能是合法 SC 用法的(function as child):console.error(DEV),生产静默
- 可能需要重试的(Suspense thenable):throw SuspenseException(§8),被 work loop 捕获
同样是”抛”,抛不同的对象、在不同 mode 下被不同的捕获器处理——这是 React 把错误分层成 “grade” 的工程手段。
5.7.4 coerceRef:字符串 ref 的 120 行的遗产代码
coerceRef(ReactChildFiber.js:120)——一个 120 行的函数,专门处理一个 React 18+ 已经强烈建议不用的特性:字符串 ref(<div ref="foo"/>)。字符串 ref 早在 2017 就被建议迁移到 useRef()/createRef(),但 React 一直保持 backward-compat。打开真实源码看这 120 行在做什么:
- 发现是字符串 ref 时,在
__DEV__下打一条非常长的 warning(包含迁移提示) - 向上找
element._owner(即使用这个元素的组件的 Fiber) - 如果 owner 不是 ClassComponent,抛错:“Function components cannot have string refs.”
- 检查旧 ref 是否还是同样字符串——是就复用旧的 ref function(避免每次渲染生成新函数导致 ref 回调被频繁调用 null/inst)
- 否则构造一个新的 ref function:
function(value) { owner.refs[stringRef] = value; }
120 行代码解决一个几乎没人再用的历史遗留特性。这背后是 React 团队的一条承诺:向后兼容性是团队责任、不是用户责任。即使一个特性被 deprecated 了 5 年,只要还没 remove,就必须在每个版本里继续工作——不能让用户升级 React 发现自己的代码无声崩坏。
这也解释了为什么 React 的 minor 版本迭代慢——每个改动都要考虑 backward compat。对比 Vue 每个大版本都可能破坏 API,React 选的是另一条路:框架演进的节奏被”不打扰老代码”这条底线限制住。
读到这条让你能正确看待 React 代码库里的每一段”看起来冗长/过时”的代码——它们大多不是技术债,而是对一个 2000 万使用者生态的承诺。下次你在业务代码里纠结”要不要保留一个老 API”时,可以想想 React 为了一个老字符串 ref 保留了 120 行——有时候保留代码比删代码更有工程价值。
5.8.1 本章与其他章节的系统性关联
读到这里,Reconciliation 已经从 §5.1 的三条启发式假设一路被拉到 §5.6.2 的”有 key 用 key、没 key 用 index”——表层规律 → 算法骨架 → 源码细节。这一章以一个”独立模块”出现,但它和全书其他部分的编织极其紧密,四条关键接缝值得显式点出:
与第 3 章(Fiber 架构)的接合点:Reconciliation 操作的不是 React Element 树——是 Fiber 树。第 3 章讲过 WIP/current 双缓冲——本章的 useFiber → createWorkInProgress 就是在”克隆 current、生成 WIP”。placeChild 标记的 Placement flag 挂在 WIP Fiber 上、到第 6 章 commit 时才被真正执行。理解这条”标记在 WIP、执行在 commit”的 lazy pipeline 需要把本章和 §3、§6 三章连起来读。
与第 6 章(Commit)的延续:§5.5.2 讲过 deleteChild 只打 flag 不操作 DOM——真正的 DOM 删除在下一章的 commitMutationEffects。第 6 章会详细讲 returnFiber.deletions 数组怎么被遍历、怎么调 unmount lifecycle、怎么调 ref cleanup。本章 + 下章连起来构成 React 渲染管线的”读(Diff 决策)+ 写(Commit 应用)“两端。
与第 7/8 章(Hooks、并发模式)的关系:Reconciliation 决定了哪些 Fiber 被复用——复用意味着 Hook 链表、state、context 订阅都继承;不复用意味着组件 unmount 再 mount,所有 hooks 状态清零。这解释了为什么 §5.1 的”type 变化 = 整棵子树重建”对开发者如此重要——state 丢失是 Diff 决策的直接后果。第 7 章里”Hooks 为什么必须在顶层以相同顺序调用”也和本章的 Fiber 复用规则紧密相关——同一个 Fiber 多次渲染用同一条 Hook 链表。
与 Vue 3 的 LIS 算法对照:§5.4 提到 Vue 3 用最长递增子序列做最优移动。Vue 3 选 LIS 是因为它的 VDOM 是完整的数组(可以任意索引),React 的 Fiber 是单向链表(只有 sibling)——数据结构决定了算法选择。这条对照如果回到 “Vue 源码系列” 卷的 diff 章节里读(本丛书第 3 卷《Vue 响应式与 vnode diff》),会看到 patchKeyedChildren 用 2 次端点扫 + LIS 的完整实现——两种哲学的并排对照比单看任一方都有价值。
这四条接合点不是”附加的参考”——它们是理解 React 为什么这么做 Diff 的必要上下文。Reconciliation 看起来是个算法问题,实际上是”数据结构约束 + 工程折衷 + 系统管线位置”的三维产物。只看算法层会错过为什么 React 用单向扫描;只看 Fiber 会错过 Diff 本身的启发式哲学。把本章放进 Reconciler → Scheduler → Commit → Hooks → 并发模式 的完整地图里理解,才能真正用好 React——而不是一知半解地抱怨”它为什么这样 diff”。
接下来的第 6 章会把本章打好的所有标记(Placement/Deletion/Update)落地到真实 DOM——这是你一直在期待的”另一半”。建议读完第 6 章后再回来重读本章 §5.5.2 的 deleteChild——那时候你会看到一个完整的”打标记 → 收集 effect list → 执行 commit”的循环,Reconciliation 的所有伏笔在下一章全部收束。
5.9 本章小结
Reconciliation 是 React 最核心的算法,它的设计体现了工程中一个永恒的主题:完美是好的敌人。
通过三条启发式假设,React 将一个 O(n³) 的理论问题转化为了一个 O(n) 的实际问题。两轮遍历的多节点 Diff 算法在最常见的场景下(属性更新)只需一轮线性扫描即可完成,只在涉及节点增删和重排时才需要第二轮的 Map 查找。
关键要点:
key是 Diff 算法的核心线索:它让 React 能够在 O(1) 时间内判断两个节点是否是”同一个”- Bailout 是最重要的优化:跳过整棵子树的 Diff 比优化 Diff 算法本身更有效
- Diff 是逐层进行的:不会跨层级比较节点
- 移动检测是从左到右的:在某些场景下不是最优的,但在大多数场景下足够好
- type 变化意味着子树重建:这是一个需要注意的性能陷阱
在下一章中,我们将看到 Diff 算法标记的那些 Placement、Deletion 等 flags 如何在 Commit 阶段被真正执行——从虚拟 DOM 到真实 DOM 的最后一步。
5.9.1 读完本章能回答的具体问题清单
本章覆盖了 Reconciliation 的表层启发式、算法骨架、以及 ReactChildFiber.js(1554 行)+ ReactFiberBeginWork.js(相关 bailout 逻辑)的源码细节。作为检验学习效果的 checklist,下列问题都能在本章内容里找到源码锚点的答案:
- 为什么 React 的多节点 Diff 不用双端搜索/LIS?(§5.4.1——Fiber 是单向链表,没有 backpointers;迭代器兼容性;工程上选简单的 O(n))
<div>{null}<A/></div>里的 null 会拖慢 Diff 吗?(§5.4.2——第一轮有oldFiber.index > newIdx的补偿逻辑,不会)- Fragment 复用时
useFiber的第二个参数为什么是children而不是props?(§5.4.3——Fragment 没有 props 对象) - 为什么
key={index}和不写 key 在删除场景下行为一样?(§5.6.2——React 默认用 newIdx 做兜底 key,显式写等价于默认) - Lazy 组件 resolve 后会重建 Fiber 吗?(§5.5.3——不会,
resolveLazy(elementType) === current.type允许 Fiber 复用) - HMR 能保持 state 是怎么实现的?(§5.5.3——
isCompatibleFamilyForHotReloading让 elementType 不等也能复用) React.memo(() => ...)和React.memo(Component, compare)性能一样吗?(§5.7.1——前者走 SimpleMemoComponent fast path、后者走普通 MemoComponent)setState(sameValue)完全零成本吗?(§5.7.2——函数体会被调用一次,但不会 reconcile 子树)- commit 前的渲染被中断会留下半删除的 DOM 吗?(§5.5.2——
deleteChild只打 flag,真删在 commit;不变量保证原子性) - mount 时每个子节点都打 Placement flag 吗?(§5.7.1-bis——不打,整棵子树用父节点一次 appendChild)
- 把对象当 children 和把函数当 children,React 反应一样吗?(§5.7.3——object throw Error、function 只 console.error)
如果上面 11 个问题你都能不回源码就说出原理——你对 Reconciliation 的掌握已经从”理解工作流程”过渡到”能 debug 和教别人”。这些问题把真实源码细节、工程决策和排障能力绑在一起,能回答它们,才说明你真正读懂了 React 的子树复用机制。
5.9.2 本章源码定位
为了让”绝对真实”的承诺可验证,下面列出本章所有引用的源码锚点。读者如果对任何一段分析存疑,按行号对照核对即可:
-
ReactChildFiber.js(1554 行)- line 120
coerceRef - line 240
throwOnInvalidObjectType - line 255
warnOnFunctionType - line 289
createChildReconciler(闭包工厂) - line 292
deleteChild - line 306
deleteRemainingChildren - line 325
mapRemainingChildren - line 346
useFiber - line 355
placeChild - line 385
placeSingleChild - line 394
updateTextNode - line 413
updateElement - line 512
createChild - line 604
updateSlot - line 695
updateFromMap - line 788
warnOnInvalidKey - line 835
reconcileChildrenArray(主逻辑 165 行) - line 1228
reconcileSingleElement
- line 120
-
ReactFiberBeginWork.js- line 301
let didReceiveUpdate(模块级标志) - line 455
bailoutHooks+bailoutOnAlreadyFinishedWork组合 - line 470
updateMemoComponent(含 SimpleMemoComponent 升级)
- line 301
源码版本:react 仓库 commit 546fe4681。如果你读到的 React 版本更新,行号可能有偏移,但函数名和主要逻辑结构应保持一致——函数名是比行号更稳定的定位锚。读者在 diff 自己的 React 版本和本章描述时,建议用函数名全文搜索而不是按行号翻——后者在社区 release 每 3-6 周一次的节奏下很快就失效。
最后一条给自学者的建议:想真正吃透本章内容,最好自己在 vscode 里打开 ReactChildFiber.js + ReactFiberBeginWork.js,把鼠标停在每一条本章引用的行上打断点,跑一次 codesandbox 的 React demo,让 Diff 真实地 trace 过去。文本教学能把”源码在说什么”讲清楚,debug 体验能把”源码被调用时的参数状态”烙进记忆——两者缺一不可。
思考题
-
为什么 React 的多节点 Diff 不使用最长递增子序列(LIS)算法来最小化移动操作? 从实现复杂度、实际场景分布、性能收益三个角度分析这个设计决策。
-
考虑以下场景:一个列表从
[A, B, C, D, E]变为[E, D, C, B, A](完全反转)。请手动模拟 React 的两轮 Diff 过程,计算需要几次 DOM 移动操作。最优解需要几次? -
React.memo的浅比较使用Object.is,而不是===。 请列举两者在哪些情况下会产生不同的结果,并解释 React 选择Object.is的原因。 -
假设你正在开发一个虚拟滚动列表组件,列表中的元素可能被频繁地插入、删除和重排。你会如何设计
key策略来最大化 React Diff 的效率?