多节点Diff第一轮遍历深度解析
一句话概括
多节点 Diff 第一轮遍历是”从左到右、顺序对比”的快速路径——新旧 key+type 都匹配就复用并继续,一遇到不匹配立即跳出,把剩余节点交给第二轮(Map 查找)处理。
核心知识点
1. 第一轮遍历做了什么
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ReactChildFiber.js 简化
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
let oldFiber = currentFirstChild;
let newIdx = 0;
// 第一轮:从左到右逐个比较
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
// key 不匹配 → 跳出第一轮!
break;
}
// key 匹配 → 复用或创建 → 连接到结果链表
previousNewFiber.sibling = newFiber;
oldFiber = oldFiber.sibling; // 旧链表往下走
}
// ... 出口判断,进入第二/三轮
}
updateSlot 是决策点:比较旧 fiber 的 key 和新 element 的 key。key 匹配时继续比 type;key 不匹配时返回 null,触发 break。
2. 三种结束模式
| 结束条件 | 含义 | 后续处理 |
|---|---|---|
oldFiber === null 且 newIdx === newChildren.length | 完美匹配,全部复用 | 结束,无需进入第二轮 |
newIdx === newChildren.length(但 oldFiber 还有) | 新节点比旧节点少 | 删除剩余 oldFiber |
updateSlot 返回 null | key 不匹配,冲突 | 进入第二轮(Map 查找) |
3. 典型场景走一遍
1
2
旧: [A(key=a), B(key=b), C(key=c), D(key=d)]
新: [A(key=a), B(key=b), X(key=x), C(key=c), D(key=d)]
1
2
3
newIdx=0: A 的 key "a" vs A 的 key "a" → ✅ 复用
newIdx=1: B 的 key "b" vs B 的 key "b" → ✅ 复用
newIdx=2: X 的 key "x" vs C 的 key "c" → ❌ 不匹配 → 跳出!
跳出后:前 2 个已处理,旧剩余 [C, D],新剩余 [X, C, D],进入第二轮 Map 查找。
4. 为什么先做顺序扫描而不是直接 Map
React 团队的统计:超过 90% 的列表更新不发生位置移动(仅追加、删除或修改 props)。顺序扫描在无变化时 O(n) 且无需构建 Map,比哈希查找更快、内存更省。只有顺序扫描失败时才退化到 Map 查找——这叫”常见情况快速路径”。
5. key 相同但 type 不同的特殊处理
1
2
旧: [<span key="a"/>, <li key="b"/>]
新: [<li key="a"/>, <li key="b"/>]
newIdx=0 时 key 匹配但 type 不同(span vs li)。updateSlot 不会返回 null(它会标记旧 fiber 删除并创建新 fiber),但新 fiber 的 alternate === null——这是”新创建”的信号。第一轮会继续还是跳出?跳出,因为不能复用旧 fiber,需要进入第二轮处理剩下的。
「其实你每天都在用」
列表追加元素:
setTodos([...todos, newTodo]),第一轮全部匹配(前面的 key 都对上),最后一个新节点在 oldFiber 耗尽后直接创建——整个过程 O(n),无需 Map。删除第一项:旧
[A, B, C]→ 新[B, C],newIdx=0 时 A≠B→跳出第一轮,existingChildrenMap 中 B 和 C 被匹配复用,A 被删除。反转列表(
reverse()):newIdx=0 时第一个元素 key 就对不上→第一轮 0 个匹配,全部进 Map。这是最差情况之一,但实际反转列表很少见。列表中间插入:
splice(2, 0, newItem),前两个在第一轮匹配,到插入位置跳出→Map 中找到后续元素并复用。插入位置越靠后,第一轮贡献越大。虚拟列表(windowing):只有可见区域的元素被渲染,窗口滚动时 key 大量变化,大部分情况靠 Map 查找。但窗口内的顺序不变的部分仍受益于第一轮。
常见误解 (FAQ)
❌ 误区 1:”React Diff 用双端比较”
React 用单向扫描 + Map 兜底。双端比较是 Vue 2/3 的策略,适合”旧的头和新的尾匹配”的场景(如把第一项移到最后)。React 选择更简单的实现,这跟 Fiber 是单向链表(不能从尾部反向遍历)有关。
❌ 误区 2:”key 用 index 会让 Diff 变慢”
主要不是性能问题,是正确性问题。index 作 key 时,列表中间插入一项会导致后续所有元素 key 都”换人”,React 复用错误节点的 DOM,输入框内容会错位。
❌ 误区 3:”第一轮能处理所有移动”
第一轮只处理”位置不变”的匹配。如果一个元素从位置 3 移到位置 0,第一轮 0 个匹配,全部交给第二轮 Map 查找+移动判定。移动本质上发生在第二轮。
一句话总结
Diff 算法的最大智慧不在于”怎么比”,而在于”什么时候别比了”——第一轮扫描 90% 的情况,冲突了就果断跳出,把难题交给 Map,绝不恋战。