React completeWork 阶段的物理 DOM 构建逻辑:分析节点创建、属性初始化以及副作用标志向上传递的冒泡算法

好,各位同学,欢迎来到今天这场名为“React 内核解密:物理 DOM 构建与副作用冒泡大作战”的讲座。我是你们今天的讲师,一个在 React 内部源码里迷路过,但终于找到出口的资深工程师。

我们都知道,写 React 很爽,组件化、声明式,感觉像是上帝在捏泥人。但是,当 React 准备把那个泥人变成真实的 HTML 节点插到页面上时,到底发生了什么?那个传说中的 completeWork 阶段,究竟是在完成什么惊天动地的大事?

今天,我们不谈 Hooks,不谈 Diff 算法(那是上一节课的事,也就是 reconcile 阶段),我们直接杀入 commit 阶段的腹地——completeWork。这里是物理 DOM 构建的工厂,是副作用标记的传声筒。

准备好了吗?系好安全带,我们要开始“造 DOM”了。

第一部分:双缓冲技术——为什么要有两棵树?

在进入 completeWork 之前,我们必须先搞清楚一个核心概念:为什么会有两个 Fiber 树?

你可能会问:“React 不是应该直接根据虚拟 DOM 生成真实 DOM 吗?” 大错特错。React 的并发模式里,它是怎么玩的呢?它会有一个正在屏幕上显示的树(current),还有一个正在后台默默构建、准备替换掉 current 的树(workInProgress)。

你可以把 current 树想象成你正在放映的电影,它是只读的,用户正在看这个。而 workInProgress 树,是导演在剪片室里搭的布景。导演在布景上疯狂修改、调整灯光、把演员(DOM 节点)搬来搬去。如果导演觉得布景太烂,随时可以重来。一旦导演满意了,currentworkInProgress 就会交换角色,电影正式上映新的版本。

而我们的主角 completeWork,就是这个导演的执行团队。它的任务就是:拿着 workInProgress 这棵树,去对比 current 这棵树,然后决定怎么修改那个 current 的 DOM 树。

第二部分:节点创建工厂——从 Fiber 到 DOM

completeWork 的入口,通常是一个函数调用。让我们假装我们在写一个极简版的 React 源码来理解这个流程。

当 Fiber 节点的 type 是一个字符串(比如 'div''span')时,React 知道这是原生 DOM 元素。如果是组件,那事情就复杂了(可能涉及 Fiber 树的合并等),但今天我们聚焦在原生 DOM 的构建上。

代码示例 1:节点创建的入口

function completeWork(current, workInProgress) {
  // 这里的 current 是旧树,workInProgress 是新树
  const base = current !== null ? current.memoizedProps : null;
  const newProps = workInProgress.pendingProps;

  // 根据类型分发
  switch (workInProgress.tag) {
    case HostComponent: // 比如 'div'
      return completeHostComponent(current, workInProgress, newProps, rootContainerInstance);
    case HostText: // 比如 'Hello World'
      return completeHostTextComponent(current, workInProgress, newProps);
    // ... 其他类型
  }
}

看,这里有个关键的判断:current 是否为空?

如果 current 为空,说明这是一个全新的节点,之前页面上根本不存在这个 DOM。这时候,我们需要“创造”一个。

代码示例 2:创建 DOM 实例

function createInstance(type, newProps, rootContainerInstance, hostContext) {
  // 1. 创建原生 DOM 元素
  const domElement = document.createElement(type);

  // 2. 应用初始属性
  // 注意:这里就是属性初始化的开始
  updateProperties(domElement, newProps);

  // 3. 如果有子节点,这里可能会建立父子关系
  // 但在 completeWork 里,真正的父子挂载是在后续的递归中完成的

  return domElement;
}

这里的 updateProperties 就是我们要讲的第二个大模块。我们在创建 DOM 的时候,绝对不会让它光秃秃的。我们要给它加 className,加 id,加 style。React 会把 newProps 对象里的属性,尽可能一一对应地挂载到 domElement 上。

小贴士: 你可能会问,为什么是 createInstance 而不是直接 createElement?因为 React 为了性能,有时候会合并多次属性更新,或者处理一些特殊的属性(比如 valuedefaultValue 的区别)。但在 completeWork 的逻辑里,它的本质就是“创建 DOM”和“设置属性”。

第三部分:属性初始化——属性化妆师

属性初始化可不是简单的把对象扔进 DOM。这就像给模特化妆。

