React 19.3.1 源码深度剖析-第 4 章 Reconciliation 协调算法(Diff 算法全解)
本章是《React 19.3.1 源码深度剖析》系列的核心章节之一。它紧接第 3 章(Fiber 架构与 Flags 副作用模型),并为第 5 章(生命周期/Hook 与渲染)、第 6 章(Commit 阶段)提供算法基础。读完本章,你将能够:
- 徒手写出
reconcileChildrenArray的"头部线性对比 + 剩余建 Map"两段式算法;- 解释为什么 React 的 diff 是 O(n) 启发式而非通用的树编辑距离算法;
- 用源码逻辑证明"用数组下标当 key"为什么是灾难;
- 说清 React 19 在 render 阶段只
markUpdate、真正的 props diff 推迟到 commit 阶段这一关键变化;- 理解协调期的错误如何被包装成 Throw fiber 交给 Error Boundary。
所有结论均可在
react-19.3.1仓库的真实源码中核对,引用格式为文件:行号。
目录
- 动机与背景:协调算法为什么存在
- 整体心智模型
- 协调的入口:
reconcileChildren与 mount/update 分叉 - 分发中枢:
reconcileChildFibersImpl - 单节点 Diff:
reconcileSingleElement/reconcileSingleTextNode/reconcileSinglePortal - 数组 Diff 核心:
reconcileChildrenArray三段式算法(全章重点) - 用 mermaid 还原数组 Diff 的完整数据流
- 四个前后对比示例:插入头部 / 删除中间 / 重排 / key 错误
- Iterator/Map 的 Diff:
reconcileChildrenIterator - key 机制深挖:为什么 key 重要、index 兜底与 index 作 key 的灾难
- 辅助函数全家福:
createChild/updateElement/updateSlot/updateFromMap/useFiber/deleteChild - 边界场景穷举:文本 / Fragment / null·false / 嵌套 / key 冲突告警
- 与 props diff 链路的衔接(React 19 新模型)
- 协调期的错误处理:Throw fiber 与
throwException cloneChildFibers与resetChildFibers- 与其他模块的衔接(第 3 章 Flags、第 6 章 Commit)
- 性能分析与复杂度
- 用法与最佳实践(结合源码)
- 源码索引表
- 自测 FAQ
- 本章要点小结
1. 动机与背景:协调算法为什么存在
1.1 问题的本质
React 的核心承诺是"开发者用声明式描述 UI(一段 JSX/ReactElement 树),框架负责把这份声明高效地映射到真实宿主环境(DOM、Native、Canvas...)"。当我们调用 setState、改变 props 或调用 useReducer 触发一次更新时,React 会得到一棵新的 ReactElement 树。问题在于:
- 新的 element 树与旧的 fiber 树(已挂载、已对应真实 DOM 节点)之间,哪些节点可以复用 ?哪些需要新建 ?哪些需要删除?
- 复用的节点在 DOM 中的位置是否变了(移动 / 原地保留)?
- 节点自身的属性(props)变了哪些?
这套"比较新旧两棵树、决定如何最小代价地更新真实 DOM"的过程,就是 Reconciliation(协调) ,业界俗称 Diff 算法。
1.2 从 Stack Reconciler 到 Fiber Reconciler
在 React 15 及以前,协调基于 Stack Reconciler:递归遍历虚拟 DOM 树,递归调用组件 render,然后自底向上 commit。它有致命缺陷:
- 不可中断:递归一旦开始就必须走到黑,长达几十毫秒的递归会阻塞主线程,掉帧、卡顿。
- 无法区分优先级:所有更新一视同仁,无法为高优先级输入(如点击、输入)让路。
- 递归栈即"工作进度":一旦暂停,栈帧丢失,无法恢复。
React 16 引入 Fiber Reconciler ,把递归改成可中断、可恢复、带优先级的循环(见第 3 章)。协调不再是"一次性递归对比整棵树",而是:
- 以 workInProgress(工作中的 fiber 树) 为工作产物,与 current(上一次已提交的 fiber 树) 做对比;
- 通过
alternate指针在 current 与 workInProgress 之间互指,复用时直接createWorkInProgress(current, props)克隆; - 分阶段:
beginWork(向下构建子 fiber)→completeWork(向上归并副作用标记)。
本章聚焦的协调算法,正是 Fiber 架构下 beginWork 中"为某个 fiber 生成其子节点 fiber 链表"的那一段逻辑,全部位于 packages/react-reconciler/src/ReactChildFiber.js。
1.3 为什么是 O(n) 启发式,而不是通用树编辑距离
理论上,把一棵树转换为另一棵树的最优解是 树编辑距离(Tree Edit Distance) ,复杂度 O(n³) 甚至更高。React 用两条经验假设把复杂度压到 O(n):
- 不同类型(type)的节点,其子树结构完全不同,直接销毁重建 (
reconcileSingleElement中child.elementType !== elementType即走deleteRemainingChildren+ 新建)。这避免了跨 type 的子树逐个比对。 - 同一层级的一组子节点,用
key来标识身份(identity)。有了 key,React 就能在旧集合里 O(1) 查到"谁还是你",而不需要暴力两两比对。
这两条假设并非数学最优,但在真实 UI 中(列表项类型稳定、靠 key 区分)代价极小,收益巨大。源码里反复出现的 key 比对,就是假设 2 的落地。
2. 整体心智模型
一句话概括协调:
给定"父 fiber(returnFiber)""上一次提交的子 fiber 头节点(currentFirstChild)""这一次渲染出来的 ReactElement 子节点(newChild)",协调算法产出一个新的 workInProgress 子 fiber 链表,并在 fiber 上打上
Placement/ChildDeletion等 Flags,供第 6 章 commit 阶段消费。
可以把协调想象成"旧链表(current children,单链)与新数组(newChildren,可随机访问)"之间的对齐(alignment):
记住三个角色:
- returnFiber :父 fiber,副作用(Flags + deletions)挂在它身上(
returnFiber.flags |= ChildDeletion,returnFiber.deletions.push(...))。 - currentFirstChild :旧子链表的头。注意只传了"头",因为单链表只能从头部遍历(
child.sibling)。这是协调算法只能"前向单向"扫描的根本原因------源码注释也明说了"we don't have backpointers on fibers"。 - newChild:这一次渲染产出的子节点,可能是单个 ReactElement、文本、数组、iterator,或 null。
3. 协调的入口:reconcileChildren 与 mount/update 分叉
所有组件类型(FunctionComponent、ClassComponent、HostComponent、Fragment...)在 beginWork 里算出 nextChildren 后,都会调用 reconcileChildren。这是本章真正的"大门"。
下面这段来自 packages/react-reconciler/src/ReactFiberBeginWork.js:339:
js
// packages/react-reconciler/src/ReactFiberBeginWork.js:339
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
if (current === null) {
// If this is a fresh new component that hasn't been rendered yet, we
// won't update its child set by applying minimal side-effects. Instead,
// we will add them all to the child before it gets rendered. That means
// we can optimize this reconciliation pass by not tracking side-effects.
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
// If the current child is the same as the work in progress, it means that
// we haven't yet started any work on these children. Therefore, we use
// the clone algorithm to create a copy of all the current children.
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
3.1 current === null 决定 mount 还是 update
这一行 if (current === null) 是全章第一个分水岭,务必理解:
current === null:意味着这个 fiber 是第一次渲染 ,还没有任何"上一次的实例"可以对比。此时调用mountChildFibers(...)。current !== null:意味着有上一次的 fiber 树可比,调用reconcileChildFibers(...),并把current.child作为旧子链表头传入。
两个函数都来自同一个工厂 createChildReconciler,区别只在参数 shouldTrackSideEffects:
js
// packages/react-reconciler/src/ReactChildFiber.js:2110
export const reconcileChildFibers: ChildReconciler =
createChildReconciler(true); // 更新:追踪副作用
export const mountChildFibers: ChildReconciler =
createChildReconciler(false); // 挂载:不追踪副作用
shouldTrackSideEffects 这个名字直译为"是否追踪副作用"。它的含义是:这次协调产生的 fiber,是否需要被标记 Placement(插入)、是否要把删除的旧节点收集到 returnFiber.deletions?
3.2 为什么 mount 阶段不追踪副作用
首次挂载时,所有新 fiber 都将被 commit 阶段无条件创建并插入 DOM(appendChild),没有必要也不应该给它们打 Placement 标记------因为没有"旧节点"可对照,全部都是新增的。源码注释说得很清楚:"we can optimize this reconciliation pass by not tracking side-effects"。
看 createChildReconciler 里受 shouldTrackSideEffects 控制的几个关键行为:
deleteChild(:434):若!shouldTrackSideEffects直接return(noop)。deleteRemainingChildren(:448):同上,直接return null。placeChild(:511):若!shouldTrackSideEffects,只给flags |= Forked(用于 hydration 的 useId),然后return lastPlacedIndex,不打 Placement。placeSingleChild(:543):只有shouldTrackSideEffects && newFiber.alternate === null才打 Placement。
所以在 mount 路径下:
js
workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
// ↑ currentFirstChild 直接传 null
注意 mount 时 currentFirstChild 传的是 null(不是 current.child)。因为根本不存在旧子节点,协调从"空"开始,所有新子节点都是新建。
3.3 reconcileChildren 的调用方们
reconcileChildren 在 beginWork 里被各组件 updater 调用。举三个最典型的:
函数组件 (updateFunctionComponent,ReactFiberBeginWork.js:1453):render 出 nextChildren 后------
js
// 在 updateFunctionComponent 内部(约 :1546 之后,renderWithHooks 得到 nextChildren)
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
原生宿主组件 (updateHostComponent,ReactFiberBeginWork.js:1966):注意这里有个特殊优化------
js
// packages/react-reconciler/src/ReactFiberBeginWork.js:1981
let nextChildren = nextProps.children;
const isDirectTextChild = shouldSetTextContent(type, nextProps);
if (isDirectTextChild) {
// 直接文本子节点特例:不生成 HostText fiber,交由宿主环境处理
nextChildren = null;
} else if (prevProps !== null && shouldSetTextContent(type, prevProps)) {
// 从直接文本切到普通子节点,需要重置内容
workInProgress.flags |= ContentReset;
}
// ...
markRef(current, workInProgress);
reconcileChildren(current, workInProgress, nextChildren, renderLanes); // :2031
return workInProgress.child;
shouldSetTextContent 判断诸如 <div>hello</div> 这种"唯一子节点就是纯文本"的情况。这种特例避免了多分配一个 HostText fiber,是性能优化(详见 12.1)。
类组件、其它组件 同理,都汇聚到 reconcileChildren。换句话说,reconcileChildren 是整棵 workInProgress 树向下生长的"受精卵"。
4. 分发中枢:reconcileChildFibersImpl
reconcileChildFibers(对外导出版)是个壳,内部用 try/catch 包裹 reconcileChildFibersImpl,并处理 Suspense/错误(见第 14 章)。我们重点看分发逻辑 reconcileChildFibersImpl(ReactChildFiber.js:1854)。
js
// packages/react-reconciler/src/ReactChildFiber.js:1854
function reconcileChildFibersImpl(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
// Handle top level unkeyed fragments without refs as if they were arrays.
const isUnkeyedUnrefedTopLevelFragment =
typeof newChild === 'object' &&
newChild !== null &&
newChild.type === REACT_FRAGMENT_TYPE &&
newChild.key === null &&
(enableFragmentRefs ? newChild.props.ref === undefined : true);
if (isUnkeyedUnrefedTopLevelFragment) {
validateFragmentProps(newChild, null, returnFiber);
newChild = newChild.props.children; // 顶层无 key 无 ref 的 <></> 直接展开成子数组
}
// Handle object types
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
const firstChild = placeSingleChild(
reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes),
);
currentDebugInfo = prevDebugInfo;
return firstChild;
}
case REACT_PORTAL_TYPE:
return placeSingleChild(
reconcileSinglePortal(returnFiber, currentFirstChild, newChild, lanes),
);
case REACT_LAZY_TYPE: {
const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
const result = resolveLazy(newChild as any);
const firstChild = reconcileChildFibersImpl(
returnFiber, currentFirstChild, result, lanes,
);
currentDebugInfo = prevDebugInfo;
return firstChild;
}
}
if (isArray(newChild)) {
const firstChild = reconcileChildrenArray(
returnFiber, currentFirstChild, newChild, lanes,
);
return firstChild;
}
if (getIteratorFn(newChild)) {
const firstChild = reconcileChildrenIteratable(
returnFiber, currentFirstChild, newChild, lanes,
);
return firstChild;
}
if (enableAsyncIterableChildren &&
typeof newChild[ASYNC_ITERATOR] === 'function') {
const firstChild = reconcileChildrenAsyncIteratable(
returnFiber, currentFirstChild, newChild, lanes,
);
return firstChild;
}
// Usables:thenable / context,递归解包
if (typeof newChild.then === 'function') {
const thenable = newChild;
const firstChild = reconcileChildFibersImpl(
returnFiber, currentFirstChild, unwrapThenable(thenable), lanes,
);
return firstChild;
}
if (newChild.$$typeof === REACT_CONTEXT_TYPE) {
const context = newChild;
return reconcileChildFibersImpl(
returnFiber, currentFirstChild,
readContextDuringReconciliation(returnFiber, context, lanes), lanes,
);
}
throwOnInvalidObjectType(returnFiber, newChild);
}
// 字符串 / 数字 / bigint → 文本节点
if ((typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number' ||
typeof newChild === 'bigint') {
return placeSingleChild(
reconcileSingleTextNode(
returnFiber, currentFirstChild, '' + newChild, lanes,
),
);
}
if (__DEV__) {
if (typeof newChild === 'function') warnOnFunctionType(returnFiber, newChild);
if (typeof newChild === 'symbol') warnOnSymbolType(returnFiber, newChild);
}
// null / false / undefined / true / 空串 → 删除所有旧子节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
4.1 分发逻辑的分支树
几个关键点:
- 顶层数组被当作"子节点集合"而不是 Fragment 。注释:
If the top level item is an array, we treat it as a set of children, not as a fragment.但嵌套数组 (出现在 children 内部)会被createChild包成 Fragment fiber(见 11.1)。 - Lazy / thenable / context 在这里被递归"解包"成真实节点。注意递归只在
Usables/Lazy上发生,不会发生嵌套数组递归 ------reconcileChildrenArray内部遇到数组元素时,交给createChild包一层 Fragment。 REACT_ELEMENT_TYPE走reconcileSingleElement,且外面套一层placeSingleChild(只有新建才打 Placement)。- 最终兜底 :
null/false/undefined/true/空串都落到deleteRemainingChildren,即"这次没渲染任何东西,把旧子节点全删了"。
4.2 顶层无 key 无 ref 的 Fragment 展开
js
const isUnkeyedUnrefedTopLevelFragment =
typeof newChild === 'object' && newChild !== null &&
newChild.type === REACT_FRAGMENT_TYPE &&
newChild.key === null &&
(enableFragmentRefs ? newChild.props.ref === undefined : true);
<></>(无 key 无 ref)在协调入口处被原地展开 成它的 children,等价于把它从"树结构"里抹掉。这带来一个源码注释承认的歧义:<>{[...]}</> 与 <>...</> 在某些边界下被同等对待。但一旦给了 key 或 ref,Fragment 就会真正生成一个 Fragment fiber(createFiberFromFragment)。
5. 单节点 Diff
当 newChild 是单个 ReactElement / 文本 / Portal 时,算法不需要"数组对比",而是沿旧子链表逐个兄弟遍历 ,找到能复用的那个,其余删除。三个函数:reconcileSingleElement、reconcileSingleTextNode、reconcileSinglePortal。
5.1 reconcileSingleElement(单 Element)------ ReactChildFiber.js:1703
这是单节点 Diff 的核心。逐行拆解:
js
// packages/react-reconciler/src/ReactChildFiber.js:1703
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild; // 从旧子链表头开始
while (child !== null) {
// TODO: If key === null and child.key === null, then this only applies to
// the first item in the list.
if (
child.key === key ||
(enableOptimisticKey && child.key === REACT_OPTIMISTIC_KEY)
) {
// ① key 命中,再比 type
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
if (child.tag === Fragment) {
deleteRemainingChildren(returnFiber, child.sibling); // 删它后面的旧兄弟
const existing = useFiber(child, element.props.children);
if (enableOptimisticKey) existing.key = key;
if (enableFragmentRefs) coerceRef(existing, element);
existing.return = returnFiber;
if (__DEV__) { existing._debugOwner = element._owner; existing._debugInfo = currentDebugInfo; }
validateFragmentProps(element, existing, returnFiber);
return existing;
}
} else {
if (
child.elementType === elementType ||
(__DEV__ ? isCompatibleFamilyForHotReloading(child, element) : false) ||
(typeof elementType === 'object' && elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === child.type)
) {
// ② key 命中 且 type 匹配:复用
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
if (enableOptimisticKey) existing.key = key;
coerceRef(existing, element);
existing.return = returnFiber;
if (__DEV__) { existing._debugOwner = element._owner; existing._debugInfo = currentDebugInfo; }
return existing;
}
}
// ③ 走到这里说明 key 命中但 type 不匹配:删整条链表后 break
deleteRemainingChildren(returnFiber, child);
break;
} else {
// ④ key 不命中:逐个 deleteChild 这个旧兄弟,继续找
deleteChild(returnFiber, child);
}
child = child.sibling; // 沿兄弟链表前进
}
// ⑤ 遍历完都没找到能复用的:新建
if (element.type === REACT_FRAGMENT_TYPE) {
const created = createFiberFromFragment(
element.props.children, returnFiber.mode, lanes, element.key,
);
// ... 设置 debugInfo/ref ...
return created;
} else {
const created = createFiberFromElement(element, returnFiber.mode, lanes);
coerceRef(created, element);
created.return = returnFiber;
// ... debugInfo ...
return created;
}
}
5.1.1 四个分支的精确语义
把 while 循环拆解成四种结局:
| 旧链表状态 | 条件 | 动作 | 代码位置 |
|---|---|---|---|
| key 命中 & type 匹配 | child.key===key && child.elementType===elementType |
deleteRemainingChildren(child.sibling) 删掉它后面的所有旧兄弟(因为这次只有一个新节点,后面的旧兄弟都没用了),然后 useFiber(child, props) 复用当前节点 |
:1739-1766 |
| key 命中 & type 不匹配 | child.key===key 但 type 不同 |
deleteRemainingChildren(child) 把从当前节点开始整条链表 全删(注意这里传 child 不是 child.sibling,因为当前这个 type 不对的也要删),break 跳出循环 |
:1769-1771 |
| key 不命中 | child.key !== key |
deleteChild(returnFiber, child) 删掉这个旧兄弟,继续 child = child.sibling 找下一个 |
:1772-1774 |
| 遍历完都没命中 | 循环结束 | 走到函数末尾,根据 element.type 新建 Fragment 或 Element fiber |
:1778-1805 |
5.1.2 一个极易混淆的细节:两种情况删除范围不同
- type 匹配时 删的是
child.sibling(保留当前这个复用的 child,删它后面所有兄弟)。 - type 不匹配时 删的是
child(连同当前这个 type 不对的也删掉),然后 break。
原因:type 不匹配意味着"同一个 key 下,这次的组件类型变了"。React 的假设是"同 key 同 type 才是同一个身份",类型变了就是全新组件,当前节点连同其后兄弟全部作废,最后走到末尾新建。这是 1.3 节假设 1(不同类型子树直接重建)的体现。
5.1.3 useFiber 与 createFiberFromElement 的区别
js
// packages/react-reconciler/src/ReactChildFiber.js:502
function useFiber(fiber: Fiber, pendingProps: mixed): Fiber {
const clone = createWorkInProgress(fiber, pendingProps);
clone.index = 0;
clone.sibling = null;
return clone;
}
useFiber = 复用旧 fiber 的 alternate 克隆,保留其 DOM 实例(stateNode)。createFiberFromElement = 从零创建新 fiber(后面 commit 阶段才会 createInstance 建 DOM)。是否复用 DOM 实例,就是这两者最大的区别,也是协调算法"高效"的根本。
5.2 reconcileSingleTextNode(单文本)------ ReactChildFiber.js:1673
js
// packages/react-reconciler/src/ReactChildFiber.js:1673
function reconcileSingleTextNode(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
textContent: string,
lanes: Lanes,
): Fiber {
// 文本节点没有 key,不检查 key
if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
// 旧第一个孩子就是文本节点:复用,删其余
deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
const existing = useFiber(currentFirstChild, textContent);
existing.return = returnFiber;
return existing;
}
// 旧第一个孩子不是文本节点:删全部旧的,新建文本 fiber
deleteRemainingChildren(returnFiber, currentFirstChild);
const created = createFiberFromText(textContent, returnFiber.mode, lanes);
created.return = returnFiber;
if (__DEV__) { /* debugInfo */ }
return created;
}
文本节点的特殊之处:没有 key (源码注释 There's no need to check for keys on text nodes since we don't have a way to define them)。所以只比"旧第一个孩子是不是 HostText":
- 是 → 复用(改
pendingProps为新的textContent),删其余兄弟; - 否 → 删光旧的,新建。
这解释了为什么 <div>hello</div> 改成 <div><span/>world</div> 时,旧的文本 fiber 会被销毁、新建 span:因为第一个孩子从 HostText 变成了 HostComponent。
5.3 reconcileSinglePortal(单 Portal)------ ReactChildFiber.js:1808
Portal 与 Element 几乎一致,只是复用条件除了 key 还要比较 containerInfo 和 implementation:
js
if (child.tag === HostPortal &&
child.stateNode.containerInfo === portal.containerInfo &&
child.stateNode.implementation === portal.implementation) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, portal.children || []);
// ...
return existing;
} else {
deleteRemainingChildren(returnFiber, child);
break;
}
5.4 placeSingleChild(仅新建打 Placement)------ ReactChildFiber.js:543
单节点复用/新建后,外面套一层 placeSingleChild:
js
// packages/react-reconciler/src/ReactChildFiber.js:543
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;
}
含义:只有当 shouldTrackSideEffects 为真(更新阶段),且这个 fiber 是新建的(alternate === null,即没有对应旧 fiber)时,才打 Placement。
- 如果是复用(
alternate !== null),说明它就在这个位置,不需要移动,不打 Placement。 PlacementDEV是 dev 模式下用于 StrictMode 双调用识别"这是新挂载"的标记。
把单节点 Diff 的整体流程画成图:
6. 数组 Diff 核心:reconcileChildrenArray 三段式算法(全章重点)
现在进入全章最硬核的部分。数组 Diff 是整个协调算法里最复杂的函数,源码位于 packages/react-reconciler/src/ReactChildFiber.js:1177,约 190 行。它解决的是:新子节点是数组(可随机访问),旧子节点是单链表(只能从头遍历),如何高效对齐并标记移动/删除/新增。
源码注释开篇就解释了算法设计取舍(极重要,建议背诵):
text
// packages/react-reconciler/src/ReactChildFiber.js:1183
// 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. ...
// In this first iteration, we'll just live with hitting the bad case
// (adding everything to a Map) in for every insert/move.
核心约束:fiber 是单链表,没有回退指针 ,所以无法做"双端(头尾同时)对比"(那是 Vue 2 / inferno 的算法)。React 选择了一个前向单向 、两段式的 O(n) 启发式:
- 第一阶段(线性同位置对比) :新数组从头、旧链表从头,按 index 一个个比对。只要 key 能对上(或文本节点)就复用;一旦对不上就立刻退出快速路径。
- 第二阶段(分支) :
- 若新数组先遍历完 → 旧链表剩余的全部删除(
deleteRemainingChildren)。 - 若旧链表先遍历完(oldFiber 变 null) → 新数组剩下的全部新建插入。
- 若新数组先遍历完 → 旧链表剩余的全部删除(
- 第三阶段(剩余建 Map) :若两者都没遍历完(说明中间某处 key 错位,发生了移动/乱序),把剩余的旧链表 按 key(无 key 用 index)建 Map,再遍历剩余的新数组 ,用
updateFromMap在 Map 里查复用,最后 Map 里残留的旧节点全部删除。
6.1 完整源码(逐段标注)
js
// packages/react-reconciler/src/ReactChildFiber.js:1177
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<any>,
lanes: Lanes,
): Fiber | null {
let knownKeys: Set<string> | null = 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) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
const newFiber = updateSlot(
returnFiber, oldFiber, newChildren[newIdx], lanes,
);
if (newFiber === null) {
// 快速路径中断(key 不匹配)
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
if (__DEV__) {
knownKeys = warnOnInvalidKey(returnFiber, newFiber, newChildren[newIdx], knownKeys);
}
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
// 槽位匹配上了,但没复用旧 fiber(是新建),需要把旧 fiber 删掉
deleteChild(returnFiber, oldFiber);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
// ============ 阶段二分支 A:新数组用完 → 删剩余旧节点 ============
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}
// ============ 阶段二分支 B:旧链表用完 → 剩余新节点全插入 ============
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) continue;
if (__DEV__) {
knownKeys = warnOnInvalidKey(returnFiber, newFiber, newChildren[newIdx], knownKeys);
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}
// ============ 阶段三:剩余旧节点建 Map,按 key 复用 ============
const existingChildren = mapRemainingChildren(oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes,
);
if (newFiber !== null) {
if (__DEV__) {
knownKeys = warnOnInvalidKey(returnFiber, newFiber, newChildren[newIdx], knownKeys);
}
if (shouldTrackSideEffects) {
const currentFiber = newFiber.alternate;
if (currentFiber !== null) {
// 复用了旧 fiber,从 Map 里删掉,避免最后被当残留删除
if (enableOptimisticKey && currentFiber.key === REACT_OPTIMISTIC_KEY) {
existingChildren.delete(-newIdx - 1);
} else {
existingChildren.delete(
currentFiber.key === null ? newIdx : currentFiber.key,
);
}
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
// Map 里残留的旧节点 = 没被任何新节点复用 → 删除
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}
6.2 局部变量总览
| 变量 | 含义 |
|---|---|
resultingFirstChild |
新链表的头节点,最终返回挂到 workInProgress.child |
previousNewFiber |
上一轮生成的 newFiber,用于 previousNewFiber.sibling = newFiber 串链表 |
oldFiber |
当前正在对比的旧 fiber(沿 sibling 前进) |
lastPlacedIndex |
已"原地保留"的旧节点里最大的旧 index ;用于判断后续节点要不要打 Placement(移动判定核心,见 6.4) |
newIdx |
当前新数组下标 |
nextOldFiber |
预取的下一个旧 fiber(oldFiber.sibling),处理 oldFiber.index > newIdx 的跳跃 |
knownKeys |
dev 模式去重 key 告警用的 Set |
6.3 updateSlot:快速路径的"同位置 key 比对"
阶段一每次循环调用 updateSlot(ReactChildFiber.js:842)来决定"当前旧 fiber 和当前新元素能否在同位置复用":
js
// packages/react-reconciler/src/ReactChildFiber.js:842
function updateSlot(
returnFiber: Fiber,
oldFiber: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
const key = oldFiber !== null ? oldFiber.key : null;
if ((typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number' || typeof newChild === 'bigint') {
// 文本节点:旧节点没 key 才复用(文本节点本身也没 key)
if (key !== null) 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 必须相同
const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
const updated = updateElement(returnFiber, oldFiber, newChild, lanes);
currentDebugInfo = prevDebugInfo;
return updated;
} else {
return null; // key 不同 → 快速路径中断
}
}
case REACT_PORTAL_TYPE: {
if (newChild.key === key) {
return updatePortal(returnFiber, oldFiber, newChild, lanes);
} else {
return null;
}
}
case REACT_LAZY_TYPE: { /* resolveLazy 后递归 updateSlot */ }
}
// 数组 / iterator / thenable / context 分支...
if (isArray(newChild) || getIteratorFn(newChild) || ...) {
if (key !== null) return null;
// 包成 Fragment
return updateFragment(returnFiber, oldFiber, newChild, lanes, null);
}
// ...
throwOnInvalidObjectType(returnFiber, newChild);
}
return null;
}
updateSlot 的契约(函数头注释):Update the fiber if the keys match, otherwise return null. 也就是说,它只在"旧 fiber 的 key 和新元素的 key 相同"时返回一个可复用(或新建)的 fiber;否则返回 null,触发阶段一 break。
注意文本节点 / 数组 / iterator 这类没有 key 的节点 :它们只有在"旧 fiber 也无 key(key === null)"时才复用。一旦旧 fiber 有 key 而新节点无 key,直接 return null,快速路径中断------这正是"key 不一致就走慢路径"的体现。
6.4 placeChild 与 lastPlacedIndex:移动判定的精髓
这是数组 Diff 最精妙的部分,决定哪个节点需要打 Placement(在 commit 阶段被移动 DOM 位置)。
js
// packages/react-reconciler/src/ReactChildFiber.js:511
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number,
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// mount 阶段:只用 Forked 标记(hydration useId),不打 Placement
newFiber.flags |= Forked;
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// 移动:旧位置在 lastPlacedIndex 之前 → 打 Placement
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
// 原地保留:不动,lastPlacedIndex 前进到 oldIndex
return oldIndex;
}
} else {
// 新增插入
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
}
}
6.4.1 lastPlacedIndex 的语义
lastPlacedIndex 始终保持为"到目前为止,所有'原地保留'(没有移动)的复用节点中,最大的旧 index"。
判定规则只有一句话:
如果当前复用节点的旧 index ≥ lastPlacedIndex,说明它相对前面的兄弟位置没往后退,可以原地保留,lastPlacedIndex 前进到它的旧 index;否则说明它"倒退"了,必须打 Placement(移动)。
为什么这个规则正确?因为 DOM 中节点的相对顺序由"原地保留 + 必要时移动"决定。React 假设:从前往后扫,凡是旧 index 单调递增的复用节点,它们的相对顺序已经是正确的,不需要动;一旦遇到一个复用节点的旧 index 比前面已确认"不动"序列的最大 index 还小,就意味着它本来在更靠前的位置、现在被排到了后面------相对顺序乱了,必须靠 Placement 在 commit 阶段把它插到正确位置。
6.4.2 三个阶段都调用 placeChild
注意 lastPlacedIndex 在阶段一、阶段二 B、阶段三里被一路 placeChild 累加传递。它跨阶段连续有效 ,是全局唯一的"不动序列锚点"。这意味着即便走完了快速路径再进 Map 慢路径,lastPlacedIndex 仍携带阶段一已确认的"不动序列"信息,移动判定是正确的。
6.4.3 一个小陷阱:阶段一中 oldFiber && newFiber.alternate === null 的删除
js
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
// 槽位匹配上了,但没复用旧 fiber(是新建),需要把旧 fiber 删掉
deleteChild(returnFiber, oldFiber);
}
}
在阶段一,如果 updateSlot 返回了一个新建 的 fiber(alternate === null,例如同位置 key 相同但 type 不同导致 updateElement 内部走新建分支),说明旧 fiber 不能被复用,要删除。updateSlot 返回非 null 让循环继续,但旧 fiber 要被 deleteChild 清掉。这是"key 相同但 type 不同"的边界处理。
6.5 阶段三:mapRemainingChildren + updateFromMap
当快速路径因 key 错位中断后,进入慢路径。先把剩余旧链表建 Map:
js
// packages/react-reconciler/src/ReactChildFiber.js:467
function mapRemainingChildren(
currentFirstChild: Fiber,
): Map<string | number | ReactOptimisticKey, Fiber> {
const existingChildren = new Map();
let existingChild = currentFirstChild;
while (existingChild !== null) {
if (existingChild.key === null) {
// 无 key 的,用 index 当 key 存进 Map
existingChildren.set(existingChild.index, existingChild);
} else if (enableOptimisticKey && existingChild.key === REACT_OPTIMISTIC_KEY) {
// optimistic key:存成 -index-1 以便和正整数 index 区分
existingChildren.set(-existingChild.index - 1, existingChild);
} else {
existingChildren.set(existingChild.key, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}
然后用 updateFromMap 遍历剩余新数组,在 Map 里查:
js
// packages/react-reconciler/src/ReactChildFiber.js:985(节选)
function updateFromMap(
existingChildren, returnFiber, newIdx, newChild, lanes,
): Fiber | null {
if ((typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number' || typeof newChild === 'bigint') {
const matchedFiber = existingChildren.get(newIdx) || null;
return updateTextNode(returnFiber, matchedFiber, '' + newChild, lanes);
}
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
const matchedFiber =
existingChildren.get(newChild.key === null ? newIdx : newChild.key) ||
(enableOptimisticKey && existingChildren.get(-newIdx - 1)) ||
null;
const updated = updateElement(returnFiber, matchedFiber, newChild, lanes);
return updated;
}
// ... portal / lazy / array / thenable / context ...
}
}
return null;
}
关键差异:updateSlot 要求"旧 fiber 恰好在同位置且 key 相同",而 updateFromMap 是"在整张 Map 里按 key(无 key 按 index)找任意位置的旧 fiber"。这正是慢路径能处理乱序/移动的原因------不再受同位置限制。
6.5.1 Map 残留 = 需要删除的旧节点
阶段三结束后,existingChildren 里没被任何新节点取走 的旧 fiber,就是"这次新数组里已经不存在"的旧节点。reconcileChildrenArray 末尾:
js
if (shouldTrackSideEffects) {
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
把它们全部 deleteChild,挂到 returnFiber.deletions + ChildDeletion 标记。
6.5.2 复用后要从 Map 里删除
阶段三循环里,复用成功(newFiber.alternate !== null)后:
js
if (shouldTrackSideEffects) {
const currentFiber = newFiber.alternate;
if (currentFiber !== null) {
if (enableOptimisticKey && currentFiber.key === REACT_OPTIMISTIC_KEY) {
existingChildren.delete(-newIdx - 1);
} else {
existingChildren.delete(currentFiber.key === null ? newIdx : currentFiber.key);
}
}
}
为什么必须删? 避免一个旧 fiber 被多个新节点"重复复用"。如果复用后不删,残留遍历时它不会被删除(正确),但更关键的是:下一个同 key 的新节点会再次从 Map 取同一个旧 fiber,导致复用错乱。删除保证"一对一"复用。
6.6 把三段式算法写成"可照着写"的伪代码
为便于记忆与实现,把 reconcileChildrenArray 提炼成与源码等价的伪代码:
text
function reconcileChildrenArray(returnFiber, oldHead, newArr, lanes):
newHead = null
prev = null
old = oldHead
lastPlaced = 0
i = 0
# ---- 阶段一:线性同位置 ----
while old != null and i < newArr.length:
nextOld = (old.index > i) ? old : old.sibling
slot = updateSlot(returnFiber, old, newArr[i], lanes)
if slot == null: # key 不匹配,退出快速路径
if old == null: old = nextOld
break
if track and old != null and slot.alternate == null:
deleteChild(returnFiber, old) # 同位置但没复用
lastPlaced = placeChild(slot, lastPlaced, i)
if prev == null: newHead = slot
else: prev.sibling = slot
prev = slot
old = nextOld
i += 1
# ---- 阶段二 A:新数组用完 ----
if i == newArr.length:
deleteRemainingChildren(returnFiber, old)
return newHead
# ---- 阶段二 B:旧链表用完 ----
if old == null:
while i < newArr.length:
f = createChild(returnFiber, newArr[i], lanes)
if f == null: continue
lastPlaced = placeChild(f, lastPlaced, i)
if prev == null: newHead = f
else: prev.sibling = f
prev = f
i += 1
return newHead
# ---- 阶段三:剩余建 Map ----
map = mapRemainingChildren(old)
while i < newArr.length:
f = updateFromMap(map, returnFiber, i, newArr[i], lanes)
if f != null:
if track and f.alternate != null:
map.delete(f.alternate.key == null ? i : f.alternate.key)
lastPlaced = placeChild(f, lastPlaced, i)
if prev == null: newHead = f
else: prev.sibling = f
prev = f
i += 1
if track:
map.forEach(child -> deleteChild(returnFiber, child))
return newHead
7. 用 mermaid 还原数组 Diff 的完整数据流
把第 6 章的三段式用一个流程图串起来:
这条链路里,任何"快速路径中断"都只是跳出阶段一,后续统一由阶段二/三兜底,保证算法在任何输入下都能终止且结果正确。
8. 四个前后对比示例
下面用具体的新旧数组,手算协调算法产出的 fiber 与 Flags,验证你对源码的理解。约定 :旧 fiber 表示为 [key@oldIndex],新元素表示为 <key>,P=打 Placement,D=删除(ChildDeletion 收集)。
8.1 示例一:插入头部(最经典)
旧 :[A@0, B@1, C@2] 新 :[X, A, B, C](X 无 key,A/B/C key 分别为 'a'/'b'/'c')
过程:
- 阶段一:
- newIdx=0,oldFiber=A@0。updateSlot(X, A):X 是文本/无key,A 有 key 'a',
key !== null→ 返回 null。快速路径中断 。oldFiber 此时不是 null(是 A),走break。
- newIdx=0,oldFiber=A@0。updateSlot(X, A):X 是文本/无key,A 有 key 'a',
- 阶段二:newIdx=0 ≠ len(4),且 oldFiber=A != null → 不进二A/二B,进阶段三。
- 阶段三:
mapRemainingChildren(A)={'a':A, 'b':B, 'c':C}。- newIdx=0,X 无 key →
existingChildren.get(0)。A 的 key 是 'a' 不是 index,所以 get(0) 为 undefined → updateTextNode(oldFiber=null) → 新建 X ,alternate===null→ placeChild 打 P(插入)。lastPlacedIndex 不变(新建不前进)。 - newIdx=1,A(key='a') → map.get('a')=A → 复用。
alternate!==null,placeChild:A 的 oldIndex=0,0 < lastPlacedIndex(0)?否(相等)→ 原地保留,lastPlacedIndex 前进为 0(还是 0)。从 Map 删 'a'。 - newIdx=2,B(key='b') → map.get('b')=B,oldIndex=1 ≥ 0 → 原地保留。lastPlaced=1。删 'b'。
- newIdx=3,C(key='c') → map.get('c')=C,oldIndex=2 ≥ 1 → 原地保留。lastPlaced=2。删 'c'。
- newIdx=0,X 无 key →
- Map 空,无残留删除。
结论 :X 打 Placement(插入到头部),A/B/C 原地保留、不打 Placement。这正是 React 期望的高效结果------只插入一个节点,其余不动。注意:这里因为 X 无 key,它通过 get(newIdx=0) 没匹配到任何旧节点所以是新建,而 A/B/C 凭借 key 被正确复用。如果 X 也给了 key 比如 'x',则 map.get('x') 同样为 undefined → 新建,效果一致。
8.2 示例二:删除中间
旧 :[A@0, B@1, C@2] 新 :[A, C]
- 阶段一:
- newIdx=0,A@0:key 匹配 → 复用,oldIndex=0 ≥ lastPlaced(0) → 原地,lastPlaced=0。oldFiber=B。
- newIdx=1,B@1 vs 新 C(key='c'):updateSlot(C, B),B.key='b' !== 'c' → 返回 null。中断。
- 阶段三:
mapRemainingChildren(B)={'b':B, 'c':C}(从 B 开始)。- newIdx=1,C(key='c') → map.get('c')=C → 复用,oldIndex=2 ≥ lastPlaced(0) → 原地保留,lastPlaced=2。删 'c'。
- Map 残留
'b':B→deleteChild(B),B 被删除。
结论:A、C 原地保留,B 被删除。正确。
8.3 示例三:重排(逆序,最考验移动判定)
旧 :[A@0, B@1, C@2] 新 :[C, B, A](key 分别为 'c'/'b'/'a')
- 阶段一:
- newIdx=0,A@0 vs C(key='c'):key 不匹配 → null 中断。
- 阶段三:
map = {'a':A,'b':B,'c':C}。- newIdx=0,C(key='c') → map.get('c')=C,oldIndex=2。placeChild:2 < lastPlaced(0)? 否 → 原地保留,lastPlaced=2。删 'c'。
- newIdx=1,B(key='b') → map.get('b')=B,oldIndex=1 < lastPlaced(2)? 是 → 打 P(移动)。lastPlaced 不变=2。删 'b'。
- newIdx=2,A(key='a') → map.get('a')=A,oldIndex=0 < 2? 是 → 打 P(移动)。删 'a'。
- Map 空。
结论 :C 原地保留(因为它在旧数组 index 最大,排到新数组第一个刚好"不需要后退"),B、A 各打一次 Placement 被移动到正确位置。整体只移动 2 个节点,而不是销毁重建 3 个。这就是 key + lastPlacedIndex 启发式的价值。
注意一个反直觉点:逆序时最"稳"的是旧 index 最大的那个(C)。因为算法从前往后扫,C 第一个处理,它相对"前面什么都不需要"自然是原地。这也解释了为什么把"最常不动的节点"排在旧数组末尾理论上更优。
8.4 示例四:key 错误(用 index 当 key 的灾难)
假设列表 list = [{id:1,name:'A'},{id:2,name:'B'},{id:3,name:'C'}],渲染时 list.map((item, index) => <li key={index}>{item.name}</li>)。
旧 :[li(key=0)@0, li(key=1)@1, li(key=2)@2],对应内容 A,B,C。 现在把数组改成 list = [{id:3,name:'C'},{id:1,name:'A'},{id:2,name:'B'}](头部插入 C),但 key 仍是 index。
新 :[li(key=0)对应C, li(key=1)对应A, li(key=2)对应B]
- 阶段一:
- newIdx=0,旧 li@0(key=0)。新元素 key=0 → 匹配!updateSlot 复用。但注意:复用时
useFiber用的是旧 fiber 的 DOM 实例,而旧 fiber@0 的 DOM 上显示的是 'A',新元素想显示 'C'。 - 结果是:旧第一个 li(内容是 A)被复用,它的 pendingProps 变成 C 的内容。type 相同(都是 li),所以 DOM 实例保留,只是文字被就地修改。
- newIdx=1,旧 li@1(key=1) 复用 → 显示 A。
- newIdx=2,旧 li@2(key=2) 复用 → 显示 B。
- newIdx=0,旧 li@0(key=0)。新元素 key=0 → 匹配!updateSlot 复用。但注意:复用时
- 阶段二 A:newIdx=3==len → deleteRemainingChildren(null),无删除。
表面看"没删没建,很高效" ,但这是假象式的灾难:
- 状态错乱:如果 li 内部有受控输入、本地 state、或 ref,这些状态是绑定在 fiber/DOM 实例上的。用 index 当 key,A 的状态跟着"位置 0 的 fiber"走,但实际数据变成了 C。于是用户在 A 的输入框打的字,会错误地出现在 C 上。这是经典 bug。
- 无法正确识别增删:头部插入时,理想结果是"新增一个 C、A/B 位置后移"。但 index key 让 React 误以为"三个位置都还在,只是内容变了",于是它去 diff props(内容),把三个 li 的文字都改了一遍。如果列表项有重组件/副作用,这会是严重性能与正确性问题。
- 删除末项会误伤:反过来,删除第一个元素,index key 会让 React 复用前两个位置的 fiber,只删除最后一个------但最后一个 fiber 上可能绑着原本属于第三项的组件实例,导致"删了第一项却丢了对第三项的状态"。
源码层面看,错在哪?updateSlot/updateFromMap 的复用完全由 key 决定(:879 newChild.key === key)。index 作 key 时,key 就是 0,1,2,它和"位置"强耦合,失去了"跨位置标识身份"的意义。正确做法是 key={item.id}:mapRemainingChildren 建 Map 时以 '1'/'2'/'3' 为键,头部插入 C 时 updateFromMap 能在 Map 里找到 id=3 的旧 fiber 复用,A/B 凭借 id 原地保留,既正确又高效(参考示例一的理想路径)。
一句话:key 的意义是"跨渲染跨位置稳定地标识一个元素的身份"。index 会随位置变化,等于"没有 key"。
9. Iterator/Map 的 Diff:reconcileChildrenIterator
reconcileChildrenIterator(ReactChildFiber.js:1493)与 reconcileChildrenArray 共用同一套三段式算法 ,区别在于它从 iterator.next() 拉取新元素,而不是 newChildren[newIdx] 下标访问。源码注释:This is the same implementation as reconcileChildrenArray(), but using the iterator instead.
关键区别点:
- 循环条件 :数组版是
newIdx < newChildren.length,iterator 版是!step.done。 - 获取元素 :数组版
newChildren[newIdx],iterator 版step.value。 - 没有"随机访问" ,所以阶段三
updateFromMap对无 key 的节点仍用newIdx(计数下标)当 Map 键------这没问题,因为newIdx在两种实现里语义一致(都是"遍历到第几个新元素")。 - Map/Generator 告警 :
reconcileChildrenIteratable(:1371)会在 dev 下对Maps as children、不可重复遍历的 Generator 给出console.error。
异步迭代(reconcileChildrenAsyncIteratable,:1437)会把 AsyncIterator 包成同步 iterator(每次 next() 内部 unwrapThenable),复用 reconcileChildrenIterator。
所以本章第 6 节对数组 Diff 的全部分析,对 iterator 同样适用。掌握数组版即掌握 iterator 版。
10. key 机制深挖
10.1 key 在算法中的三个作用点
- 阶段一
updateSlot:newChild.key === key决定是否同位置复用(:879)。 - 阶段三
mapRemainingChildren:key 作为 Map 的键(:495);无 key 退化为 index(:484)。 - 阶段三
updateFromMap:existingChildren.get(newChild.key === null ? newIdx : newChild.key)(:1014)查复用。
10.2 无 key 节点的兜底:index
当节点没有显式 key(child.key === null),算法用 child.index(该 fiber 在其父下的位置下标)作为 Map 键。这是"退而求其次":没有稳定身份时,只能假设"位置即身份"。代价就是 8.4 节描述的 index 作 key 灾难。
源码 mapRemainingChildren:
js
if (existingChild.key === null) {
existingChildren.set(existingChild.index, existingChild); // index 兜底
} else if (enableOptimisticKey && existingChild.key === REACT_OPTIMISTIC_KEY) {
existingChildren.set(-existingChild.index - 1, existingChild); // optimistic key 用负偏移
} else {
existingChildren.set(existingChild.key, existingChild);
}
enableOptimisticKey 是 React 19 的实验特性(乐观 key,用于并发插入的平滑过渡),用 -index-1 存储以与正整数 index 区分,避免冲突。若你不是在读实验分支,可忽略。
10.3 为什么 key 要尽量稳定且唯一
- 稳定:同一逻辑实体的 key 在多次渲染间不变,才能跨位置复用(示例一、三)。
- 唯一 :同层级同父下 key 应唯一。
warnOnInvalidKey(:1122)在 dev 下检测重复 key 并console.error:Encountered two children with the same key... Non-unique keys may cause children to be duplicated and/or omitted。
若重复 key,会发生:阶段三 updateFromMap 查到第一个匹配旧 fiber 复用并立刻从 Map 删掉,第二个同 key 新节点再查 Map 就找不到了 → 可能新建或复用错乱,导致"节点被复制或遗漏"的未定义行为。
10.4 key 与 type 的组合身份
注意:仅 key 相同不够 ,reconcileSingleElement 还比 elementType(:1740),updateSlot→updateElement 也比 current.elementType === elementType(:603)。即身份 = (key, type) 二元组。同一个 key 但 type 不同 = 不同身份(见 5.1.2 的 deleteRemainingChildren(child) 分支)。
11. 辅助函数全家福
11.1 createChild(ReactChildFiber.js:710):新节点的创建工厂
createChild 负责把"一个 newChild 值"变成 fiber(用于阶段二 B 的全插入、阶段三新建、以及嵌套数组包 Fragment):
js
// packages/react-reconciler/src/ReactChildFiber.js:710(节选)
function createChild(returnFiber, newChild, lanes): Fiber | null {
if ((typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number' || typeof newChild === 'bigint') {
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: {
const created = createFiberFromElement(newChild, returnFiber.mode, lanes);
coerceRef(created, newChild);
created.return = returnFiber;
return created;
}
case REACT_PORTAL_TYPE: { /* createFiberFromPortal */ }
case REACT_LAZY_TYPE: { /* resolveLazy 后递归 createChild */ }
}
if (isArray(newChild) || getIteratorFn(newChild) || ...) {
// 嵌套数组/iterator:包成 Fragment fiber
const created = createFiberFromFragment(newChild, returnFiber.mode, lanes, null);
created.return = returnFiber;
return created;
}
if (typeof newChild.then === 'function') {
// thenable:解包后递归
return createChild(returnFiber, unwrapThenable(thenable), lanes);
}
if (newChild.$$typeof === REACT_CONTEXT_TYPE) {
return createChild(returnFiber, readContextDuringReconciliation(...), lanes);
}
throwOnInvalidObjectType(returnFiber, newChild);
}
return null; // 函数/符号/null 等返回 null(被外层 continue 跳过)
}
要点:
- 字符串/数字/bigint →
createFiberFromText(注意空串''会被忽略,返回 null)。 - 嵌套数组或 iterator (出现在 children 内部,非顶层)→ 包一层 Fragment fiber (
createFiberFromFragment)。这就是"顶层数组展开、嵌套数组成 Fragment"差异的来源(见 4.1)。 - thenable / context → 递归解包。
- 其它非法类型 →
throwOnInvalidObjectType抛错(如把普通对象当 child)。
11.2 updateElement(ReactChildFiber.js:580):复用或新建单个 Element
阶段一/三里真正执行"复用 or 新建"的落点:
js
// packages/react-reconciler/src/ReactChildFiber.js:580(节选)
function updateElement(returnFiber, current, element, lanes): Fiber {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
return updateFragment(returnFiber, current, element.props.children, lanes, element.key);
}
if (current !== null) {
if (current.elementType === elementType ||
(__DEV__ ? isCompatibleFamilyForHotReloading(current, element) : false) ||
(typeof elementType === 'object' && elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === current.type)) {
// 复用:克隆 current,pendingProps 换成新 props
const existing = useFiber(current, element.props);
coerceRef(existing, element);
existing.return = returnFiber;
return existing;
}
}
// 新建
const created = createFiberFromElement(element, returnFiber.mode, lanes);
coerceRef(created, element);
created.return = returnFiber;
return created;
}
注意热重载(HMR)分支:isCompatibleFamilyForHotReloading 允许"同 family 的不同版本"被复用(dev only),以及 Lazy 解析后 type 匹配也复用。
11.3 useFiber(ReactChildFiber.js:502):克隆复用
js
function useFiber(fiber: Fiber, pendingProps: mixed): Fiber {
const clone = createWorkInProgress(fiber, pendingProps);
clone.index = 0;
clone.sibling = null;
return clone;
}
createWorkInProgress 会把新 fiber 的 alternate 指向旧 fiber,旧 fiber 的 alternate 指回新 fiber,DOM 实例 stateNode 被保留。这就是为什么复用节点"不动 DOM 只更新 props"。
11.4 deleteChild(ReactChildFiber.js:434)与 deleteRemainingChildren(ReactChildFiber.js:448)
js
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
if (!shouldTrackSideEffects) return; // mount 阶段 noop
const deletions = returnFiber.deletions;
if (deletions === null) {
returnFiber.deletions = [childToDelete];
returnFiber.flags |= ChildDeletion;
} else {
deletions.push(childToDelete);
}
}
function deleteRemainingChildren(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
): null {
if (!shouldTrackSideEffects) return null;
let childToDelete = currentFirstChild;
while (childToDelete !== null) {
deleteChild(returnFiber, childToDelete);
childToDelete = childToDelete.sibling;
}
return null;
}
要点:
- 删除不会立刻操作 DOM ,只是把待删 fiber 收集到父的
deletions数组并打ChildDeletion标志。真正卸载 DOM 是 commit 阶段(第 6 章)遍历deletions做的事。 - 这正是第 3 章所述"Flags 是 render 阶段的标记、commit 阶段消费"的体现。
- mount 阶段(
shouldTrackSideEffects=false)删除是 noop,因为 mount 没有旧节点可删。
11.5 warnOnInvalidKey(ReactChildFiber.js:1122)
dev 专用,维护一个 knownKeys Set,遍历新建 fiber 时检测重复 key 与缺失 key 并告警。它不改变算法结果,只是开发期保障。线上构建里整段被摇树移除。
12. 边界场景穷举
12.1 文本节点的特例(shouldSetTextContent)
HostComponent 在 updateHostComponent(ReactFiberBeginWork.js:1982)会判断 shouldSetTextContent(type, nextProps):如果宿主元素只有唯一子节点且是文本(如 <div>hello</div>、{['hello']}),React 不生成独立的 HostText fiber,而是把文本直接作为 DOM 文本节点,由宿主环境在创建实例时设置。
- 这种特例下
nextChildren = null,协调不会再为文本单独建 fiber(避免了一次无谓的 fiber 分配与一次额外的 commit)。 - 当从"直接文本"切成"含元素子节点"时,会打
ContentReset标志,commit 阶段先清空文本再插入子节点。
12.2 Fragment 的协调
- 顶层无 key 无 ref 的
<></>:在reconcileChildFibersImpl入口被展开成 children(:1872-1882),不产生 Fragment fiber。 - 有 key 或 ref 的 Fragment / 嵌套 Fragment :
updateElement/updateFragment生成真正的Fragmentfiber(createFiberFromFragment)。Fragment fiber 的tag === Fragment,其child是真正的子节点,completeWork 阶段 Fragment 只做bubbleProperties不触碰 DOM(见ReactFiberCompleteWork.js:1104)。 - Fragment 复用时同样比对 key 与 tag(
:1719、reconcileSingleElement的 Fragment 分支)。
12.3 null / false / undefined / true / 空串
这些"不渲染任何东西"的值在 reconcileChildFibersImpl 末尾落到:
js
return deleteRemainingChildren(returnFiber, currentFirstChild);
即把旧子节点全部删除(标记 ChildDeletion)。注意空串 '' 在 updateSlot/createChild 里被当作"无内容"返回 null(不生成文本 fiber),所以 <div>{''}</div> 不会创建空文本节点,且会清空旧文本。
12.4 嵌套结构
协调是递归 的:父数组 diff 出的每个 fiber,在 beginWork 阶段会再对自己的 nextChildren 调用 reconcileChildren。所以"数组里套数组"时:
- 内层数组作为外层某个元素的 children,被
createChild包成 Fragment(11.1)。 - 该 Fragment fiber 在 beginWork 时,其 children(内层数组)再次走
reconcileChildrenArray。
因此 diff 算法本身是"一层一层"的,不跨层比对(符合 1.3 假设 1)。
12.5 key 冲突告警
见 10.3 与 11.5。warnOnInvalidKey 在 dev 下对每个新 child 检查 key 唯一性,重复则 console.error。这是警告而非纠正------算法不会自动修复,重复 key 仍会导致未定义行为,需开发者自行修正。
13. 与 props diff 链路的衔接(React 19 新模型)
协调算法决定了"哪些 fiber 复用、哪些新建、谁移动、谁删除",但复用节点的 props 变化如何落实到 DOM?这是 React 19 与旧版本差异最大的地方,必须讲清。
13.1 React 19 的关键变化:render 阶段只 markUpdate
在 completeWork 的 HostComponent 分支(ReactFiberCompleteWork.js:1374):
js
case HostComponent: {
popHostContext(workInProgress);
const type = workInProgress.type;
if (current !== null && workInProgress.stateNode != null) {
updateHostComponent(current, workInProgress, type, newProps, renderLanes); // :1378
} else {
// 挂载分支:createInstance / popHydrationState ...
}
bubbleProperties(workInProgress);
// ...
return null;
}
updateHostComponent(注意这个函数在 ReactFiberCompleteWork.js 里,不是 ReactFiberBeginWork 的同名函数)就是 React 19 里 props diff 的 render 阶段落点:
js
// packages/react-reconciler/src/ReactFiberCompleteWork.js:463
function updateHostComponent(current, workInProgress, type, newProps, renderLanes) {
if (supportsMutation) {
const oldProps = current.memoizedProps;
if (oldProps === newProps) {
return; // 引用相同,直接 bailout,不碰 DOM
}
markUpdate(workInProgress); // ← 关键:只标记,不生成 payload
} else if (supportsPersistence) {
// 持久化渲染器(如 SSR 静态)走 cloneInstance 路径
}
}
核心变化 :React 19 删除了 diffProperties 函数 。旧版 React 16~18 在 render 阶段会调用 diffProperties(oldProps, newProps) 算出 updatePayload 数组(如 ['style', {...}, 'className', 'x']),挂到 workInProgress.updateQueue(或 fiber 的某个字段),commit 阶段 commitUpdate 再消费这个 payload。React 19 直接不预先计算 payload ,只在 oldProps !== newProps 时打一个 Update 标志,把真正的 diff 推迟到 commit 阶段内联完成。
源码注释与代码对照::481 markUpdate(workInProgress) 之后再无 diffProperties 调用。这一点在本系列第 6 章 commit 阶段会看到 commitUpdate 内部重新 updateProperties(domElement, type, oldProps, newProps) 时现场对 oldProps/newProps 做 diff。
13.2 为什么这样改?
把 diff 推迟到 commit 的好处:
- render 阶段更轻:协调只关心"结构"(增删移动),不再关心"属性细节"。render 阶段是可能并发重入、被中断的,减少其工作量能降低中断/恢复成本与内存占用(不再为每个 host fiber 暂存 payload 数组)。
- 避免无效计算:若某个 fiber 最终因优先级/并发被跳过或替换,预计算的 payload 就浪费了。推迟到 commit(确定要落地时)再算,避免浪费。
- diff 逻辑集中 :DOM 属性设置细节(
setProp、setValueForStyles)本就属于 host 配置层(ReactFiberConfigDOM),内联到 commit 阶段更内聚。
代价:commit 阶段要做更多同步工作(对每个被 Update 标记的 host fiber 现场 diff)。但 commit 是同步不可中断的,这部分开销可接受。
13.3 commit 阶段:commitUpdate → updateProperties → setProp
commitUpdate(ReactFiberConfigDOM.js:999):
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:999
export function commitUpdate(
domElement, type, oldProps, newProps, internalInstanceHandle,
): void {
updateProperties(domElement, type, oldProps, newProps); // DOM 属性 diff + 应用
updateFiberProps(domElement, newProps); // 更新事件句柄
}
oldProps/newProps 来自哪?来自 fiber 的 current.memoizedProps 与 workInProgress.memoizedProps(第 6 章 commit 时 workInProgress 已替代 current,旧值从 alternate 取)。
updateProperties(ReactDOMComponent.js:1518)按 tag 分发,对 input/select/textarea 等特殊标签走专门路径,通用路径对每个 prop 调 setProp:
js
// packages/react-dom-bindings/src/client/ReactDOMComponent.js:1518(节选)
export function updateProperties(domElement, tag, lastProps, nextProps): void {
if (__DEV__) validatePropertiesInDevelopment(tag, nextProps);
switch (tag) {
case 'div': case 'span': case 'svg': /* 常见标签快车道,直接 break 进通用逻辑 */ break;
case 'input': { /* updateInput 专门处理 value/checked 等受控逻辑 */ return; }
case 'select': { /* updateSelect */ return; }
// ...
}
// 通用路径:遍历 lastProps,删掉 nextProps 里没了的属性(setProp(..., null, ...))
// 遍历 nextProps,与 lastProps 不同的才 setProp 应用
for (const propKey in nextProps) {
const nextProp = nextProps[propKey];
const lastProp = lastProps[propKey];
if (nextProps.hasOwnProperty(propKey) && (nextProp != null || lastProp != null)) {
setProp(domElement, tag, propKey, nextProp, nextProps, lastProp);
}
}
}
13.4 setProp 与 setValueForStyles:属性落地的细节
setProp(ReactDOMComponent.js:397)是个大 switch,按 key 分流:
children+ 字符串/数字 →setTextContent(直接设 DOM 文本)。className→setValueForKnownAttribute(domElement, 'class', value)(React 用class而非className落 DOM)。style→setValueForStyles(domElement, value, prevValue)(见下)。src/href→ 处理javascript:URL 消毒(sanitizeURL),空串告警。dangerouslySetInnerHTML→ 特殊处理。- 自定义属性、
data-*、aria-*→ 经possibleStandardNames规范化后setValueForAttribute/setValueForProperty。 - 事件(
onClick等)→ 不在setProp的普通属性里处理,而是由updateFiberProps整体替换事件句柄(见 13.5)。
setValueForStyles(CSSPropertyOperations.js:111):
js
export function setValueForStyles(node, styles, prevStyles) {
if (styles != null && typeof styles !== 'object') {
throw new Error('The `style` prop expects a mapping...');
}
if (__DEV__ && styles) Object.freeze(styles); // dev 冻结防止被改
const style = node.style;
if (prevStyles != null) {
// 先清掉 prev 有、next 没有的样式
for (const styleName in prevStyles) {
if (prevStyles.hasOwnProperty(styleName) &&
(styles == null || !styles.hasOwnProperty(styleName))) {
const isCustomProperty = styleName.indexOf('--') === 0;
if (isCustomProperty) style.setProperty(styleName, '');
else if (styleName === 'float') style.cssFloat = '';
else style[styleName] = '';
}
}
// 再应用 next 与 prev 不同的样式
for (const styleName in styles) {
const value = styles[styleName];
if (styles.hasOwnProperty(styleName) && prevStyles[styleName] !== value) {
setValueForStyle(style, styleName, value);
}
}
} else {
for (const styleName in styles) {
if (styles.hasOwnProperty(styleName)) setValueForStyle(style, styleName, value);
}
}
}
这正体现"commit 阶段现场 diff":React 19 不再预存 payload,而是拿着 oldProps 和 newProps 在 commit 时现比 ,用 prevStyles 清旧、用 styles 设新。setProp 的每个分支都是"增量对比 + 增量应用"。
13.5 事件与自定义元素
- 事件 :React 19 的事件系统(见事件章节)把事件挂在根节点委托。commit 时
updateFiberProps(domElement, newProps)(ReactDOMComponentTree.js:295)只更新 DOM 节点上的"当前 props 句柄"(internalPropsMap.set(node, props)或node[internalPropsKey] = props)。事件触发时从节点取最新 props 找对应 handler。所以事件 handler 变化不需要动 DOM 属性,只换句柄引用------这是 React 19 事件处理高效的原因。 - 自定义元素 (
isCustomElement):对自定义元素,属性设置走setPropOnCustomElement(而非标准属性路径),属性名按原样透传、布尔值特殊处理等。源码在setProp末尾分支处理。
13.6 用一张图串起 props diff 全流程
14. 协调期的错误处理:Throw fiber 与 throwException
协调过程中若发生普通 JavaScript 错误 (非 Suspense 的 wakeable),React 不能让它直接冒泡炸掉整个渲染,而是把它捕获并包装成一个特殊的 Throw fiber ,交给上层最近的 Error Boundary(第 3 章的 ErrorBoundary 由 getNearestMountedAncestor + throwException 处理)。
14.1 reconcileChildFibers 的 try/catch(ReactChildFiber.js:2034)
js
// packages/react-reconciler/src/ReactChildFiber.js:2034
function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes): Fiber | null {
const prevDebugInfo = currentDebugInfo;
currentDebugInfo = null;
try {
thenableIndexCounter = 0;
const firstChildFiber = reconcileChildFibersImpl(returnFiber, currentFirstChild, newChild, lanes);
thenableState = null;
return firstChildFiber;
} catch (x) {
if (x === SuspenseException || x === SuspenseActionException ||
(!disableLegacyMode && (returnFiber.mode & ConcurrentMode) === NoMode &&
typeof x === 'object' && x !== null && typeof x.then === 'function')) {
// Suspense 异常需要保持挂起态,不能包成 Throw fiber,直接重抛
throw x;
}
// 普通错误:包装成 Throw fiber,作为"出错的孩子"
const throwFiber = createFiberFromThrow(x, returnFiber.mode, lanes);
throwFiber.return = returnFiber;
if (__DEV__) {
// 用最近父级的 owner/task 作为错误来源
throwFiber._debugOwner = returnFiber._debugOwner;
throwFiber._debugTask = returnFiber._debugTask;
// ...从 debugInfo 找带 stack 的 owner...
}
return throwFiber;
} finally {
currentDebugInfo = prevDebugInfo;
}
}
机制精髓:
reconcileChildFibersImpl内部抛出的任何非 Suspense 异常,都被这里 catch。- 区分两类:
- Suspense 异常 (
SuspenseException/SuspenseActionException,或 legacy 模式下的 thenable)→ 原样重抛 (throw x),因为 Suspense 需要保持"挂起-重放"语义,不能包装。 - 普通错误 → 创建
createFiberFromThrow(x, ...)虚拟 fiber 作为当前父的"孩子",返回它。这个 Throw fiber 在beginWork的 begin 阶段会故意抛错 ,从而触发上层 Error Boundary 的捕获逻辑(第 3 章所述:Throw fiber 的 tag 会使 begin 流程走throwException路径)。
- Suspense 异常 (
- 注释:
Something errored during reconciliation but it's conceptually a child that errored and not the current component itself so we create a virtual child that throws in its begin phase.------ 意思是"出错的是子节点,不是当前组件",所以包装成会抛错的子节点,让当前组件有机会用 Error Boundary 处理。
14.2 throwException(ReactFiberThrow.js:364):区分 wakeable 与普通错误
协调(以及 begin/complete 阶段)抛出的错误最终汇集到 throwException。入口处就分流:
js
// packages/react-reconciler/src/ReactFiberThrow.js:364
function throwException(root, returnFiber, sourceFiber, value, rootRenderLanes): boolean {
sourceFiber.flags |= Incomplete; // 标记该 fiber 未完成
if (value !== null && typeof value === 'object') {
if (typeof value.then === 'function') {
// ===== 情形 A:value 是 wakeable(Promise/thenable) → Suspense 挂起 =====
const wakeable = value;
resetSuspendedComponent(sourceFiber, rootRenderLanes);
const suspenseBoundary = getSuspenseHandler();
if (suspenseBoundary !== null) {
// 标记最近的 Suspense 边界显示 fallback,挂 retry listener(ping)
// ...
return false; // false 表示"是 suspense,不是未捕获错误"
} else {
// 无 Suspense 边界:并发根下允许无限挂起;legacy 根视为错误
// ...
}
}
}
// ===== 情形 B:普通错误(非 wakeable) =====
// 若是 hydration 期间错误,尝试切到 client render 恢复
if (getIsHydrating() && (disableLegacyMode || sourceFiber.mode & ConcurrentMode)) {
// ...寻找 hydrationBoundary,ForceClientRender 后重试...
}
// 否则:作为未捕获错误,向上寻找 Error Boundary(通过 returnFiber 链)
// ...最终由 createCapturedValue 包装,交给 boundary 的 getDerivedStateFromError
}
两种情形的本质区别:
| 情形 | value 形态 | 处理 | 是否"错误" |
|---|---|---|---|
| Suspense 挂起 | typeof value.then === 'function'(wakeable) |
标记最近 Suspense 边界切 fallback,注册 ping 重试 | 否,是正常的异步等待 |
| 普通错误 | 其它(Error 实例、字符串等) | 标记 Incomplete,向上找 Error Boundary 捕获 |
是,需要 boundary 兜底 |
这一分流保证:当你在组件里 throw promise(配合 use 或 Suspense)时,React 走情形 A 优雅降级;当你抛出一个真正的 Error 时,走情形 B 被 Error Boundary 捕获,而不是污染协调逻辑。
15. cloneChildFibers 与 resetChildFibers
这两个函数服务于"复用已有 workInProgress 子树"的特殊场景(如 bailout、错误或 Suspense 重试时复用已构建的子树,避免重复协调)。
js
// packages/react-reconciler/src/ReactChildFiber.js:2120
export function cloneChildFibers(current: Fiber | null, workInProgress: Fiber): void {
if (current !== null && workInProgress.child !== current.child) {
throw new Error('Resuming work not yet implemented.');
}
if (workInProgress.child === null) return;
let currentChild = workInProgress.child;
let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
workInProgress.child = newChild;
newChild.return = workInProgress;
while (currentChild.sibling !== null) {
currentChild = currentChild.sibling;
newChild = newChild.sibling = createWorkInProgress(currentChild, currentChild.pendingProps);
newChild.return = workInProgress;
}
newChild.sibling = null;
}
cloneChildFibers 把 workInProgress 已有的子 fiber 链表整体 createWorkInProgress 克隆一遍(建立新的 alternate 关系),用于"重放"已有的协调结果而不重新跑 diff 算法。注意它只克隆一层子链表,递归克隆由后续 beginWork 完成。
resetChildFibers(:2149)则相反:把子树的 resetWorkInProgress 重置(清空 flags/lanes),用于第二次 pass 前清理。
这两个函数说明:协调算法产出的 fiber 子树是可被"复用/重放"的,这与第 3 章 Fiber 的 alternate 双缓冲模型一脉相承。
16. 与其他模块的衔接(第 3 章 Flags、第 6 章 Commit)
16.1 与第 3 章 Fiber / Flags 的衔接
- Fiber 字段 :协调读
current.child(旧子头)、current.sibling/index/key/elementType/tag;写workInProgress.child/sibling/index/alternate/flags。 - Flags 的产生 :协调打出
Placement、ChildDeletion、Forked、PlacementDEV。这些是第 3 章所述的"副作用标志",在 render 阶段只标记不执行。 deletions数组 :deleteChild把待删 fiber 收集到returnFiber.deletions,配合ChildDeletion标志。bubbleProperties:每个 fiber 在completeWork(ReactFiberCompleteWork.js:793)把子树的flags/subtreeFlags冒泡到父。协调打的 Placement 最终会通过子树冒泡,让 commit 能从 root 遍历到所有需要处理的 fiber。didBailout短路 :bubbleProperties里completedWork.alternate !== null && alternate.child === completedWork.child判定"完全 bailout"------即本次该 fiber 的子树没有产生新 work,连子指针都没变。这与协调的"复用"(useFiber克隆、child 指针变化)相区别。
16.2 与第 6 章 Commit 的衔接
协调只是"规划",commit 才是"执行":
- Placement → commit 阶段
commitPlacement:insertBefore/appendChild把 DOM 节点移动到 workInProgress 指示的位置。注意是"移动节点"而非重建(因为 fiber 复用了 stateNode)。 - ChildDeletion →
commitDeletion:遍历returnFiber.deletions,对每棵待删子树执行卸载(调用componentWillUnmount、解绑 ref、移除 DOM)。 - Update (React 19 的
markUpdate)→commitWork→commitUpdate:如前 13.3 节,现场updateProperties应用属性变更。 prepareBeforeMutation、mutation、layout三个 commit 子阶段依次消费这些 Flags。
简言之:协调产出"计划书"(fiber 树 + Flags + deletions),commit 按图施工。理解协调的 Flags 语义,是读懂第 6 章的前提。
17. 性能分析与复杂度
17.1 时间复杂度
- 阶段一(线性对比) :最多遍历 min(旧链表长度, 新数组长度) 次,每次
updateSlot是 O(1)(仅比 key 与 type),故 O(min(m, n))。 - 阶段三(建 Map + 查复用) :建 Map O(k)(k = 剩余旧节点数),遍历剩余新数组 O(r)(r = 剩余新节点数),每次
updateFromMap是 Map O(1) 查找。故 O(k + r)。 - 总体:最坏情况(完全乱序)约等于 O(m + n),远优于通用树编辑距离 O(n³)。
17.2 空间复杂度
mapRemainingChildren建一张 Map,最多存全部剩余旧节点,空间 O(k)。- 产生的 workInProgress 子链表是新分配的(或
useFiber克隆),与节点数成正比 O(n)。 - 对比 React 15 递归的 O(n) 调用栈,Fiber 用链表 + 循环把"栈"摊平,避免爆栈。
17.3 何时成为瓶颈
- 超长列表无 key / 用 index 作 key:阶段三退化成"按 index 兜底",且发生大量 props diff(见 8.4)。应给稳定唯一 key。
- 频繁头部插入/删除 :虽然算法是 O(n),但每次头部变化会让
lastPlacedIndex判定出大量移动(Placement),commit 阶段 DOM 移动成本高。若列表极长且频繁重排,考虑虚拟列表(只渲染可视区)或React.memo+ 稳定 key 减少子树重渲染。 reconcileChildrenIterator的不可重复迭代器:每次渲染都重新迭代,无法缓存;且 Generator 被 dev 告警(会 mutate)。优先用数组。
17.4 key 对性能的实际影响
结合示例一 vs 示例四:同样"头部插入",用 key=id 时只有 1 个 Placement + 其余原地;用 key=index 时 0 个 Placement 但 3 个 props 全量 diff(且状态错乱)。Placement 的代价是 DOM 移动(commit 阶段 1 次 insertBefore),props diff 代价是 3 次属性对比 + 潜在状态错误。正确 key 让算法"该动的动、该留的留",既正确又高效。
18. 用法与最佳实践(结合源码)
- 始终给动态列表稳定且唯一的 key (如数据库 id),绝不用数组下标。源码
updateFromMap/mapRemainingChildren全靠 key 复用(:1014 / :495)。下标随位置变,等于无 key(10.2)。 - 避免在 render 中创建新数组/对象当 key 来源 :key 必须是字符串/数字,且跨渲染稳定。
enableOptimisticKey的实验乐观 key 用于并发插入,普通开发无需关心。 - 顶层数组无需包
<></>:reconcileChildFibersImpl已对无 key 无 ref 的顶层 Fragment 展开(:1872)。多层无意义 Fragment 反而增加 fiber 节点(虽然后会被优化,但能免则免)。 - 直接用字符串作子节点比
{array}略省 :<div>hello</div>走shouldSetTextContent特例(12.1),不建 HostText fiber;而{['hello']}也会走该特例(数组单文本元素),效果接近,但复杂 children 用数组更清晰。 React.memo/key配合减少重协调 :当父重渲染,子若 props 相等且 key 相同,useFiber复用,子进入 bailout(didReceiveUpdate=false),可跳过子子树协调(第 3 章)。- 不要用
key传递非身份语义:key 不参与渲染、不可在组件内读取。它只服务于协调的复用判定(源码从不把 key 传给组件)。 - 错误边界要包裹可能抛错的子树:协调期错误会被包成 Throw fiber(14.1),只有上层 Error Boundary 能接住;否则冒泡到 root 导致整棵树卸载。
- Suspense 与错误不要混淆 :在子位置
throw promise是 Suspense 挂起(情形 A),throw new Error是错误(情形 B)。源码throwException据此分流(14.2)。
12.6 协调与 StrictMode 双调用
React 在 dev 模式下对函数组件做双调用(double invoke) :render 阶段会把组件函数执行两次,以暴露不纯的副作用。renderWithHooks 内部在 StrictMode 下会第二次调用组件函数,但协调算法本身并不因此翻倍执行两次。原因在于:
- 双调用发生在
updateFunctionComponent→renderWithHooks内部(拿到nextChildren之前)。到reconcileChildren这一步时,nextChildren已经是"最终的那一份" ReactElement 树。 - 协调产出的 fiber 上打的
Placement/ChildDeletion等标志,在一次 workInProgress 渲染中只计算一次。双调用影响的是"组件函数副作用是否干净",不是"diff 算几遍"。
但要注意:PlacementDEV 标志与 StrictMode 重挂载识别有关 。placeChild(:538)和 placeSingleChild(:547)在新建节点时会同时打 Placement | PlacementDEV。dev 下 StrictMode 用 PlacementDEV 判断"这是一次真正的新挂载",从而决定是否要重放子树的 effects(useEffect/useLayoutEffect)。如果某节点只是复用(alternate !== null),则不打 PlacementDEV,StrictMode 不会把它当"新挂载"重跑 effects------这正是源码注释 This is a move. The fiber already existed, so this is not a new mount; don't set PlacementDEV, which would cause StrictMode to re-run the effects in its subtree as if it had remounted. 的用意(:528-530)。
12.7 协调与并发可中断 / 优先级
Fiber 架构的核心红利是渲染可中断 。协调(reconcileChildrenArray 等)是在 beginWork 循环里被调用的,而 beginWork 由工作循环(work loop)驱动,每处理完一个 fiber 都会检查是否有更高优先级的任务需要让路。因此:
- 协调本身是"可暂停的" :工作循环不会一次跑完整棵树的协调,而是处理若干 fiber 后 return,把控制权交还调度器。下次恢复时从
workInProgress树断点继续。 shouldTrackSideEffects与可中断的一致性 :因为 mount 路径(mountChildFibers)不追踪副作用,即使被中断后重来,也不会留下"半截 deletions"需要清理------要么整段重做,要么由resetChildFibers统一重置(见第 15 章)。这正是 mount 阶段刻意不做副作用追踪的深层原因:断点恢复更简单、更安全。- 更新路径的副作用需要可被丢弃 :若一次更新协调到一半被更高优先级打断,旧的 workInProgress 子树会被丢弃(因为 alternate 指向的 current 没变),下次重新协调时
current仍是上一次提交的版本,reconcileChildFibers又能正确地从头比对。Flags 只写在 workInProgress 上,不影响已提交的 current,天然支持"丢弃重来"。
12.8 协调与 Hydration(SSR)的交叠
服务端渲染(SSR)后客户端 hydrate,协调算法会进入"比对脱水 DOM"的模式。几个关键交叠点:
getIsHydrating():协调多处用if (getIsHydrating()) { pushTreeFork(returnFiber, numberOfForks); }(reconcileChildrenArray:1269/1301/1364等)。pushTreeFork记录"这个父节点下有 N 个子节点",服务于useId的确定性 ID 生成------Hydration 时同一棵树的每次渲染 ID 必须稳定,所以需要在协调时把"子节点数量/分支"这一信息固定下来。Forked标志 :mount 路径下placeChild不打 Placement 而是flags |= Forked,正是为了让useId算法知道"这些 fiber 属于一个 children 列表"(数组/iterator)。- Hydration 错误恢复 :
throwException(第 14 章)里若 hydrate 期间抛错,会标记最近 Suspense 边界ForceClientRender,然后从该边界重新走 client render(非 hydrate)路径。此时协调从"比对脱水 DOM"切换为"正常创建 DOM",updateHostComponent(complete 版)会走popHydrationState→ 若非 hydration 则createInstance。 - 文本节点直接对齐 :Hydration 下
<div>hello</div>的文本由 dehydration 时已在 DOM 里,reconcileSingleTextNode复用已有文本 fiber 时useFiber会保留stateNode(已存在的文本 DOM 节点)。
12.9 ref 在 React 19 中的变化对协调的影响
React 19 把 ref 变成了普通 prop(enableRefAsProp)。对协调的影响是:
coerceRef(workInProgress, element)(ReactChildFiber.js:291)在updateElement/reconcileSingleElement/updateFragment等复用或新建 时被调用,把element.props.ref读出来挂到workInProgress.ref。不再走旧版createRef/字符串 ref 的特殊通道。markRef(current, workInProgress)(updateHostComponent:2030、updateFunctionComponent内)在 begin 阶段标记 ref 是否需要更新,具体 ref 的挂载/卸载由 commit 阶段处理(第 6 章)。- 因为 ref 现在是 prop,它和别的 prop 一样参与"props 是否变化"的判断;但 ref 的 attach/detach 是独立副作用,不与
markUpdate(属性更新)混用。
13.7 React 19 相对 React 18 的协调差异对照
为帮助从旧版本迁移/理解的读者,列出协调相关的最关键差异:
| 维度 | React 18 | React 19 |
|---|---|---|
| 属性 diff 时机 | render 阶段 diffProperties 算出 updatePayload 数组,挂到 fiber |
删除 diffProperties ,render 阶段 updateHostComponent 只 markUpdate,commit 阶段 commitUpdate 现场 updateProperties 增量 diff |
ref 处理 |
特殊字段(ref 不在 props 里,单独通道) |
ref 作为普通 prop,coerceRef 从 props.ref 读取 |
| Fragment 带 ref | 不允许(Fragment 无 ref) | enableFragmentRefs 允许 <Fragment ref={...}>,coerceRef/validateFragmentProps 相应处理 |
| 乐观 key | 无 | enableOptimisticKey 实验特性,用 REACT_OPTIMISTIC_KEY 与 -index-1 存储做并发插入平滑过渡 |
| 错误包装 | 同样有 Throw fiber | 逻辑一致,dev 下补充 _debugInfo/_debugOwner 链条更完善 |
uses 解包位置 |
use 在组件内 |
协调期也能遇到 thenable/context 直接在 reconcileChildFibersImpl 内 unwrapThenable/readContextDuringReconciliation 解包 |
为什么属性 diff 推迟到 commit 是更优的设计 :render 阶段在并发下可能被中断、被更高优先级打断重做,若每个 host fiber 都预先算出 payload 数组,这些计算结果很可能被丢弃(浪费 CPU 与内存)。推迟到 commit(确定要落地、不可中断)再算,既减少 render 负担,又让 diff 逻辑(setProp/setValueForStyles)内聚在 host 配置层。代价是 commit 阶段同步工作量增加,但 commit 本就是同步尾段,可接受。
8.5 端到端追踪:从 JSX 到 commit
把前面所有碎片串成一个完整例子,验证"协调 → Flags → commit"的闭环。
给定组件:
jsx
function List({items}) {
return <ul>{items.map(i => <li key={i.id}>{i.text}</li>)}</ul>;
}
首次渲染 items = [{id:1,text:'A'},{id:2,text:'B'}]:
- render :
List函数返回<ul>的 ReactElement,其 children 是一个数组 (两个<li>element)。 - beginWork(ul 的 HostComponent) :
updateHostComponent算出nextChildren = [li#1, li#2],调用reconcileChildren(current=null, ...)→ 因current===null走 mountChildFibers (shouldTrackSideEffects=false)。 - 数组 diff(mount) :
reconcileChildrenArray阶段一:oldFiber=null,立即进入阶段二 B(旧链表空)→ 对每个 newChildcreateChild新建lifiber,串成链表,挂到ul.workInProgress.child。因为!shouldTrackSideEffects,placeChild只打Forked,不打 Placement。 - completeWork :
ul与两个li各自completeWork,HostComponent分支current===null走createInstance真正建 DOM 节点(<ul>、两个<li>),appendAllChildren把 li 挂到 ul 下。 - commit(首挂载) :因 mount,所有新建节点无条件
appendChild到容器;无 deletions、无 Placement 处理;li文本通过setTextContent设置。
接着更新,items 变成 [{id:2,text:'B'},{id:1,text:'A'}](逆序)且 A 文本改为 'A!':
- render :
List返回新数组[li#2, li#1],li#1的 children 变了。 - beginWork(ul) :
current!==null走 reconcileChildFibers (shouldTrackSideEffects=true)。reconcileChildrenArray阶段一:newIdx=0,旧 li#1@0,key '1' ≠ '2' →updateSlot返回 null,中断。阶段三:建 Map{'1':li#1,'2':li#2}。newIdx=0 取 li#2 → 复用(oldIndex=1),1 < lastPlaced(0)?否 → 原地,lastPlaced=1;newIdx=1 取 li#1 → 复用(oldIndex=0 < 1)→ 打 Placement;Map 空。 - li#1 的 props 变化 :
li#1复用时pendingProps变为新 element props(children 从 'A' 到 'A!')。进入li#1.completeWork→updateHostComponent(complete 版):oldProps !== newProps→markUpdate(打Update标志)。注意没有 payload,只是标记。 - bubbleProperties :
li#1的Update、Placement向上冒泡到ul的subtreeFlags。 - commit :
commitPlacement:因li#1打了 Placement,把它对应的 DOM 节点移动到ul下正确位置(末尾),实现逆序。commitWork/commitUpdate:因li#1打了 Update,commitUpdate(dom, 'li', oldProps, newProps)→updateProperties现场 diff 出 children 文本从 'A' 变 'A!' →setProp/setTextContent修改 DOM 文本。- 无 deletions(两个 li 都复用)。
这个追踪完整展示了:协调只负责"结构(谁复用、谁移动、谁删除)+ 打标志",真正的 DOM 创建/移动/属性修改全部推迟到 commit。
10.5 Optimistic Key 与 REACT_OPTIMISTIC_KEY
enableOptimisticKey 是 React 19 的实验特性(乐观 key),用于在并发插入场景下提供平滑过渡:当新列表项暂时没有确定 key(乐观更新)时,React 用一个哨兵 REACT_OPTIMISTIC_KEY 占位,待 key 确定后再替换(existing.key = key,见 reconcileSingleElement:1756、updateElement/updateFragment 内 if (enableOptimisticKey) 分支)。
在协调的 Map 处理上,乐观 key 的旧 fiber 会被存成 -index-1(mapRemainingChildren:493),updateFromMap/updateFromMap 查找时会先用真实 key 查、查不到再用 -newIdx-1 兜底(:1016-1018、:1035-1037),从而保证"乐观 key 不会凭空假设身份,若有真实 key 命中优先复用真实 key"。这是为并发表单/列表插入场景设计的精细优化,普通开发无需主动使用,但理解它有助于读懂协调代码里那些 enableOptimisticKey 分支。
17.5 一个具体的复杂度推演
设旧列表 m=1000 项、新列表 n=1000 项:
- 最佳情况(仅尾部追加 1 项,前 1000 项 key 稳定) :阶段一线性比完 1000 项(O(1000)),第 1001 项进阶段二 B 新建(O(1))。总约 O(1001),且只有 1 个新建 Placement,0 个移动/删除。极致高效。
- 最坏情况(完全逆序) :阶段一第 1 项就 key 不匹配中断,阶段三建 Map O(1000) + 遍历 O(1000),全部 1000 次
updateFromMap为 O(1) 查找,合计 O(2000)。但lastPlacedIndex会判定出约 999 次移动 (除最后一项外全部 Placement),commit 阶段要做 999 次 DOM 移动。算法复杂度仍 O(n),但实际 DOM 操作代价高------这说明"算法复杂度低"不等于"运行时无成本",大规模重排仍应尽量用稳定 key + 虚拟化等手段缓解。 - 用 index 作 key 的隐藏代价 :同样尾部追加 1 项,index key 下阶段一完美匹配前 1000 个"位置",看似 O(1001) 且 0 移动------但每个复用的 li 其
pendingProps内容都变了(因为数据错位),导致 1000 个markUpdate,commit 阶段对 1000 个 li 各做一次属性 diff,且状态错乱。即"省了移动、赔了 props diff 与正确性"。
结论:协调算法的 O(n) 是结构比对的复杂度;真正影响用户体验的是 commit 阶段的 DOM 移动次数与属性 diff 次数,而这二者都取决于 key 的质量。
19. 源码索引表
| 函数 / 符号 | 文件 | 行号 | 作用 |
|---|---|---|---|
reconcileChildren |
packages/react-reconciler/src/ReactFiberBeginWork.js |
339 | 协调入口,current===null 决定 mount/update 分叉 |
beginWork |
ReactFiberBeginWork.js |
4226 | 向下构建 workInProgress 的总入口 |
updateFunctionComponent |
ReactFiberBeginWork.js |
1453 | 函数组件协调触发点 |
updateHostComponent |
ReactFiberBeginWork.js |
1966 | 宿主组件协调触发点(含 shouldSetTextContent 特例) |
createChildReconciler |
packages/react-reconciler/src/ReactChildFiber.js |
431 | 工厂,依据 shouldTrackSideEffects 生成两个 reconciler |
deleteChild |
ReactChildFiber.js |
434 | 收集待删 fiber 到 deletions + ChildDeletion |
deleteRemainingChildren |
ReactChildFiber.js |
448 | 删光剩余旧链表 |
mapRemainingChildren |
ReactChildFiber.js |
467 | 剩余旧节点建 Map(key 或 index 兜底) |
useFiber |
ReactChildFiber.js |
502 | 克隆复用旧 fiber(保留 stateNode) |
placeChild |
ReactChildFiber.js |
511 | 移动判定核心(lastPlacedIndex) |
placeSingleChild |
ReactChildFiber.js |
543 | 单节点仅新建打 Placement |
updateTextNode |
ReactChildFiber.js |
552 | 文本节点复用/新建 |
updateElement |
ReactChildFiber.js |
580 | 单 Element 复用/新建(落点) |
updatePortal |
ReactChildFiber.js |
638 | Portal 复用/新建 |
updateFragment |
ReactChildFiber.js |
672 | Fragment 复用/新建 |
createChild |
ReactChildFiber.js |
710 | 新节点创建工厂(含嵌套数组包 Fragment) |
updateSlot |
ReactChildFiber.js |
842 | 阶段一同位置 key 比对 |
updateFromMap |
ReactChildFiber.js |
985 | 阶段三按 key 复用 |
warnOnInvalidKey |
ReactChildFiber.js |
1122 | dev 重复/缺失 key 告警 |
reconcileChildrenArray |
ReactChildFiber.js |
1177 | 数组 diff 三段式核心 |
reconcileChildrenIteratable |
ReactChildFiber.js |
1371 | iterator 入口(Map/Generator 告警) |
reconcileChildrenAsyncIteratable |
ReactChildFiber.js |
1437 | 异步迭代入口 |
reconcileChildrenIterator |
ReactChildFiber.js |
1493 | iterator 版三段式(与数组同算法) |
reconcileSingleTextNode |
ReactChildFiber.js |
1673 | 单文本 diff |
reconcileSingleElement |
ReactChildFiber.js |
1703 | 单 Element diff |
reconcileSinglePortal |
ReactChildFiber.js |
1808 | 单 Portal diff |
reconcileChildFibersImpl |
ReactChildFiber.js |
1854 | 类型分发出入口 |
reconcileChildFibers |
ReactChildFiber.js |
2034 | 壳(try/catch 包 Throw fiber) |
mountChildFibers |
ReactChildFiber.js |
2112 | shouldTrackSideEffects=false 实例 |
reconcileChildFibers(导出) |
ReactChildFiber.js |
2110 | shouldTrackSideEffects=true 实例 |
cloneChildFibers |
ReactChildFiber.js |
2120 | 复用已有子树 |
resetChildFibers |
ReactChildFiber.js |
2149 | 重置子树用于第二次 pass |
completeWork |
packages/react-reconciler/src/ReactFiberCompleteWork.js |
1082 | 向上归并副作用 |
HostComponent 分支 |
ReactFiberCompleteWork.js |
1374 | 宿主组件 complete 落点 |
updateHostComponent(complete 版) |
ReactFiberCompleteWork.js |
463 | React 19 只 markUpdate,不生成 payload |
bubbleProperties |
ReactFiberCompleteWork.js |
793 | flags/subtreeFlags 冒泡 |
updateProperties |
packages/react-dom-bindings/src/client/ReactDOMComponent.js |
1518 | commit 阶段 DOM 属性 diff |
setProp |
ReactDOMComponent.js |
397 | 单属性落地(className/style/src...) |
setValueForStyles |
packages/react-dom-bindings/src/client/CSSPropertyOperations.js |
111 | style 增量 diff |
updateFiberProps |
packages/react-dom-bindings/src/client/ReactDOMComponentTree.js |
295 | 更新事件句柄引用 |
commitUpdate |
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js |
999 | commit 阶段 host 属性更新入口 |
throwException |
packages/react-reconciler/src/ReactFiberThrow.js |
364 | 区分 wakeable(Suspense) 与普通错误 |
20. 自测 FAQ
Q1:为什么说协调算法是 O(n) 而不是 O(n³)? A:因为两条启发式假设------(1)不同类型节点子树直接重建不比对;(2)同层节点用 key 标识身份,使复用查找为 O(1) Map 操作。整体对旧/新列表各遍历约一遍,O(m+n)。通用树编辑距离是 O(n³),React 为 UI 场景牺牲了数学最优换取实用高效。
Q2:current === null 时为什么用 mountChildFibers? A:mountChildFibers 由 createChildReconciler(false) 生成,shouldTrackSideEffects=false。首次挂载所有节点都是新增,无需打 Placement、无需收集 deletions(mount 时 deleteChild/deleteRemainingChildren/placeChild 都是 noop 或仅打 Forked)。这样省去无意义的副作用标记。
Q3:单节点 diff 里,key 命中但 type 不匹配会怎样? A:走 deleteRemainingChildren(returnFiber, child)(传 child 而非 child.sibling),即从当前这个 type 不对的旧节点开始整条删掉,然后 break,最后新建。因为假设"同 key 同 type 才是同一身份",type 变了就是全新组件。
Q4:placeChild 的 lastPlacedIndex 到底怎么判定移动? A:对复用节点(alternate!==null),若 oldIndex < lastPlacedIndex 打 Placement(移动),否则原地保留且 lastPlacedIndex = oldIndex。lastPlacedIndex 始终等于"已确认不动序列中最大的旧 index"。从前往后扫,旧 index 不递减就不必移动。
Q5:为什么 index 作 key 是灾难? A:key 的意义是"跨位置稳定标识身份"。index 随位置变化,等于没 key。updateSlot/updateFromMap 完全靠 key 决定复用,index key 让"位置 0 的 fiber"永远复用"新数组位置 0 的元素",导致组件状态(state/ref/受控输入)错绑到错误的数据上(见 8.4)。
Q6:数组 diff 的"快速路径"什么情况下中断? A:updateSlot 返回 null 时中断,即"旧 fiber 的 key 和新元素的 key 不同"(文本/无 key 节点遇到有 key 旧节点也算)。中断后进入阶段二/三兜底,不会出错,只是多走了慢路径(建 Map)。
Q7:阶段三 Map 里残留的旧节点怎么处理? A:existingChildren.forEach(child => deleteChild(returnFiber, child))。残留即"新数组里已不存在"的旧节点,全部标记删除(ChildDeletion),commit 阶段卸载。
Q8:为什么 React 19 在 render 阶段只 markUpdate 而不预先算 updatePayload? A:React 19 删除了 diffProperties,把属性 diff 推迟到 commit 阶段由 commitUpdate→updateProperties→setProp 现场完成。好处是 render 阶段更轻(可中断/重入、省内存),避免为被跳过的 fiber 做无用 diff。代价是 commit 阶段同步工作略增,但 commit 不可中断可接受。
Q9:协调期抛错(比如子组件 render 抛异常)会怎样? A:reconcileChildFibers 的 try/catch 捕获非 Suspense 错误,包成 createFiberFromThrow(x) 的 Throw fiber 作为当前父的孩子返回;该 Throw fiber 在 begin 阶段会故意抛错,触发上层 Error Boundary。Suspense 异常(thenable)则原样重抛,走挂起逻辑(throwException 区分两者,见 14.2)。
Q10:reconcileChildrenIterator 和 reconcileChildrenArray 算法一样吗? A:核心三段式完全一致,只是新元素来源从"数组下标访问"变成"iterator.next()"。所有对数组版的分析(移动判定、建 Map、删除残留)对 iterator 版同样成立。注意 Map/不可重复 Generator 在 dev 下有告警。
Q11:顶层数组和嵌套数组处理有何不同? A:顶层数组在 reconcileChildFibersImpl 直接当 children 集合走 reconcileChildrenArray;嵌套数组(出现在某元素的 children 里)由 createChild 包成 Fragment fiber(:783),再由该 Fragment 在 beginWork 时对其内部再协调。无 key 无 ref 的顶层 <></> 会被展开。
Q12:cloneChildFibers 在什么场景用? A:当 workInProgress 子树已构建、需要"重放"已有协调结果而不重跑 diff 时(如 bailout、Suspense 重试),把子链表整体 createWorkInProgress 克隆建立新 alternate 关系。它只克隆一层,深层递归由后续 beginWork 完成。
21. 本章要点小结
- 入口分叉 :
reconcileChildren用current === null选择mountChildFibers(不追踪副作用)或reconcileChildFibers(追踪),决定要不要打 Placement / 收集 deletions。 - 分发中枢 :
reconcileChildFibersImpl按 newChild 类型分发到单节点 / 数组 / iterator / null 等路径;顶层无 key 无 ref 的 Fragment 被展开。 - 单节点 diff :
reconcileSingleElement沿旧兄弟链表遍历,key 命中且 type 匹配才useFiber复用,否则逐个删除;placeSingleChild仅新建打 Placement。 - 数组 diff 三段式 (全章核心):① 线性同位置对比(
updateSlot要求 key 相同);② 分支(新用完删旧 / 旧用完插新);③ 剩余旧节点建 Map(mapRemainingChildren),updateFromMap按 key 复用,Map 残留删除。 - 移动判定 :
lastPlacedIndex记录"不动序列最大旧 index",复用节点旧 index < 它则打 Placement,仅原地保留时前进。 - key 的意义:跨位置稳定标识身份;index 作 key 等于无 key,造成状态错乱;重复 key 触发 dev 告警且行为未定义。
- React 19 props diff 新模型 :
completeWork的updateHostComponent只markUpdate,真正的diffProperties已删除,推迟到 commit 阶段commitUpdate→updateProperties→setProp/setValueForStyles现场增量应用;事件经updateFiberProps整体替换句柄。 - 错误处理 :
reconcileChildFibers的 try/catch 把普通错误包成 Throw fiber 交给 Error Boundary;throwException区分 wakeable(Suspense 挂起)与普通错误。 - 衔接 :协调只产生 fiber 树 + Flags(
Placement/ChildDeletion/Update)+deletions,由bubbleProperties冒泡,completeWork归并,最终第 6 章 commit 按 Flags 施工。
至此,你已具备徒手实现
reconcileChildrenArray的能力,也看清了它与第 3 章 Fiber/Flags、第 6 章 Commit 的完整闭环。下一章我们将深入 Hook 与渲染调度,看nextChildren是如何从组件函数里被"算"出来的。