React 组件挂载顺序:从源码视角分析自上而下的 beginWork 与自下而上的 completeWork 逻辑

React 组件挂载全解析:从 Fiber 树的“自上而下”到“自下而上”

各位同学,大家好!

今天我们不聊业务,不聊 UI 设计,咱们来聊点硬核的。如果你觉得 React 只是简单的 JSX 转换,那你可就太小看它了。React 的核心,其实是一台精密的瑞士钟表,而 Fiber 就是那个齿轮组。

今天我们要讲的是组件挂载的“双城记”:自上而下的 beginWork自下而上的 completeWork。这就像是一个忙碌的项目经理(父节点)在分配任务,然后看着下属(子节点)一个个把活干完再回来汇报。

准备好了吗?把咖啡喝好,我们直接开讲。


第一章:React 的“多线程”错觉与 Fiber 架构

首先,咱们得搞清楚 React 为什么这么折腾。在 React 15 时代,那叫一个“同步渲染”。你一调接口,页面卡死三秒,全靠 setTimeout 模拟异步。到了 React 16,React 团队引入了 Fiber。

Fiber 是什么?它不是什么高深莫测的物理概念,它就是一个 JavaScript 对象。

function FiberNode() {
  this.tag = ...; // 标记类型:函数组件、类组件、宿主组件(DOM节点)等
  this.return = null; // 父节点
  this.child = null;  // 第一个子节点
  this.sibling = null; // 下一个兄弟节点
  this.alternate = null; // 当前节点 vs 旧节点(用于对比)
  this.effectTag = ...; // 副作用标记:更新、插入、删除等
}

你可以把 Fiber 树想象成一个链表。React 不像人类那样一次性记住整棵树,它更像一个健忘但高效的流水线工人。它一次只处理一个节点,处理完了再去下一个。

那么,这个工人是怎么工作的呢?这就引出了我们今天的两个核心函数:beginWorkcompleteWork


第二章:自上而下的 beginWork —— “分配任务”

想象一下,你是公司的 CEO。你坐在办公室里,面前有一份复杂的组织架构图。你想知道每个部门今年干了什么活。

beginWork 的任务就是:向下遍历,分配任务,创建子节点

1. 调度入口:performUnitOfWork

React 的调度器(Scheduler)会调用 performUnitOfWork 函数。这个函数是整个挂载循环的引擎。它的核心逻辑非常简单粗暴:

function performUnitOfWork(currentFiber) {
  // 1. 先尝试做 beginWork
  const next = beginWork(currentFiber);

  // 2. 如果有子节点,就递归去处理子节点
  if (next) {
    next.return = currentFiber; // 建立父子关系
    return next;
  }

  // 3. 如果没有子节点了,说明这个节点已经处理完了,该轮到它的父节点处理了
  // 所以要回溯,调用 completeUnitOfWork
  return completeUnitOfWork(currentFiber);
}

注意这个逻辑: 它不是一次性遍历完,而是“递归调用” + “栈回溯”。这就像你下楼梯,一级一级走下去(beginWork),走到头了(叶子节点),再一级一级爬上来(completeWork)。

2. beginWork 的核心逻辑

beginWork 函数长得非常像 switch 语句,因为它要根据节点的类型(tag)来决定怎么干活。

假设我们有一个组件树:

function App() { return <Header /> }
function Header() { return <div>Content</div> }

performUnitOfWork 拿到 App 的 Fiber 节点时,会调用 beginWork(App)

// beginWork 伪代码
function beginWork(currentFiber) {
  switch (currentFiber.tag) {
    case HostComponent: // 比如 <div>
      return updateHostComponent(currentFiber);
    case FunctionComponent: // 比如 function App()
      return updateFunctionComponent(currentFiber);
    // ... 其他类型
  }
}

如果是 FunctionComponent(函数组件),React 会干两件大事:

  1. 调用 render 函数:这是你代码里写 return <Header /> 的地方。
  2. 协调子节点:对比 current 树(旧树)和 workInProgress 树(新树),决定是复用节点还是创建新节点。
function updateFunctionComponent(currentFiber) {
  // 1. 调用用户的 render 函数
  // 这时候 React 会执行你的函数组件代码,产生新的 Fiber 节点
  const children = currentFiber.type(currentFiber.props);

  // 2. 处理返回的 children
  // reconcileChildren 会对比新旧 children,返回下一个要处理的 Fiber
  currentFiber.child = reconcileChildren(
    currentFiber, 
    currentFiber.alternate, 
    children
  );

  return currentFiber.child; // 返回第一个子节点,让 performUnitOfWork 去处理它
}

这里的自上而下体现在哪里?
体现在 递归
App 拿到 Header,然后 Header 拿到 div,然后 div 拿到 text
Fiber 树的 child 指针被像接力棒一样传递下去。

