Skip to main content

第 7 章:多节点 diff——React 怎么复用节点

第 6 章最后,reconcileChildren 把 JSX 元素变成 fiber。本章看它内部:一堆新元素(数组)和一堆旧 fiber(链表)摆在一起,React 怎么决定谁复用、谁新建、谁删除、谁移动。这就是 diff。

先看一个经典场景:

// 上一轮渲染:
<li key="a">A</li> <li key="b">B</li> <li key="c">C</li>
// 这一轮:
<li key="a">A</li> <li key="c">C</li> <li key="b">B</li>

理想做法:A 原地不动,C 和 B 换位。React 要算出「最小变更集」:哪个 DOM 不用碰、哪个要挪、哪个要删。

先记住这一句:diff 的目标是花最少的力气把旧列表变成新列表。它靠三样东西:双指针扫一遍、key 决定能不能复用、lastPlacedIndex 判断要不要移动

diff 的边界:只比同一层

先说一条铁律,diff 只比较同一父节点下的兄弟层。父 fiber 的 reconcileChildren 拿到的只是它自己的 children,父子之间的"层级"从不跨层复用。你在 div 里加了一层 span,那层子树直接重建,不跟别层比。所以 key 只在同一层内有意义,这也是第 2 章说"key 住元素上,给协调算法读"的原因。

一图流:多节点 diff 两遍走

第一遍:双指针,从左往右

多节点 diff 的实现在 reconcileChildrenArrayReactChildFiber.js:1124)。它维护两个指针:oldFiber 在旧的 fiber 链表上,newIdx 在新 children 数组上,从左往右一对一对地比。

for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
nextOldFiber = oldFiber.sibling;
const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
break; // key 对不上,第一遍到此为止
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
oldFiber = nextOldFiber;
}

updateSlotReactChildFiber.js:811)负责"对得上吗":type 相同且 key 相同,就复用旧的 fiber(新建一个 workInProgress 兄弟);对不上返回 null,第一遍 break

第一遍是纯往前看,它只能处理"头对头"能对上的情况。对不上,说明有插入、删除或乱序,进入分情况处理:

  • 新数组走完了:旧的还有剩,全删(deleteRemainingChildren444)。
  • 旧链表走完了:剩下的新元素全是插入,直接 createChild681)。
  • 两边都有剩:进第二遍。

源码注释说得很实在(1130):fiber 没有往回指的指针,所以 diff 不做"从两端往中间搜"的优化,就是单向往前。

第二遍:建 Map 按 key 找

两边都有剩,说明中间有乱序或插入。这时候把剩下的旧节点全塞进一个以 key 为索引的 Map(mapRemainingChildren463):

const existingChildren = mapRemainingChildren(oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
// ...
}

updateFromMap941)拿着新元素的 key 去 Map 里 O(1) 查旧 fiber:查到了就复用,并把这个 key 从 Map 里删掉;最后 Map 里剩下的,就是这次彻底消失的旧节点,全进删除列表。

位移判断:lastPlacedIndex

复用的节点,到底要不要真的移动 DOM?判断逻辑在 placeChild492),维护一个 lastPlacedIndex(上一个"原地不动"的节点在旧列表里的位置):

const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// This is a move.
newFiber.flags |= Placement; // 打上"要移动"的标记
return lastPlacedIndex;
} else {
return oldIndex; // 顺序没乱,原地待着
}

直觉是这样:只要某个节点在旧列表里的位置,还排在前面所有"没动过"的节点后面,它就不用动。一旦出现"位置比该有的靠前",就说明它被提前了,需要打个 Placement 标记,commit 阶段去挪。Placement 这个标记会在第 17 章(commit)被消费掉。

key 的价值,在这一章最清楚

没 key 时,React 只能用索引当 key 比,一旦列表中间插了一个,后面的全对不上、全重建。有 key,插入删除乱序都能精确复用。回到开头那个 A C B 的例子:有 key,A 不动、C 和 B 各打一个 Placement;没 key,几乎整条链重建。

顺带澄清一个常见误区:key 不是"性能优化"这么简单,它是"正确性"的一部分。列表项带内部状态(比如输入框里的字)时,没 key 会导致状态串位。看 diff 源码就明白了,key 直接决定 fiber 跟元素怎么配对。

动手实验

  1. 有 key vs 无 key:写一个列表,每个 <li> 内部放一个 useState 的输入框。在头部插一条新数据:无 key 时输入框内容会错位,有 key 时稳如泰山。这就是 key 的正确性意义。
  2. 看 Placement 标记:在 placeChild(ReactChildFiber.js:492)打断点,把列表 B C A 打乱成 C A B。观察 oldIndexlastPlacedIndex 的大小关系,哪些节点被打了 Placement
  3. 看第二遍的 Map:在 mapRemainingChildren(ReactChildFiber.js:463)打断点,中间插入一个新 key 的节点,观察 existingChildren Map 里 key 的增删。

小结

  • diff 只比较同一层兄弟,跨层直接重建。
  • 第一遍双指针从前往后对,key 对不上就停。
  • 新数组先走完 → 删剩余;旧链表先走完 → 全插入;都有剩 → 第二遍建 Map 按 key 找回。
  • lastPlacedIndex 判定位移oldIndex < lastPlacedIndex 就打 Placement 移动标记。
  • key 决定配对,影响的不只是性能,更是状态正确性
  • 下一步:render 阶段往下钻完了,开始往上走。第 8 章看 completeWork 怎么收集 DOM 副作用。