completeWork 会拿到 workInProgressnewProps,然后遍历它们。如果发现属性变了,它需要调用 DOM 的 setAttribute 方法。

代码示例 3:属性更新的逻辑

function updateProperties(domElement, updatePayload) {
  // updatePayload 是一个数组,格式通常是 [key, value, key, value, ...]
  // 这种扁平化数组是为了性能,方便 Diff 和批量处理

  for (let i = 0; i < updatePayload.length; i += 2) {
    const propKey = updatePayload[i];
    const propValue = updatePayload[i + 1];

    if (propKey === 'style') {
      // 处理样式对象
      for (const styleName in propValue) {
        domElement.style[styleName] = propValue[styleName];
      }
    } else if (propKey === 'className') {
      // class 属性通常会被处理成 className
      domElement.setAttribute('class', propValue);
    } else if (propKey === 'value') {
       // 特殊处理 input 的 value
       domElement.value = propValue; 
    } else {
      // 通用属性
      domElement.setAttribute(propKey, propValue);
    }
  }
}

这里有一个很有意思的细节。React 在构建 updatePayload 时,会做一个非常有意思的“体操”。比如,你写了 <input value="hi" />,React 不会傻傻地每次都去改 input.value。它会在 reconcile 阶段把变化记录下来。如果没变,completeWork 根本不会调用 setAttribute,直接跳过。这就是性能优化的关键。

第四部分:副作用标志——孩子间的传话游戏

好了,节点建好了,属性刷好了。现在 DOM 变成了“光杆司令”。它身上空荡荡的,没有子元素。

这时候,我们需要把这些节点“挂”上去。但是,挂上去不仅仅是 appendChild 这么简单。因为 reconcile 阶段只是画了张蓝图(Fiber 树),而 completeWork 是要把蓝图变成现实。

这里就引出了 React 最复杂的逻辑之一:副作用标志的向上冒泡

想象一下,你在开发一个巨大的公司。div 是部门经理,span 是员工,text 是一张便签。
reconcile 阶段,React 标记了每个员工的变动:

  • 员工 A 换了工位(Placement)。
  • 员工 B 被开除了(Deletion)。
  • 员工 C 只是更新了个名字(Update)。

现在,部门经理(div)走进来了。completeWork 需要知道:我现在的状态是什么?我有新员工进来吗?有老员工走人吗?

React 通过 Fiber 节点上的 flags 属性来记录这些信息。

代码示例 4:副作用标志的传播

当处理完子节点后,父节点的 flags 会加上子节点的某些 flags。这是一种“或”的关系。

// 伪代码
function completeWork(current, workInProgress) {
  // ... 处理 props ...

  const child = workInProgress.child;
  if (child !== null) {
    // 1. 递归处理子节点
    // completeWork 会递归调用自己,从左到右遍历 Fiber 树
    // 到了最底层的 text 节点,它不会有子节点了,处理完就返回
    // 此时,text 节点的 flags 已经确定下来了(比如它被标记为 Placement)
    completeWork(null, child);

    // 2. 处理完子节点后,向上冒泡
    // 父节点需要知道孩子干了什么

    if (child.flags & Placement) {
      // 如果子节点需要被插入
      // 父节点自己也需要被标记为 Placement
      // 或者更准确地说是,父节点容器需要被标记为 Placement
      workInProgress.flags |= Placement;
    }

    // 这是一个简化的例子,真实逻辑要复杂得多
    // 它会根据子节点的状态,决定父节点应该怎么提交
  }
}

核心逻辑解析:

React 的 completeWork 是深度优先遍历(DFS)。它先处理左边的叶子节点,处理完后,带着这个叶子节点的状态(flags),回到父节点。

这个“状态”传递的过程,就是冒泡算法

  • Placement(插入):如果一个子节点是 Placement,说明它在这个渲染周期里是新来的。那么它的父节点(或者父节点的父节点…直到根节点)就必须被标记为需要“挂载”或者“更新”。因为父节点 DOM 树的结构变了,必须刷新。
  • Update(更新):如果一个子节点是 Update,父节点可能也需要被标记为 Update,因为父节点的子节点数量可能变了,或者父节点自身也需要被重新渲染。
  • Deletion(删除):如果一个子节点要被删除,那它的父节点就是“坟墓”,父节点需要被标记为需要处理删除逻辑。

代码示例 5:插入节点的具体实现

