React Fiber 树的深度优先遍历:探究 completeWork 阶段对 DOM 实例的挂载逻辑

各位同学,大家好!

欢迎来到“React 内部架构解密”系列讲座的第 N 期。今天,咱们要聊的东西有点“硬核”,有点“底层”,甚至有点像是在拆一台正在运行的机器。

如果不加修饰地说,React Fiber 是一个调度算法;但如果用更通俗的话来说,Fiber 是 React 的心脏,是它的调度员。而今天我们要讲的 completeWork,则是这个调度员在完成工作后,真正动手“盖房子”的那个阶段。

咱们今天不整那些虚头巴脑的“引言”,也不搞什么“总结升华”。咱们直接把 React 的源码扒开,拿个放大镜,看看它是怎么把一个 JavaScript 对象(Fiber 节点),变成屏幕上一个实实在在的 HTML 标签(DOM 节点)的。

准备好了吗?咱们开始吧。


第一部分:Fiber 是怎么“走”的?栈帧与迭代

在深入 completeWork 之前,咱们得先搞清楚一件事:Fiber 是怎么遍历那棵树的?

在 React 旧版本(Stack Reconciler)里,那是个递归过程。就像你走路,你只能走到头,走到头了再回头。如果树太大,递归太深,浏览器主线程就被卡住了,用户就会感觉到页面卡顿。

现在,React 变成了 Fiber。它不再是递归了,而是迭代。怎么迭代?靠栈帧。

想象一下,你是一个工头,手里拿着一张施工单(Fiber 节点)。你走到一棵树前,你要决定先干哪一层的活。Fiber 的遍历逻辑是这样的:

  1. 入栈:你拿到当前节点的数据,把它压进你的“工作栈”。
  2. 干活:你检查这个节点需要做什么(是创建 DOM?是更新属性?还是删除?)。
  3. 找子节点:如果有子节点,你把当前节点挂到子节点的 return 指针上,然后把子节点作为当前节点,入栈
  4. 出栈:如果子节点处理完了,或者没有子节点了,你就从栈里弹出来,回到父节点,处理兄弟节点。

这个栈帧的切换,就是 React 遍历树的核心。


第二部分:调和 vs. 完成—— 蓝图与施工

React 的渲染过程通常被分为两个阶段:

  1. Reconciliation (调和阶段):也就是 Diff 算法。在这个阶段,React 比较新旧两棵树,计算出差异。这个阶段会生成很多标记(Effect Tags),比如 Placement(新增)、Update(更新)、Deletion(删除)。这个阶段是可以被打断的,所以比较快。
  2. Commit (提交阶段):也就是今天的主角 completeWork。在这个阶段,React 根据调和阶段算出来的 Effect Tags,真正地去操作 DOM,去改变页面。

注意了! completeWork 的核心任务只有一个:把 Fiber 节点“挂载”到 DOM 树上

这里有个关键点:调和阶段生成的 Effect Tags,决定了 completeWork 要怎么干活。比如,如果一个节点标记为 Placement,那 completeWork 就得去 document.createElement


第三部分:completeWork 的入口与类型判断

咱们直接看源码(简化版逻辑)。

当 Fiber 调度器把一个工作单元(WorkUnit)分配给你时,你会调用 beginWorkbeginWork 完成后,或者如果它被打断了,React 就会调用 completeWork

completeWork 函数的开头,通常是这样的:

function completeWork(current, workInProgress, renderLanes) {
  // 1. 获取当前 Fiber 节点的类型
  const tag = workInProgress.tag;

  // 2. 根据类型分发不同的处理逻辑
  switch (tag) {
    case HostComponent:
      return completeHostComponent(current, workInProgress, renderLanes);
    case HostText:
      return completeHostTextComponent(current, workInProgress, renderLanes);
    case Fragment:
    case Portal:
      // ...其他类型的处理
    default:
      return null;
  }
}

这里的 tag 告诉了我们这是什么节点。

  • HostComponent:对应 HTML 标签,比如 div, span, p
  • HostText:对应文本节点,比如 “Hello World”。
  • Fragment:对应 <>...</>
  • Component:对应我们写的 React 组件(比如 <Counter />)。