此时发生了什么?

  • Apprender 被调用了。
  • Headerrender 被调用了。
  • DOM 节点还没创建,只是 Fiber 节点被分配了任务。

第三章:自下而上的 completeWork —— “汇报工作”

beginWork 走到最深处(比如一个文本节点 Text,或者一个没有子组件的 div),它会发现 childnull

这时候,performUnitOfWork 就会调用 completeUnitOfWork

1. 完成当前节点

completeUnitOfWork 会调用 completeWorkbeginWork 是“分配”,completeWork 就是“执行”。

function completeUnitOfWork(fiber) {
  // 1. 先处理当前节点的 completeWork
  completeWork(fiber);

  // 2. 如果有兄弟节点,去处理兄弟节点
  let sibling = fiber.sibling;
  if (sibling) {
    return sibling;
  }

  // 3. 如果没有兄弟节点,说明父节点也处理完了,继续向上回溯
  let returnFiber = fiber.return;
  if (returnFiber) {
    return returnFiber;
  }

  // 4. 到了根节点,任务结束
  return null;
}

这里的自下而上体现在哪里?
体现在 回溯
当叶子节点处理完,它会向上找到父节点,父节点处理完,再找爷爷节点。

2. completeWork 的核心逻辑

completeWork 的主要工作非常具体:创建 DOM、处理副作用、处理生命周期

function completeWork(currentFiber) {
  const newProps = currentFiber.memoizedProps;
  const tag = currentFiber.tag;

  switch (tag) {
    case HostComponent: // 比如 <div>
      // 1. 创建 DOM 节点 (createInstance)
      const instance = createInstance(newProps.type, newProps);
      // 2. 将 DOM 节点挂载到父节点的 DOM 容器中 (mountChildInstance)
      appendAllChildren(instance, currentFiber);
      // 3. 标记副作用 (Placement)
      currentFiber.effectTag |= Placement;
      // 4. 返回 DOM 实例供父节点使用
      currentFiber.stateNode = instance;
      break;

    case FunctionComponent:
      // 函数组件没有自己的 DOM,它的 DOM 由子节点决定
      // 重要的是处理 useEffect 的 mount
      commitBeforeMutationEffects(); // 这里处理 useEffect 的 mount
      break;
  }
}

这里发生了什么?

  • DOM 诞生createInstance 生成了真实的 <div> 标签。
  • 父子连接appendAllChildren 把刚刚创建好的子节点 DOM,挂载到了父节点的 DOM 里。
  • 副作用标记Placement 标记告诉 React:“嘿,这个节点是新加进来的,下次提交阶段要把它插到页面上。”

第四章:生命周期钩子在哪里?(源码视角的真相)

这是面试中最爱问的问题,也是同学们最迷糊的地方。constructor, render, useEffect 到底跑在哪一步?

让我们用刚才的组件树 App -> Header -> div 来复盘整个流程。

1. constructorrender (在 beginWork 中)

performUnitOfWork 拿到 App 的 Fiber:

  1. 调用 beginWork(App)
  2. 发现是 FunctionComponent
  3. 调用 App.render(props, state)
  4. App.render 返回 <Header />
  5. reconcileChildren 生成 Header 的 Fiber 节点。
  6. 调用 Header.render(props, state)
  7. Header.render 返回 <div>Content</div>
  8. reconcileChildren 生成 div 的 Fiber 节点。

结论constructorrender 是在 自上而下遍历 的过程中执行的。父组件的 render 执行完,才会去执行子组件的 render。

2. useEffect (在 completeWork 中)

这是重点,也是难点。

假设你在 App 组件里写了 useEffect(() => { console.log('App mounted') }, [])

React 不会在 render 阶段就执行这个回调。为什么?因为 React 需要先确保子组件都渲染完了,DOM 都挂载好了,才能执行副作用。

执行流程是这样的:

  1. beginWork 执行到 div 节点,发现是叶子节点。
  2. completeWork(div) 执行。把 DOM 插入页面。
  3. performUnitOfWork 回溯,执行 completeWork(Header)
    • 这里会处理 Header 的副作用。
  4. performUnitOfWork 回溯,执行 completeWork(App)
    • 关键点来了!
    • completeWork 处理 FunctionComponent 时,React 会检查这个组件是否有挂载阶段的副作用。
    • 它会调用 commitBeforeMutationEffects
    • 在这个阶段,React 会把 useEffect 的回调函数放入一个队列。
  5. 任务结束,进入 commit 阶段。

结论useEffect 的回调函数是在 自下而上遍历 完成之后,进入 commit 阶段之前,或者在 completeWork 的特定逻辑中被收集起来的。