当 flags 暗示我们需要插入一个子节点时,completeWork 会调用一个神奇的方法:commitPlacement。虽然这个方法主要是在 commitRoot 阶段被调用的,但在 completeWork 阶段,我们已经通过 flags 知道了它要发生。

completeWork 内部(更准确地说是 React 内部调度器在协调时),它会调用 appendInitialChild

function appendInitialChild(returnFiber, childFiber) {
  // returnFiber 是父节点,childFiber 是准备插入的子节点
  // 这时候,DOM 已经创建好了

  const child = childFiber.stateNode; // 这就是真实 DOM 节点

  // 1. 把子节点挂载到父节点的 DOM 上
  if (returnFiber.stateNode === null) {
     // 如果父节点还没有创建 DOM(这通常发生在父节点是 Fragment 或者还没递归到父节点的时候)
     // 这是一个特殊情况,通常在 completeWork 开始时会处理
     // 这里简化为:父节点 DOM 已经存在
  }

  returnFiber.stateNode.appendChild(child);

  // 2. 更新链表关系
  // Fiber 树本质上是一个单向链表
  returnFiber.lastEffect = childFiber;
}

这里有个经典的“鸡生蛋,蛋生鸡”问题:如果父节点还没创建 DOM,怎么 appendChild
React 的策略是:延迟挂载
completeWork 的最开始,如果 current 为 null(全新渲染),React 会先从根节点开始,一路创建 DOM 节点,把 DOM 节点的引用(stateNode)填进 Fiber 节点里。所以当递归到子节点时,父节点的 stateNode 已经存在了。

第五部分:文字节点与原生节点的区别

你可能注意到了,我一直在区分 HostComponent(div, span)和 HostText(文本)。

completeWork 里,它们的处理方式截然不同。

HostComponent (div, span 等)
它们是“容器”。它们需要建立父子关系,需要处理 props,需要冒泡副作用。

HostText (文本节点)
文本节点通常是叶子节点,它没有子节点。它不需要 appendChild
它的 completeWork 逻辑非常简单:

  1. 创建一个 document.createTextNode
  2. 应用文本内容。
  3. 如果文本内容变了,更新它。
  4. 确定它的副作用(是新增、删除还是更新文本内容)。
  5. 向上冒泡。比如,如果文本节点被删除了,它的父 div 就要标记为需要删除。

代码示例 6:HostText 的处理

function completeWork(current, workInProgress) {
  if (current === null) {
    // 全新创建
    workInProgress.stateNode = document.createTextNode(workInProgress.pendingProps);
  } else {
    // 对比
    const oldText = current.memoizedProps;
    const newText = workInProgress.pendingProps;

    if (oldText !== newText) {
      // 文本变了
      workInProgress.stateNode.nodeValue = newText;
      // 设置 Update 标志
      workInProgress.flags |= Update;
    }
  }
  // HostText 不需要递归处理子节点
  return null; 
}

第六部分:完整案例演练——从 JSX 到真实 DOM 的全过程

为了让大家彻底明白,我们来手写一个完整的流程。假设我们渲染这个 JSX:

<div className="box">
  <span>Hello</span>
</div>

步骤 1:Reconcile 阶段(我们略过,直接看结果)
此时,Fiber 树已经构建完毕,并且标记好了 Flags。

  • div (HostComponent): Flags = [Placement, Update]
  • span (HostComponent): Flags = [Placement, Update]
  • Hello (HostText): Flags = [Update] (假设是更新)

步骤 2:进入 completeWork 阶段

我们有一个 workInProgress 树,现在开始处理。

  1. 处理 div 节点

    • current 为 null,这是根节点的新子树。
    • 创建 DOM:div
    • 设置 Props:className = "box"
    • 递归处理子节点
      • 调用 completeWork 处理 span
  2. 处理 span 节点

    • current 为 null。
    • 创建 DOM:span
    • 设置 Props:无特殊 props。
    • 递归处理子节点
      • 调用 completeWork 处理 Hello (HostText)。
  3. 处理 Hello 节点

    • current 为 null。
    • 创建 DOM:text node (值 “Hello”)。
    • 无子节点,返回
    • 向上冒泡span 节点的 flags (Placement) 需要告诉它的父节点。由于 spandiv 的孩子,divflags 需要包含 span 的变动。div 被标记为 Placement
  4. 回到 span 节点

    • span 处理完毕,递归结束。span 节点将 DOM 引用挂载到父 divstateNode 上。
  5. 回到 div 节点

    • span 处理完毕。
    • div 需要将自己的 flags 向上传递(如果 div 还有父级)。
    • div 此时已经是一个完整的物理 DOM 节点了,且它的子节点(span)也已经挂载完毕。
    • divHello 的 DOM 节点 appendChilddiv 上。