对于组件节点,completeWork 的工作相对简单(或者说是委托给子组件去处理)。但对于 HostComponentHostText,这是 DOM 挂载的重头戏。


第四部分:HostComponent 的挂载逻辑—— 从无到有

这是最精彩的部分。咱们来看看 completeHostComponent 到底干了啥。

假设我们要渲染一个 <div id="app">

  1. 创建实例
    首先,React 检查当前节点是不是一个新的节点(或者说是被标记为 Placement 的节点)。
    如果是,它就调用 createInstance

    // 简化版逻辑
    function createInstance(type, props, rootContainerInstance) {
      // 这里其实就是 document.createElement(type)
      return document.createElement(type);
    }

    这一步非常关键。它把 Fiber 节点变成了真实的 DOM 节点。此时,DOM 节点虽然存在了,但是它是空的,还没有挂载到页面上。

  2. 挂载子节点
    有了 DOM 实例,接下来要处理子节点。React 会遍历当前 Fiber 节点的 child 链表。

    这时候,它不会直接操作 DOM 树,因为 DOM 树还没挂载。它操作的是内存中的 Fiber 树。React 会递归(或者迭代)地调用 completeWork 来处理所有子节点。

    当所有子节点都被处理完,它们都变成了真实的 DOM 节点,并且挂载到了父节点的 child 链表上。

    // 简化版逻辑
    function appendInitialChild(childInstance, node) {
      childInstance.appendChild(node); // 把子 DOM 挂到父 DOM 里
    }

    注意:这里有个细节。React 的 Fiber 树是单向链表(child, sibling, return)。在调和阶段,React 通过 return 指针来建立父子关系。而在 completeWork 阶段,它利用这个 return 关系,把 DOM 节点真正地“连”起来。

  3. 处理 Props(属性)
    子节点挂好之后,咱们得给父节点加点“装修”。这时候会调用 updateProperties

    function updateProperties(domElement, updatePayload, prevProps) {
      // 遍历 updatePayload,设置 style, className, id 等
      for (let i = 0; i < updatePayload.length; i += 2) {
        const propKey = updatePayload[i];
        const propValue = updatePayload[i + 1];
        if (propKey === 'style') {
          // 设置样式
        } else if (propKey === 'className') {
          domElement.className = propValue;
        } else {
          domElement.setAttribute(propKey, propValue);
        }
      }
    }

    这就是为什么你写 className="box",React 会把 box 赋给 DOM 的 className 属性。它处理得很细致,包括事件监听器的绑定。


第五部分:Placement 逻辑—— 到底插哪里?

这是 completeWork 中最让人头秃,但也最迷人的地方:位置

假设我们有一棵树:

<div>
  <span>Old</span>
  <span>New</span>  <-- 我们要插入这个
</div>

在调和阶段,React 发现了 <span>New</span> 这个节点,并给它打上了一个 Placement 标记。

现在,到了 completeWork 阶段。当处理完 <div> 之后,轮到处理 <span>Old</span>。处理完 <span>Old</span> 后,轮到处理 <span>New</span>

在处理 <span>New</span> 之前,React 会检查它的父节点(也就是 <div>)。

React 会问自己:“嘿,我的父节点 <div> 是不是也被标记为 Placement 了?”

  • 如果父节点不是 Placement,那 <span>New</span> 就直接作为 <div> 的第一个子节点挂载就行了。
  • 如果父节点是 Placement,那就麻烦了。说明 <div> 本身就是新挂载的,或者 <div> 之前被移除了。

这时候,React 会去检查 <div>return 指针,或者通过某种机制找到 <span>Old</span>

关键逻辑来了:

React 会看 <span>New</span> 在 Fiber 树中是否有 sibling(兄弟节点)。

  • 如果有兄弟节点(比如 <span>Old</span>),React 会把 <span>New</span> 插在 <span>Old</span>前面
  • 如果没有兄弟节点,React 会把它插在 <div> 的最后。