第五章:深入剖析 —— 为什么非得这么折腾?

你可能要问:“老师,我直接在 render 里写 document.createElement 不行吗?非得搞个 Fiber 树,再搞个 completeWork?”

这就好比你点外卖。

  • 传统方式(同步渲染):你下单,厨师立马炒菜,炒完直接端给你。如果菜没熟,你还得等着。
  • React Fiber 方式(异步渲染)
    1. beginWork(下单):你告诉老板你要吃宫保鸡丁。老板记下来了,然后告诉配菜员切花生米。
    2. completeWork(做菜):配菜员切完花生米,回来告诉老板。老板接着炒菜,炒完告诉你。
    3. Commit(上菜):最后,服务员把菜端到你桌上。

为什么要分两步?

  1. 中断与恢复:如果老板正在炒菜(completeWork),突然你手机响了(用户点击了另一个 Tab)。React 可以暂停当前的 Fiber 处理,保存现场,等会儿再接着炒。
  2. 副作用隔离:子组件的副作用(比如 useEffect)必须等子组件完全渲染好(DOM 插入成功)之后才能执行。completeWork 的自下而上逻辑保证了这一点。

第六章:代码实战 —— 跟踪一个挂载过程

让我们写一段代码,然后假装我们在读源码。

function Parent() {
  console.log('Parent render');
  return (
    <Child />
  );
}

function Child() {
  console.log('Child render');
  return <div>Hello</div>;
}

执行序列(模拟 Fiber 调度):

  1. 调度器启动

    • performUnitOfWork(root) -> beginWork(Root)
    • RootFunctionComponent -> 调用 Parent.render -> reconcileChildren 创建 Parent 的子 Fiber (Child 节点)。
    • 输出:Parent render
  2. 递归向下

    • performUnitOfWork(Parent) -> beginWork(Parent) -> 返回 Child 节点。
    • performUnitOfWork(Child) -> beginWork(Child) -> 调用 Child.render -> reconcileChildren 创建 div 节点。
    • 输出:Child render
  3. 递归到底

    • performUnitOfWork(div) -> beginWork(div) -> divHostComponent。它没有子节点(div 下面没有子组件,只有文本节点,文本节点也是叶子节点)。
    • performUnitOfWork(div) -> completeUnitOfWork(div) -> 调用 completeWork(div)
    • completeWork(div) -> createInstance('div') -> 创建真实 DOM。
    • completeWork(div) -> appendChild -> 把 DOM 插入到父节点。
  4. 回溯向上

    • performUnitOfWork(div) -> 没有兄弟节点 -> 回溯到 Child
    • performUnitOfWork(Child) -> completeUnitOfWork(Child)
    • completeWork(Child) -> 处理 Child 的副作用(如果有 useEffect,在这里收集)。
    • 没有兄弟节点 -> 回溯到 Parent
  5. 继续回溯

    • performUnitOfWork(Parent) -> completeUnitOfWork(Parent)
    • completeWork(Parent) -> 处理 Parent 的副作用。
    • 没有兄弟节点 -> 回溯到 Root
  6. 结束

    • performUnitOfWork(Root) -> 结束。

最终输出顺序:
Parent render -> Child render -> (DOM 创建) -> (副作用处理) -> 页面渲染。


第七章:useEffect 的挂载细节(进阶)

这可能是最让人困惑的部分。为什么 useEffect 里的代码是在 render 之后,但在 commit 阶段才执行?

React 为了保证 DOM 已经挂载,做了一个非常巧妙的双阶段设计。

  1. Phase 1: Render Phase (beginWork)

    • 这个阶段是纯计算,不涉及 DOM 操作。
    • 这里会计算哪些节点需要更新,哪些需要插入。
    • 这里会调用 useEffectcleanup 函数(如果是更新)。
  2. Phase 2: Commit Phase (commitWork)

    • 这个阶段是真正的 DOM 操作。
    • completeWork 在这里被调用(虽然源码里 completeWork 负责创建 DOM,但副作用列表的执行是 commitEffectList)。
    • commit 的开始阶段,React 会遍历 Effect List(副作用列表)。

Effect List 是怎么来的?
completeWork 处理 HostComponent(如 div)时,React 会把这个节点的 effectTag(比如 Placement)标记下来。
然后,在 completeWork 处理 FunctionComponent 时,React 会把这个组件的副作用收集起来。

自下而上构建 Effect List
completeWork(div) 执行时,它会把 div 的 effectTag 加入列表。
completeWork(Child) 执行时,它会把自己的 effectTag 加进去。
completeWork(Parent) 执行时,它会把自己的 effectTag 加进去。

最终在 Commit 阶段
React 从下往上遍历这个列表(或者利用双缓冲树进行遍历),先执行子组件的 useEffect,再执行父组件的 useEffect

