React 19.3.1 源码深度剖析-第 4 章 Reconciliation 协调算法(Diff 算法全解)

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 仓库的真实源码中核对,引用格式为 文件:行号。


目录

  1. 动机与背景:协调算法为什么存在
  2. 整体心智模型
  3. 协调的入口:reconcileChildren 与 mount/update 分叉
  4. 分发中枢:reconcileChildFibersImpl
  5. 单节点 Diff:reconcileSingleElement / reconcileSingleTextNode / reconcileSinglePortal
  6. 数组 Diff 核心:reconcileChildrenArray 三段式算法(全章重点)
  7. 用 mermaid 还原数组 Diff 的完整数据流
  8. 四个前后对比示例:插入头部 / 删除中间 / 重排 / key 错误
  9. Iterator/Map 的 Diff:reconcileChildrenIterator
  10. key 机制深挖:为什么 key 重要、index 兜底与 index 作 key 的灾难
  11. 辅助函数全家福:createChild/updateElement/updateSlot/updateFromMap/useFiber/deleteChild
  12. 边界场景穷举:文本 / Fragment / null·false / 嵌套 / key 冲突告警
  13. 与 props diff 链路的衔接(React 19 新模型)
  14. 协调期的错误处理:Throw fiber 与 throwException
  15. cloneChildFibers 与 resetChildFibers
  16. 与其他模块的衔接(第 3 章 Flags、第 6 章 Commit)
  17. 性能分析与复杂度
  18. 用法与最佳实践(结合源码)
  19. 源码索引表
  20. 自测 FAQ
  21. 本章要点小结

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。它有致命缺陷:

  1. 不可中断:递归一旦开始就必须走到黑,长达几十毫秒的递归会阻塞主线程,掉帧、卡顿。
  2. 无法区分优先级:所有更新一视同仁,无法为高优先级输入(如点击、输入)让路。
  3. 递归栈即"工作进度":一旦暂停,栈帧丢失,无法恢复。

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):

  1. 不同类型(type)的节点,其子树结构完全不同,直接销毁重建 (reconcileSingleElement 中 child.elementType !== elementType 即走 deleteRemainingChildren + 新建)。这避免了跨 type 的子树逐个比对。
  2. 同一层级的一组子节点,用 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):

flowchart TD A[父 fiber 进入 beginWork] --> B[reconcileChildren] B --> C{current === null?} C -->|是 首次挂载| D[mountChildFibers<br/>shouldTrackSideEffects=false] C -->|否 更新| E[reconcileChildFibers<br/>shouldTrackSideEffects=true] D --> F[reconcileChildFibersImpl 分发] E --> F F --> G{newChild 类型} G -->|单 Element| H[reconcileSingleElement] G -->|文本/数字| I[reconcileSingleTextNode] G -->|数组| J[reconcileChildrenArray] G -->|Iterator| K[reconcileChildrenIterator] G -->|null/false| L[deleteRemainingChildren] H --> M[打 Placement / 复用] I --> M J --> M K --> M L --> M M --> N[返回新的 child 头节点<br/>挂到 workInProgress.child]

记住三个角色:

  • 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 分发逻辑的分支树

flowchart LR A[newChild] --> B{顶层无key无ref Fragment?} B -->|是| A2[展开 children 继续] B -->|否| C{typeof} C -->|object & 有 $$typeof| D{$$typeof} D -->|REACT_ELEMENT_TYPE| E[reconcileSingleElement] D -->|REACT_PORTAL_TYPE| F[reconcileSinglePortal] D -->|REACT_LAZY_TYPE| G[resolveLazy 递归] C -->|数组 isArray| H[reconcileChildrenArray] C -->|iterator| I[reconcileChildrenIteratable] C -->|thenable| J[unwrapThenable 递归] C -->|context| K[readContext 递归] C -->|其它 object| L[throwOnInvalidObjectType] C -->|string/number/bigint| M[reconcileSingleTextNode] C -->|null/false/undefined/空串| N[deleteRemainingChildren]

几个关键点:

  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)。
  2. Lazy / thenable / context 在这里被递归"解包"成真实节点。注意递归只在 Usables/Lazy 上发生,不会发生嵌套数组递归 ------reconcileChildrenArray 内部遇到数组元素时,交给 createChild 包一层 Fragment。
  3. REACT_ELEMENT_TYPE 走 reconcileSingleElement,且外面套一层 placeSingleChild(只有新建才打 Placement)。
  4. 最终兜底 :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 的整体流程画成图:

flowchart TD S[reconcileSingleElement] --> W{while child!=null} W --> K{child.key === element.key?} K -->|否| D1[deleteChild child] --> W K -->|是| T{child.elementType === elementType?} T -->|否| D2[deleteRemainingChildren child + break] --> NEW[新建 fiber] T -->|是| R[useFiber 复用 + deleteRemainingChildren sibling] --> RET[return] W -->|遍历结束| NEW NEW --> PL[placeSingleChild: 仅新建打 Placement]

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) 启发式:

  1. 第一阶段(线性同位置对比) :新数组从头、旧链表从头,按 index 一个个比对。只要 key 能对上(或文本节点)就复用;一旦对不上就立刻退出快速路径。
  2. 第二阶段(分支) :
    • 若新数组先遍历完 → 旧链表剩余的全部删除(deleteRemainingChildren)。
    • 若旧链表先遍历完(oldFiber 变 null) → 新数组剩下的全部新建插入。
  3. 第三阶段(剩余建 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 章的三段式用一个流程图串起来:

flowchart TD START[开始 reconcileChildrenArray] --> INIT[初始化 oldFiber=currentFirstChild, lastPlacedIndex=0, newIdx=0] INIT --> LOOP1{阶段一: oldFiber!=null 且 newIdx

这条链路里,任何"快速路径中断"都只是跳出阶段一,后续统一由阶段二/三兜底,保证算法在任何输入下都能终止且结果正确。


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 ≠ 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'。
  • 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。
  • 阶段二 A:newIdx=3==len → deleteRemainingChildren(null),无删除。

表面看"没删没建,很高效" ,但这是假象式的灾难:

  1. 状态错乱:如果 li 内部有受控输入、本地 state、或 ref,这些状态是绑定在 fiber/DOM 实例上的。用 index 当 key,A 的状态跟着"位置 0 的 fiber"走,但实际数据变成了 C。于是用户在 A 的输入框打的字,会错误地出现在 C 上。这是经典 bug。
  2. 无法正确识别增删:头部插入时,理想结果是"新增一个 C、A/B 位置后移"。但 index key 让 React 误以为"三个位置都还在,只是内容变了",于是它去 diff props(内容),把三个 li 的文字都改了一遍。如果列表项有重组件/副作用,这会是严重性能与正确性问题。
  3. 删除末项会误伤:反过来,删除第一个元素,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.

关键区别点:

  1. 循环条件 :数组版是 newIdx < newChildren.length,iterator 版是 !step.done。
  2. 获取元素 :数组版 newChildren[newIdx],iterator 版 step.value。
  3. 没有"随机访问" ,所以阶段三 updateFromMap 对无 key 的节点仍用 newIdx(计数下标)当 Map 键------这没问题,因为 newIdx 在两种实现里语义一致(都是"遍历到第几个新元素")。
  4. 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 在算法中的三个作用点

  1. 阶段一 updateSlot :newChild.key === key 决定是否同位置复用(:879)。
  2. 阶段三 mapRemainingChildren :key 作为 Map 的键(:495);无 key 退化为 index(:484)。
  3. 阶段三 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 生成真正的 Fragment fiber(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 的好处:

  1. render 阶段更轻:协调只关心"结构"(增删移动),不再关心"属性细节"。render 阶段是可能并发重入、被中断的,减少其工作量能降低中断/恢复成本与内存占用(不再为每个 host fiber 暂存 payload 数组)。
  2. 避免无效计算:若某个 fiber 最终因优先级/并发被跳过或替换,预计算的 payload 就浪费了。推迟到 commit(确定要落地时)再算,避免浪费。
  3. 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 全流程

sequenceDiagram participant A as reconcileChildrenArray participant B as completeWork(HostComponent) participant C as updateHostComponent(completeWork版) participant D as commit(第6章) participant E as commitUpdate participant F as updateProperties/setProp A->>B: 复用 fiber,pendingProps=新 props B->>C: current!=null 且 stateNode 存在 C->>C: oldProps===newProps? 引用相等则 return C->>C: 否则 markUpdate(workInProgress) 只打 Update 标志 C->>B: 返回 B->>B: bubbleProperties 向上冒泡 flags D->>E: 遇到 Update 标志的 host fiber E->>F: commitUpdate(dom, type, oldProps, newProps) F->>F: updateProperties 现场 diff old/new F->>F: setProp / setValueForStyles / 事件句柄替换 F->>F: DOM 真正被修改

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;
  }
}

机制精髓:

  1. reconcileChildFibersImpl 内部抛出的任何非 Suspense 异常,都被这里 catch。
  2. 区分两类:
    • Suspense 异常 (SuspenseException/SuspenseActionException,或 legacy 模式下的 thenable)→ 原样重抛 (throw x),因为 Suspense 需要保持"挂起-重放"语义,不能包装。
    • 普通错误 → 创建 createFiberFromThrow(x, ...) 虚拟 fiber 作为当前父的"孩子",返回它。这个 Throw fiber 在 beginWork 的 begin 阶段会故意抛错 ,从而触发上层 Error Boundary 的捕获逻辑(第 3 章所述:Throw fiber 的 tag 会使 begin 流程走 throwException 路径)。
  3. 注释: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 才是"执行":

  1. Placement → commit 阶段 commitPlacement:insertBefore/appendChild 把 DOM 节点移动到 workInProgress 指示的位置。注意是"移动节点"而非重建(因为 fiber 复用了 stateNode)。
  2. ChildDeletion → commitDeletion:遍历 returnFiber.deletions,对每棵待删子树执行卸载(调用 componentWillUnmount、解绑 ref、移除 DOM)。
  3. Update (React 19 的 markUpdate)→ commitWork→commitUpdate:如前 13.3 节,现场 updateProperties 应用属性变更。
  4. 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 何时成为瓶颈

  1. 超长列表无 key / 用 index 作 key:阶段三退化成"按 index 兜底",且发生大量 props diff(见 8.4)。应给稳定唯一 key。
  2. 频繁头部插入/删除 :虽然算法是 O(n),但每次头部变化会让 lastPlacedIndex 判定出大量移动(Placement),commit 阶段 DOM 移动成本高。若列表极长且频繁重排,考虑虚拟列表(只渲染可视区)或 React.memo + 稳定 key 减少子树重渲染。
  3. 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. 用法与最佳实践(结合源码)

  1. 始终给动态列表稳定且唯一的 key (如数据库 id),绝不用数组下标。源码 updateFromMap/mapRemainingChildren 全靠 key 复用(:1014 / :495)。下标随位置变,等于无 key(10.2)。
  2. 避免在 render 中创建新数组/对象当 key 来源 :key 必须是字符串/数字,且跨渲染稳定。enableOptimisticKey 的实验乐观 key 用于并发插入,普通开发无需关心。
  3. 顶层数组无需包 <></> :reconcileChildFibersImpl 已对无 key 无 ref 的顶层 Fragment 展开(:1872)。多层无意义 Fragment 反而增加 fiber 节点(虽然后会被优化,但能免则免)。
  4. 直接用字符串作子节点比 {array} 略省 :<div>hello</div> 走 shouldSetTextContent 特例(12.1),不建 HostText fiber;而 {['hello']} 也会走该特例(数组单文本元素),效果接近,但复杂 children 用数组更清晰。
  5. React.memo / key 配合减少重协调 :当父重渲染,子若 props 相等且 key 相同,useFiber 复用,子进入 bailout(didReceiveUpdate=false),可跳过子子树协调(第 3 章)。
  6. 不要用 key 传递非身份语义:key 不参与渲染、不可在组件内读取。它只服务于协调的复用判定(源码从不把 key 传给组件)。
  7. 错误边界要包裹可能抛错的子树:协调期错误会被包成 Throw fiber(14.1),只有上层 Error Boundary 能接住;否则冒泡到 root 导致整棵树卸载。
  8. 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 都会检查是否有更高优先级的任务需要让路。因此:

  1. 协调本身是"可暂停的" :工作循环不会一次跑完整棵树的协调,而是处理若干 fiber 后 return,把控制权交还调度器。下次恢复时从 workInProgress 树断点继续。
  2. shouldTrackSideEffects 与可中断的一致性 :因为 mount 路径(mountChildFibers)不追踪副作用,即使被中断后重来,也不会留下"半截 deletions"需要清理------要么整段重做,要么由 resetChildFibers 统一重置(见第 15 章)。这正是 mount 阶段刻意不做副作用追踪的深层原因:断点恢复更简单、更安全。
  3. 更新路径的副作用需要可被丢弃 :若一次更新协调到一半被更高优先级打断,旧的 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'}]:

  1. render :List 函数返回 <ul> 的 ReactElement,其 children 是一个数组 (两个 <li> element)。
  2. beginWork(ul 的 HostComponent) :updateHostComponent 算出 nextChildren = [li#1, li#2],调用 reconcileChildren(current=null, ...) → 因 current===null 走 mountChildFibers (shouldTrackSideEffects=false)。
  3. 数组 diff(mount) :reconcileChildrenArray 阶段一:oldFiber=null,立即进入阶段二 B(旧链表空)→ 对每个 newChild createChild 新建 li fiber,串成链表,挂到 ul.workInProgress.child。因为 !shouldTrackSideEffects,placeChild 只打 Forked,不打 Placement。
  4. completeWork :ul 与两个 li 各自 completeWork,HostComponent 分支 current===null 走 createInstance 真正建 DOM 节点(<ul>、两个 <li>),appendAllChildren 把 li 挂到 ul 下。
  5. commit(首挂载) :因 mount,所有新建节点无条件 appendChild 到容器;无 deletions、无 Placement 处理;li 文本通过 setTextContent 设置。

接着更新,items 变成 [{id:2,text:'B'},{id:1,text:'A'}](逆序)且 A 文本改为 'A!':

  1. render :List 返回新数组 [li#2, li#1],li#1 的 children 变了。
  2. 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 空。
  3. li#1 的 props 变化 :li#1 复用时 pendingProps 变为新 element props(children 从 'A' 到 'A!')。进入 li#1.completeWork → updateHostComponent(complete 版):oldProps !== newProps → markUpdate (打 Update 标志)。注意没有 payload,只是标记。
  4. bubbleProperties :li#1 的 Update、Placement 向上冒泡到 ul 的 subtreeFlags。
  5. 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. 本章要点小结

  1. 入口分叉 :reconcileChildren 用 current === null 选择 mountChildFibers(不追踪副作用)或 reconcileChildFibers(追踪),决定要不要打 Placement / 收集 deletions。
  2. 分发中枢 :reconcileChildFibersImpl 按 newChild 类型分发到单节点 / 数组 / iterator / null 等路径;顶层无 key 无 ref 的 Fragment 被展开。
  3. 单节点 diff :reconcileSingleElement 沿旧兄弟链表遍历,key 命中且 type 匹配才 useFiber 复用,否则逐个删除;placeSingleChild 仅新建打 Placement。
  4. 数组 diff 三段式 (全章核心):① 线性同位置对比(updateSlot 要求 key 相同);② 分支(新用完删旧 / 旧用完插新);③ 剩余旧节点建 Map(mapRemainingChildren),updateFromMap 按 key 复用,Map 残留删除。
  5. 移动判定 :lastPlacedIndex 记录"不动序列最大旧 index",复用节点旧 index < 它则打 Placement,仅原地保留时前进。
  6. key 的意义:跨位置稳定标识身份;index 作 key 等于无 key,造成状态错乱;重复 key 触发 dev 告警且行为未定义。
  7. React 19 props diff 新模型 :completeWork 的 updateHostComponent 只 markUpdate,真正的 diffProperties 已删除,推迟到 commit 阶段 commitUpdate→updateProperties→setProp/setValueForStyles 现场增量应用;事件经 updateFiberProps 整体替换句柄。
  8. 错误处理 :reconcileChildFibers 的 try/catch 把普通错误包成 Throw fiber 交给 Error Boundary;throwException 区分 wakeable(Suspense 挂起)与普通错误。
  9. 衔接 :协调只产生 fiber 树 + Flags(Placement/ChildDeletion/Update)+ deletions,由 bubbleProperties 冒泡,completeWork 归并,最终第 6 章 commit 按 Flags 施工。

至此,你已具备徒手实现 reconcileChildrenArray 的能力,也看清了它与第 3 章 Fiber/Flags、第 6 章 Commit 的完整闭环。下一章我们将深入 Hook 与渲染调度,看 nextChildren 是如何从组件函数里被"算"出来的。

相关推荐
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 1 章 总览与架构哲学
前端·react.js·开源
小小龙学IT1 小时前
Nginx 开源 Web 服务器与反向代理深度解
前端·nginx·开源
miofly1 小时前
腾讯开源 Hy Memory 2.0 记忆系统,3B 激活参数超越 claude-sonnet-5-5
开源·github
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 2 章 响应式内幕(一):`effect` 与依赖追踪机制
前端·vue.js·开源
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 06 章 编译器内幕(二):`transform` 管线与结构指令
前端·vue.js·开源
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 09 章 编译器内幕(五):compiler-sfc的SFC解析与<script setup> 编译
前端·vue.js·开源
码银2 小时前
【web】基于HTML/CSS,JavaScript + Three.js做3D流程图
前端·css·html
鶴哥只手遮天2 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 17 章 运行时内幕(五):`runtime-dom`、自定义元素与内置组件
前端·vue.js·开源
鶴哥只手遮天2 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 13 章 运行时组件系统:实例、props、emit、slots 与生命周期
前端·vue.js·开源