React 源码探秘(三):render 阶段,Reconciler 如何协调更新

React 源码探秘(三):render 阶段,Reconciler 如何协调更新

系列前两篇我们追踪了 JSX 如何变成 ReactElement、ReactElement 如何变成 Fiber 树。但那只回答了"首次渲染"。当 state 变化触发更新时,React 内部到底发生了什么?这就是本篇的主角——render 阶段,也就是 Reconciler(协调器)的工作。

一次更新,两段旅程

React 17/18 中,一次更新被明确切成两个阶段:

  • render 阶段:可中断。负责计算"新的 UI 应该长什么样",产出带标记的新 Fiber 树。对应源码里的 performUnitOfWorkbeginWorkcompleteWork
  • commit 阶段:不可中断。负责把计算结果真正应用到 DOM。对应 commitRoot 及一系列 commit 函数。

本篇只讲 render 阶段。它接收一颗 current 树和一份新的 ReactElement 描述,输出一颗标记好的 workInProgress 树。

双缓冲:current 与 workInProgress

React 用"双缓冲"避免每次更新都销毁重建整棵树。内存中同时存在两棵 Fiber 树:

  • current:当前已渲染、与真实 DOM 对应的树。
  • workInProgress:本次更新正在构建的新树。

两棵树的对应节点通过 fiber.alternate 互相引用。render 阶段从根节点出发,沿着 alternate 找旧节点——能复用就复用(保留 DOM 实例、state),不能复用才新建。构建完成后,commit 阶段把 fiberRoot.current 指向 workInProgress 完成切换,旧树则成为下一次更新的复用池。双缓冲也是"渲染可中断"的前提:就算 render 被高优先级任务打断重来,current 树始终是完整的,用户看到的 UI 不会闪。

beginWork:自顶向下地"提出问题"

render 阶段的主循环在 performUnitOfWork 里:对每个 fiber 先执行 beginWork,再执行 completeWork,直到整棵树走完。

beginWork(ReactFiberBeginWork.js)的职责是:根据 fiber 的 tag(FunctionComponent、ClassComponent、HostComponent……)分派到对应的 update 函数,最终收敛到 reconcileChildren——决定这个 fiber 的子节点如何生成。

JAVASCRIPT
// ReactFiberBeginWork.js(React 17/18,极度简化) function beginWork(current, workInProgress, renderLanes) { if (current !== null) { // 更新阶段:props/state 没变且优先级不够时走 bailout 快速通道 // 直接 clone 子节点,跳过整个 diff } switch (workInProgress.tag) { case FunctionComponent: return updateFunctionComponent(current, workInProgress, ...); case ClassComponent: return updateClassComponent(current, workInProgress, ...); case HostComponent: return updateHostComponent(current, workInProgress, ...); // ... } }

注意开头那个判断:current === null 表示首次挂载(mount),没有旧节点可复用;current !== null 则是更新(update)。这个区分贯穿整个协调过程。

reconcileChildren 内部也分两条路:mount 时调用 mountChildFibers(不收集删除标记),update 时调用 reconcileChildFibers(需要处理删除)。两者逻辑几乎一样,只是传参不同——这也是首次渲染比更新"轻"的原因之一。

reconcileChildren:diff 算法

diff 是协调器的灵魂,实现在 ReactChildFiber.js。React 的 diff 建立在三个前提假设上(这也是它的设计边界):

  1. 不同类型的元素产生不同的树(div 变 span,直接重建子树)。
  2. 开发者通过 key 提示哪些子元素是稳定的。
  3. 同一层级的子元素只和同层级比较,不会跨层级移动。

单节点 diff

只有一个子节点时走 reconcileSingleElement,规则很朴素:新旧节点的 key 和 type 都相同,就复用旧 fiber(useFiber 拷贝一份作为 workInProgress,保留 state 和 DOM 实例);否则把旧节点标记 Deletion,新建一个。

