第 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 的实现在 reconcileChildrenArray(ReactChildFiber.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;
}
updateSlot(ReactChildFiber.js:811)负责"对得上吗":type 相同且 key 相同,就复用旧的 fiber(新建一个 workInProgress 兄弟);对不上返回 null,第一遍 break。
第一遍是纯往前看,它只能处理"头对头"能对上的情况。对不上,说明有插入、删除或乱序,进入分情况处理:
源码注释说得很实在(1130):fiber 没有往回指的指针,所以 diff 不做"从两端往中间搜"的优化,就是单向往前。
第二遍:建 Map 按 key 找
两边都有剩,说明中间有乱序或插入。这时候把剩下的旧节点全塞进一个以 key 为索引的 Map(mapRemainingChildren,463):
const existingChildren = mapRemainingChildren(oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
// ...
}
updateFromMap(941)拿着新元素的 key 去 Map 里 O(1) 查旧 fiber:查到了就复用,并把这个 key 从 Map 里删掉;最后 Map 里剩下的,就是这次彻底消失的旧节点,全进删除列表。
位移判断:lastPlacedIndex
复用的节点,到底要不要真的移动 DOM?判断逻辑在 placeChild(492),维护一个 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 跟元素怎么配对。
动手实验
- 有 key vs 无 key:写一个列表,每个
<li>内部放一个useState的输入框。在头部插一条新数据:无 key 时输入框内容会错位,有 key 时稳如泰山。这就是 key 的正确性意义。 - 看 Placement 标记:在
placeChild(ReactChildFiber.js:492)打断点,把列表B C A打乱成C A B。观察oldIndex和lastPlacedIndex的大小关系,哪些节点被打了Placement。 - 看第二遍的 Map:在
mapRemainingChildren(ReactChildFiber.js:463)打断点,中间插入一个新 key 的节点,观察existingChildrenMap 里 key 的增删。
小结
- diff 只比较同一层兄弟,跨层直接重建。
- 第一遍双指针从前往后对,key 对不上就停。
- 新数组先走完 → 删剩余;旧链表先走完 → 全插入;都有剩 → 第二遍建 Map 按 key 找回。
lastPlacedIndex判定位移:oldIndex < lastPlacedIndex就打Placement移动标记。- key 决定配对,影响的不只是性能,更是状态正确性。
- 下一步:render 阶段往下钻完了,开始往上走。第 8 章看
completeWork怎么收集 DOM 副作用。