这背后的代码逻辑大概是这样的(伪代码):

function completeWork(current, workInProgress) {
  // ...省略前面的逻辑...

  // 检查当前节点是否有 Placement 标记
  if ((workInProgress.effectTag & Placement) !== NoEffect) {
    // 还要检查父节点是否也是 Placement
    const currentParent = workInProgress.return;
    // 假设 currentParent 是 HostComponent (比如 div)
    if (currentParent !== null && currentParent.tag === HostComponent) {
      const parentInstance = currentParent.stateNode;
      const currentChild = currentParent.child; // <span>Old</span>

      // 如果当前节点有兄弟节点
      if (workInProgress.sibling !== null) {
        // 逻辑:把当前节点插到兄弟节点的前面
        // 也就是插在 <span>Old</span> 前面
        commitPlacement(workInProgress, parentInstance, currentChild);
      } else {
        // 逻辑:没有兄弟节点,直接插到最后
        commitPlacement(workInProgress, parentInstance, null);
      }
    }
  }
  // ...省略后面的逻辑...
}

这个 commitPlacement 函数就是真正的 DOM 操作。它利用 insertBefore API 来实现插入。

function commitPlacement(fiber, parentInstance, beforeSibling) {
  // 1. 获取 DOM 节点
  const fiberDOM = fiber.stateNode;

  // 2. 决定插入位置
  if (beforeSibling) {
    // 插在 beforeSibling 前面
    parentInstance.insertBefore(fiberDOM, beforeSibling);
  } else {
    // 插到最后
    parentInstance.appendChild(fiberDOM);
  }
}

这就是为什么当你使用 key 属性时,React 能精准地判断节点是移动了、新增了还是删除了。key 帮助 React 在 Fiber 树中找到对应的兄弟节点位置。


第六部分:HostText 的挂载逻辑—— 纯文本

处理完 HTML 标签,咱们得处理文本内容。这比标签简单多了。

比如 <div>Hello</div>

在调和阶段,React 会创建一个 HostText 类型的 Fiber 节点。

completeWorkHostText 分支里:

  1. 创建实例document.createTextNode('Hello')
  2. 更新内容:如果这个节点有更新(比如 props.children 变了),它会调用 updateTextContent
function completeHostTextComponent(current, workInProgress, renderLanes) {
  const textInstance = workInProgress.stateNode;
  const textProps = workInProgress.pendingProps;

  // 如果文本变了,更新 DOM
  if (textProps !== null) {
    // 这里会处理转义字符等细节
    if (textInstance.nodeValue !== textProps) {
      textInstance.nodeValue = textProps;
    }
  }

  return null;
}

HostText 的挂载逻辑和 HostComponent 类似,也是挂载到父节点的 DOM 树中。


第七部分:一个具体的执行案例—— 模拟现场

为了让大家彻底明白,咱们模拟一个场景。

假设我们有一个组件结构:

function App() {
  return (
    <div className="container">
      <h1>Hello</h1>
      <p>World</p>
    </div>
  );
}

初始状态:DOM 树是空的。Fiber 树(调和阶段生成的)已经构建好了。

执行开始
调度器把 App 的根节点分配给 beginWork

  1. Root -> App (HostRoot)completeWork 处理 HostRoot,找到它的 child,也就是 <div>
  2. div (HostComponent)
    • tagHostComponent
    • 检查 effectTag。假设这是第一次渲染,没有 effectTag(或者有挂载标记)。
    • 创建 DOMdocument.createElement('div')div 节点在内存中诞生了。
    • 处理 Props:设置 className="container"
    • 递归子节点
      • 进入 h1
      • 进入 p
    • 处理 h1 (HostComponent)
      • 创建 <h1> DOM。
      • 创建子节点文本 “Hello”。
      • <h1> 插入到 <div> 里。
    • 处理 p (HostComponent)
      • 创建 <p> DOM。
      • 创建子节点文本 “World”。
      • <p> 插入到 <div> 里。

注意顺序
React 遍历子节点是深度优先的。
所以 Fiber 树的遍历顺序是:Root -> div -> h1 -> “Hello” -> p -> “World”。

