文章

多节点Diff第一轮遍历深度解析

多节点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 === nullnewIdx === newChildren.length完美匹配,全部复用结束,无需进入第二轮
newIdx === newChildren.length(但 oldFiber 还有)新节点比旧节点少删除剩余 oldFiber
updateSlot 返回 nullkey 不匹配,冲突进入第二轮(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,需要进入第二轮处理剩下的。

「其实你每天都在用」

  1. 列表追加元素setTodos([...todos, newTodo]),第一轮全部匹配(前面的 key 都对上),最后一个新节点在 oldFiber 耗尽后直接创建——整个过程 O(n),无需 Map。

  2. 删除第一项:旧 [A, B, C] → 新 [B, C],newIdx=0 时 A≠B→跳出第一轮,existingChildren Map 中 B 和 C 被匹配复用,A 被删除。

  3. 反转列表(reverse():newIdx=0 时第一个元素 key 就对不上→第一轮 0 个匹配,全部进 Map。这是最差情况之一,但实际反转列表很少见。

  4. 列表中间插入splice(2, 0, newItem),前两个在第一轮匹配,到插入位置跳出→Map 中找到后续元素并复用。插入位置越靠后,第一轮贡献越大。

  5. 虚拟列表(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,绝不恋战。

本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量