JAVASCRIPT
function reconcileSingleElement(returnFiber, currentFirstChild, element) { const key = element.key; let child = currentFirstChild; while (child !== null) { if (child.key === key) { if (child.elementType === element.type) { // key、type 都相同:复用,删除剩余兄弟 deleteRemainingChildren(returnFiber, child.sibling); return useFiber(child, element.props); } // key 相同但 type 不同:删掉,继续看下一个兄弟 deleteChild(returnFiber, child); } child = child.sibling; } // 没有可复用的,只能新建 return createFiberFromElement(element, ...); }

注意:key 相同但 type 不同,依然要删掉重建——key 只负责"找对位置",type 才决定"能不能用"。

多节点 diff

数组子节点走 reconcileChildrenArray,核心是两轮遍历

第一轮:从左到右同步遍历新旧两个列表,key 相同就复用(updateSlot),遇到第一个 key 不同的立刻跳出。这覆盖了最常见的场景——列表前面一大段没变,只在尾部增删。

第二轮:把第一轮剩余下来的旧节点按 key 塞进一个 Map(mapRemainingChildren),再遍历剩余的新节点,逐个从 Map 里按 key 查找复用;遍历结束后 Map 里还剩下的旧节点,全部标记删除。整个过程 O(n),不搞嵌套循环。

JAVASCRIPT
// 第二轮:剩余旧节点按 key 建索引,新节点逐个查 const existingChildren = mapRemainingChildren(returnFiber, oldFiber); for (; newIdx < newChildren.length; newIdx++) { const newFiber = updateFromMap( existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes ); // 位置不对时记录 Placement,等 commit 阶段移动 } // existingChildren 里剩下来的,全部 deleteChild

key 到底有什么用

没有 key 时,多节点 diff 只能按 index 对比:在列表头部插入一个元素,会导致后面所有节点"错位",全被当成新内容处理——组件 state 错乱、DOM 无谓重建,都是这个原因。有了稳定 key,React 才能精确识别"谁是谁",把 DOM 复用做到极致。所以列表渲染时 key 一定要稳定、唯一,不要用 index 当 key(除非列表永远不增删、不排序)。

child / sibling / return:Fiber 树的遍历方式

Fiber 树不是普通二叉树,每个节点有三个指针:

  • child:指向第一个子节点
  • sibling:指向下一个兄弟节点
  • return:指回父节点

render 阶段的遍历是深度优先的:

beginWork 沿 child 一路向下(深入) 到达叶子后 completeWork 处理自身,再转向 sibling(回溯) sibling 处理完,沿 return 回到父级,继续下一轮

performUnitOfWork 里的 do-while 循环用迭代实现这套"深入-回溯",既避免递归爆栈,也让每个 fiber 成为一个可暂停的"工作单元"——这正是后续并发渲染能随时让路给高优先级任务的结构基础。

completeWork:自底向上地"解决问题"

beginWork 自上而下地问"子节点怎么生成",completeWork(ReactFiberCompleteWork.js)则自下而上地答:

  • 对 HostComponent:mount 时 createInstance 创建真实 DOM 节点、appendAllChildren 把子树 DOM 挂上去;update 时 updateProperties 把新的 props(属性、style、事件)应用到已有 DOM。
  • 对 FunctionComponent / ClassComponent:主要做 hooks 相关的收尾和 ref 处理。
  • 每个 fiber 在这里把本次更新要做的操作写进 effectTag(React 18 中改名为 flags),并顺手构建 effect list——一条只包含"有副作用节点"的链表。commit 阶段直接沿着它走,不必再遍历整棵树。

effectTag:写给 commit 阶段的"工单"

render 阶段全程不碰 DOM,只"记账"。常见的 effectTag:

标记 含义
Placement 新增/移动,需要插入 DOM
Update 属性、文本等需要更新
Deletion 删除节点(含组件卸载、清理 effect)
Ref ref 需要挂载/卸载
Passive 需要执行 useEffect(React 18 拆为 PassiveEffect)

commit 阶段拿着这些标记,才知道"哪里插、哪里删、哪里改"。render 阶段负责"算",commit 阶段负责"做"——这个边界是 React 架构上最重要的划分之一,也是后面讲调度和并发时的前提。

小结

render 阶段可以概括为三句话:

  1. beginWork 自顶向下,通过 reconcileChildren 的 diff 决定子 fiber 是复用还是新建;
  2. completeWork 自底向上,创建/更新 DOM 实例,把副作用记进 effectTag 并串成 effect list;
  3. 全程不碰真实 DOM,只产出"带着工单的新树",交给下一站的 commit 阶段执行。

下一篇,我们跟随这些 effectTag 进入 commit 阶段,看 DOM 更新的"最后一公里"到底怎么走。

【END】

本文链接:

版权声明:本博客所有文章除声明转载外,均采用 BY-NC-SA 3.0 许可协议。转载请注明来自 知己知事知理

阅读 1 | 发布于 2026-09-02
暂无评论