这就是为什么:

function Parent() {
  useEffect(() => console.log('Parent'));
  return <Child />;
}
function Child() {
  useEffect(() => console.log('Child'));
  return <div />;
}
// 输出顺序必然是:Child -> Parent

第八章:beginWork 中的 Diff 算法(Reconciliation)

beginWork 不仅仅是创建节点,它还负责复用节点。这也就是传说中的 Diff 算法。

beginWork 遇到一个已知节点(currentFiber.alternate 存在)时,它会调用 reconcileChildren

function reconcileChildren(returnFiber, currentFiber, nextChildren) {
  // 如果没有旧 Fiber,说明是初次挂载,直接把 nextChildren 当作 children
  if (!currentFiber) {
    // ... 创建新节点
    return;
  }

  // 如果有旧 Fiber,说明是更新
  // 简单起见,这里假设 nextChildren 是个数组
  const newChildren = isArray(nextChildren) ? nextChildren : [nextChildren];

  let resultingFirstChild = null;
  let previousNewFiber = null;

  for (let i = 0; i < newChildren.length; i++) {
    const child = newChildren[i];

    // 核心逻辑:根据 key 和 type 判断能不能复用
    const tag = child.type; // 比如 'div', 'span'

    // 如果类型变了,说明是删除旧节点,创建新节点
    if (tag !== currentFiber.type) {
      // 创建新 Fiber
      const newFiber = createFiber(child.type, child.props);
      newFiber.return = returnFiber;
      resultingFirstChild = newFiber;
      break; // 简化逻辑:这里只处理第一个不匹配的
    } else {
      // 类型没变,复用!
      const existingFiber = currentFiber.sibling; // 找兄弟节点
      currentFiber = existingFiber;

      // 更新 props
      newFiber = updateFiber(existingFiber, child.props);
      newFiber.return = returnFiber;
      previousNewFiber.sibling = newFiber;
      previousNewFiber = newFiber;
    }
  }

  return resultingFirstChild;
}

这个逻辑在 beginWork 里起到了什么作用?
它决定了 beginWork 到底是返回一个新节点(如果是新增),还是复用一个旧节点(如果是更新)。


第九章:completeWork 中的副作用标记

现在我们回到 completeWork。当 beginWork 完成了“分配”和“创建/复用”任务,返回了 nextFiber 之后,completeWork 就要负责把这些节点变成真正的 DOM,并打上“标签”。

Placement(插入)
如果一个节点在 beginWork 阶段被创建了(newFiber),它肯定没出现在 current 树里。completeWork 会给它打上 Placement 标记。
commit 阶段,React 会根据这个标记,把节点插入到父节点的 appendChild 里。

Update(更新)
如果一个节点在 beginWork 阶段被复用了,说明它的 props 变了。completeWork 会给它打上 Update 标记。
commit 阶段,React 会根据这个标记,调用 patchProp 修改 DOM 属性。

Deletion(删除)
虽然 completeWork 主要处理挂载和更新,但删除逻辑也贯穿其中。当 beginWork 发现 current 树里有一个节点在 nextChildren 里找不到时,就会打上 Deletion 标记。


第十章:总结与升华

好了,同学们,让我们把这一大段代码逻辑串联起来。

React 的挂载过程,本质上就是一场精心编排的舞蹈

  1. 指挥家(Scheduler) 拿着乐谱(Fiber 树),指挥棒一挥。
  2. 递归者(beginWork 开始自上而下的跑位。
    • 它告诉父节点:“你去渲染你自己。”
    • 它告诉子节点:“你去渲染你自己。”
    • 它负责创建新的 Fiber 节点,或者复用旧的节点。
    • 它负责调用 render 函数。
  3. 执行者(completeWork 开始自下而上的跑位。
    • 当递归者跑到了舞台边缘(叶子节点),执行者登场。
    • 它把 Fiber 节点翻译成真实的 DOM 元素(createInstance)。
    • 它把 DOM 元素挂载到舞台(父节点)上。
    • 它收集副作用(useEffect),打上标签(Placement, Update)。
  4. 清洁工(commit 最后登场,根据标签,把所有改动一次性应用到页面上。

这种自上而下(逻辑构建)与自下而上(副作用执行)的结合,既保证了逻辑的连贯性,又保证了 DOM 操作的原子性。

最后送给大家一句 React 源码里的名言:

“We don’t do everything at once. We do a little bit, and then we pause.”

这就是 Fiber 的魅力,也是 React 能够在复杂的交互中保持流畅的秘密武器。希望大家在下次写 React 组件时,脑子里能浮现出那个 Fiber 节点在树上攀爬和回溯的画面。

下课!

发表回复

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