步骤 3:提交阶段
此时,React 的调度器(Scheduler)看到 div 节点的 Flags 满足提交条件。它会调用 commitRoot
commitRoot 中,它会遍历 Fiber 树,根据 Flags 执行真正的 DOM 操作:

  • Placement: 将 div 插入到 rootContainer
  • Update: 修改 divclassName
  • Placement: 将 span 插入到 div 内部。

第七部分:进阶——处理删除与更新

我们刚才主要讲了“新增”。如果既有新增,又有删除,completeWork 是怎么处理的?

这就涉及到了 Deletion

reconcile 阶段,如果发现 DOM 节点在旧树里有,但在新树里没了,React 会把这个 Fiber 节点标记为 Deletion,并把它加到一个“待删除队列”里。

注意,这里不是在 completeWork 里直接删除 DOM。删除操作主要在 commitRoot 阶段,因为删除可能会导致布局变化,React 希望把删除操作和新增操作批量处理。

但是,completeWork 必须做一件事:回收节点
因为新树可能没有这个节点了,而旧树里还有这个节点的 Fiber 和 DOM。如果不在 completeWork 里把它和旧树的引用断开,内存泄漏就来了。

代码示例 7:删除节点的断开逻辑

function completeWork(current, workInProgress) {
  if (current !== null) {
    // 说明这是一个更新或复用节点,我们需要处理旧节点
    // 如果 newFiber 不是 deleted,我们需要把 oldFiber 从 current 的链表中移除
    // 逻辑非常繁琐,涉及 prevEffect, nextEffect 的指针重连
    // 这就是为什么 completeWork 的代码很长,因为它要维护链表结构
  }
}

简单来说,如果 completeWork 发现父节点不需要子节点了,它就会把那个子节点从父节点的子链表里拔出来,挂在某个“待回收”的地方。

第八部分:性能的权衡与细节

到这里,我们基本上讲完了 completeWork 的骨架。但为什么我觉得它这么难懂?因为它充满了对性能的极致追求。

  1. 单次遍历
    completeWork 是一次遍历完成的。它不仅要创建 DOM,还要建立链表,还要传递 flags。它不能像 reconcile 那样分两步(先 create,再 delete)。因为它要在创建的同时,就断开旧的联系。

  2. Flags 的精妙设计
    React 使用了位运算来存储 Flags。Placement = 1 << 0; Update = 1 << 1; ...。这样做是为了方便位运算的“或”操作(flags |= Placement)和“与”操作(flags & Placement)。就像给每个员工发一个不同颜色的贴纸,父节点只需要收集所有孩子的贴纸,贴在自己脑门上。

  3. React 18 的并发
    在 React 18 之前,completeWork 是同步的。但在并发模式下,completeWork 的执行时间被限制了(commit 阶段不能超过 50ms)。这迫使 React 在 completeWork 里必须非常高效,不能做任何高开销的计算,比如复杂的数学运算或者大对象的拷贝。

结语:从抽象到具象的旅程

好了,同学们,今天的讲座接近尾声。

回顾一下,我们聊了什么?
我们聊了 completeWork 是如何接过 reconcile 的接力棒的。
我们聊了它是如何像个勤劳的园丁,把虚拟的 Fiber 树变成真实的物理 DOM 节点(createInstance)。
我们聊了它是如何像个精细的化妆师,给节点涂上属性的油彩(updateProperties)。
最精彩的,我们聊了它是如何像个精明的包工头,通过冒泡算法,把孩子们(子节点)的变动告诉家长们(父节点),构建起庞大的副作用更新网络。

React 的魅力在于,它用最抽象的 JSX 描述了你的想法,而 completeWork 则在幕后默默地把这些想法变成了具体的 HTML。虽然这个过程充满了指针操作、链表维护和复杂的位运算,但只要你理解了“从 Fiber 到 DOM”和“副作用向上传递”这两个核心逻辑,你就摸到了 React 内核的门把手。

下次当你看到页面闪烁,或者思考为什么某个 Props 没有生效时,希望你能想起今天讲的这些内容:不仅仅是数据流的问题,更是这些复杂的 DOM 构建逻辑在起作用。

好了,下课!别忘了去喝杯咖啡,React 还在后台替你默默地构建 DOM 呢。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注