completeWork 回到 div 时,div 已经拥有了所有的子 DOM 节点。此时,divstateNode 已经指向了那个真实的 <div> 元素。

插入 DOM 树
最后,React 会把根节点的 stateNode(那个 div)插入到 document.body 中。


第八部分:Diff 之后发生了什么?—— Update 的处理

咱们刚才说的是第一次渲染。那如果是第二次渲染呢?比如 App 里的 p 标签被删除了,变成了 <p>React</p>

在调和阶段,React 发现了差异:

  1. <p>World</p> 被标记为 Deletion
  2. <p>React</p> 被标记为 Placement(或者 Update,取决于实现)。

进入 completeWork

  1. 处理 <p>React</p>

    • 它是一个新的 Fiber 节点。
    • createInstance 创建 <p> DOM。
    • updateProperties 更新文本内容为 “React”。
    • 插入到 <div> 里。
  2. 处理 <p>World</p>

    • 它是旧节点。
    • React 检查到它的 effectTagDeletion
    • completeWork 可能会做一些清理工作(比如清理事件监听器)。
    • 然后,提交阶段会执行 commitDeletion,从 DOM 树中移除这个节点。

重点completeWork 主要负责“创建”和“更新”。而“删除”的逻辑,虽然也属于提交阶段,但通常是在 completeWork 遍历完树之后,或者在遍历过程中通过副作用链表来触发的。


第九部分:栈帧的消失与重入

这可能是最令人困惑的地方:既然 Fiber 是迭代的,那 completeWork 是怎么知道什么时候该“挂起”再“继续”的?

其实,Fiber 的工作单元是“栈帧”。
当你调用 beginWork 时,你压入一个栈帧。
当你调用 completeWork 时,你压入另一个栈帧。

但在 completeWork 的执行过程中,React 有一个非常精妙的技巧:副作用链表

React 并不是在 completeWork 里一次性把所有 DOM 操作都做完然后提交。它是按顺序把 DOM 操作挂载到一个链表上。

当你处理完一个子节点并挂载好 DOM 后,React 会把这个节点挂到父节点的 firstEffectlastEffect 链表上。
当你处理完父节点并挂载好 DOM 后,父节点也会挂到根节点的链表上。

最后,React 遍历这个根节点的 Effect 链表,一次性执行所有的 DOM 操作(插入、更新、删除)。

这就像是你一边盖房子,一边在墙上钉钉子记录“哪里要刷漆”。盖完所有房间后,你沿着墙走一圈,把所有要刷漆的地方都刷一遍。


第十部分:总结—— 挂载的“工匠精神”

好了,咱们把镜头拉远,总结一下 completeWork 到底是个什么角色。

它不是一个单一的函数,它是一个流程

  1. 它是个分类器:它根据 Fiber 的 tag,决定你是 HTML 标签、文本节点还是组件。
  2. 它是个建造者:它调用 createInstance,把 JavaScript 对象变成真实的浏览器 DOM。
  3. 它是个装修工:它调用 updateProperties,把 CSS 类名、内联样式、事件监听器安放在 DOM 上。
  4. 它是个建筑师:它利用 return 指针和 sibling 指针,决定 DOM 节点之间的父子和兄弟关系。

completeWork 的核心逻辑其实就是三个动作:

  1. 创建createInstance
  2. 挂载appendInitialChild / insertPlacement
  3. 更新updateProperties

如果你能把这个流程跑通,React 的渲染机制对你来说就不再是黑盒了。你不再只是会写 JSX,你会知道你写的每一行代码,在浏览器底层是如何一步步变成那个 <div> 的。

下次当你看到页面刷新,或者 React 报个错,你可以试着在脑海里过一遍这个 completeWork 的过程:哪一步创建了节点?哪一步插错了位置?哪一步属性没更新?

这就是技术深度的魅力所在。代码不仅是逻辑,更是构建现实世界的蓝图。

好了,今天的讲座就到这里。我是你们的讲师,咱们下次见!

发表回复

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