React completeWork 阶段源码:解析 DOM 节点的创建、属性更新及副作用标志(Flags)的冒泡

嘿,各位前端界的“搬砖工”们,大家好!

欢迎来到今天的技术讲座。今天我们不聊那些花里胡哨的 Hooks,也不聊那个还没出来的 React 19,咱们要钻进 React 内核最核心、最硬核的地方——completeWork 阶段

如果你觉得 React 的 render 阶段是“画图纸”和“定计划”,那 completeWork 就是真正的“干脏活累活”。如果说 beginWork 是那个戴着眼镜、拿着清单的工长,那 completeWork 就是那个满身油污、拿着扳手和锤子,在工地上把活儿干完的包工头。

今天,我们就来扒开这个包工头的衣服(源码),看看他是怎么创建 DOM 节点更新属性,以及怎么把副作用标志像滚雪球一样冒泡上来的。

准备好了吗?我们要开始“修仙”了。


第一部分:completeWork 是个啥?

在 React 的 Fiber 架构里,整个协调过程就像是一个庞大的工厂。

  1. beginWork:这是“计划阶段”。我们从根节点开始,根据 workInProgress(工作指针),判断该干什么活儿。比如,这是个 div,那就创建一个 div 的 Fiber 节点;这是个 span,那就创建一个 span 的 Fiber 节点。在这个过程中,我们会根据新旧 Fiber 的差异,决定给这个节点打上什么“Flag”(副作用标记)。
  2. completeWork:这是“完成阶段”。beginWork 把子节点都递归完了,现在轮到我们了。我们需要拿着 workInProgress(也就是新节点),去处理它。具体来说,就是创建真实的 DOM把属性挂上去把子节点的副作用收集起来

简单点说,beginWork 负责生成 Fiber 树,completeWork 负责把 Fiber 树翻译成 DOM 树。


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

beginWork 完成后,你的 workInProgress 节点已经准备好了,它的 type(比如 'div')和 tagHostComponent)都已经定好了。现在,completeWork 需要把它变成真正的浏览器元素。

completeWork 的核心逻辑里,首先会判断 workInProgress.type 是什么。如果是 HostComponent(也就是普通的 HTML 元素),那我们就要创建 DOM。

源码逻辑概览:

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  const newProps = workInProgress.pendingProps;

  // 根据不同的 Tag 分发不同的逻辑
  switch (workInProgress.tag) {
    // ... 其他情况省略
    case HostComponent: {
      const type = workInProgress.type;
      // 关键点 1:创建 DOM 实例
      const instance = createInstance(
        type,
        newProps,
        rootContainerInstance,
        workInProgress,
        hydrateInstance,
      );

      // 关键点 2:挂载子节点
      // 我们把 DOM 实例挂载到 workInProgress.stateNode 上
      // 这样后续提交阶段就可以直接用这个 DOM 了
      appendAllChildren(instance, workInProgress, false, false);

      // 关键点 3:更新属性
      // 这一步很关键,不能等提交阶段再挂属性,因为属性更新涉及一些副作用
      finalizeInitialChildren(
        instance,
        type,
        newProps,
        rootContainerInstance,
      );

      workInProgress.stateNode = instance;

      // 如果不是 hydrate 模式,或者需要更新 flags,走这里
      if (current === null || current.memoizedProps !== newProps) {
        workInProgress.flags |= Update;
      }

      return null;
    }
    // ...
  }
}

代码解析:

  1. createInstance:这就像 document.createElement('div')。React 会根据 type 拿到对应的 DOM 构造函数。比如遇到 'div',它就调用 divConstructor
  2. appendAllChildren:这是递归地干活。如果你的 div 里面有个 span,那我们得先把这个 span 的 DOM 创建好,然后塞到 div 里面去。
  3. finalizeInitialChildren:这不仅仅是设置 classNameid,它还会处理一些特殊的属性,比如 onMouseEnteronMouseLeave(React 为了性能做了合并处理,不像原生事件那么多),还有 suppressContentEditableWarning 这种神仙属性。

幽默一下:
你可以把 createInstance 想象成“盖房子”。beginWork 是画图纸(设计图),说:“我要盖个别墅”。completeWork 就是去工地,搬砖、砌墙、搭架子,最后真的把房子盖起来。这房子就是 workInProgress.stateNode,也就是那个真实的 DOM 节点。


第三部分:属性更新——updateProperties 的玄机

DOM 节点建好了,接下来就是装修了。这可不是简单的 element.className = 'foo'。React 必须极其高效地知道哪些属性变了,哪些没变,哪些需要删掉,哪些需要加上。

在 React 的源码里,updateProperties(在 HostComponent 里)是核心。

源码逻辑概览:

function updateProperties(
  domElement: Instance,
  updatePayload: Array<any> | null,
  oldProps: Props,
  newProps: Props,
  rootContainerInstance: Container,
) {
  if (updatePayload != null) {
    // 1. 处理事件监听器
    // React 把事件监听器单独拎出来处理,因为原生 addEventListener 比较重
    // 而且要处理 passive events
    const listeners = updatePayload[0];
    if (listeners != null) {
      // 这里的逻辑比较复杂,涉及 passive listeners 的判断
      // 简化版理解:把 newProps 里的 onXxx 事件挂载上去
    }

    // 2. 处理普通属性
    for (let i = 1; i < updatePayload.length; i += 2) {
      const propKey = updatePayload[i];
      const propValue = updatePayload[i + 1];

      // 3. 具体的 DOM 属性更新
      // 比如 className, id, style, dir, lang 等
      if (propKey === STYLE) {
        // 处理 style 对象
        applyValueForStyle(domElement, propValue);
      } else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
        // 处理 innerHTML
        const html = propValue;
        if (html != null) {
          setInnerHTML(domElement, html);
        }
      } else if (propKey === AUTOFOCUS) {
        // 处理 autoFocus
        if (propValue) {
          autoFocus(domElement);
        }
      } else {
        // 4. 其他属性
        // className vs class (浏览器兼容)
        // 其他 HTML 属性
        if (propKey === CLASSNAME) {
           // 转换成 class
           domElement.className = propValue;
        } else {
           domElement[propKey] = propValue;
        }
      }
    }
  }
}

代码解析:

  1. 事件监听器的处理:React 在 completeWork 阶段并不会直接给 DOM 挂载成百上千个 addEventListener。相反,它会收集变更,生成一个 updatePayload。这个 Payload 包含了需要添加的事件和需要移除的事件。这样在提交阶段,React 可以批量处理这些事件,性能炸裂。
  2. className vs class:React 内部统一使用 className,但在 updateProperties 里,它会检查浏览器是否支持这个属性(比如老旧浏览器),如果不支持,它可能会偷偷帮你转成 class
  3. style 对象:React 不会直接把 style={{ color: 'red' }} 赋值给 DOM 的 style 属性(因为那样会覆盖行内样式)。它会调用 applyValueForStyle,把对象展开成 element.style.color = 'red'。而且,React 会处理 style 的单位,比如数字 1 不会变成 1px,而是 1,除非是 fontSize 这种必须带单位的。

幽默一下:
属性更新就像给装修好的房子刷漆。你不会每刷一笔就停下来擦一次,你会先配好所有颜色的漆,然后一次性刷完。updatePayload 就是那个调色盘。如果老房子(旧 DOM)和新图纸(新 Props)颜色一样,那就不刷了,省墨水!


第四部分:副作用标志——Flags 的那些事儿

这是 React 源码里最让人头秃,但也最有趣的地方。Flag 就是 React 给每个 Fiber 节点贴的“小纸条”。

你可能会问:“我在 beginWork 的时候不是已经判断过差异了吗?为什么还要在这里再定一遍?”

因为 beginWork 是自上而下的,而 completeWork 是自下而上的。子节点变化了,必须告诉父节点:“嘿,我变样了,你得跟着变!”

常见的 Flags:

  • Placement (位移/新增):这个节点是新增的,或者是移动位置的。
  • Update (更新):这个节点的属性变了。
  • Deletion (删除):这个节点要被干掉了。
  • Ref (引用):这个节点挂载了 ref 回调,需要执行。
  • Snapshot (快照):用于 hydration(水合)阶段。

源码逻辑概览:

completeWorkHostComponent 分支里,有一行非常关键的代码:

if (current === null || current.memoizedProps !== newProps) {
  workInProgress.flags |= Update;
}

代码解析:

  1. current vs workInProgresscurrent 是“旧树”,workInProgress 是“新树”。
  2. memoizedProps:这是旧树里节点保存的属性快照。
  3. newProps:这是新树里节点要挂载的属性。

如果 current.memoizedProps !== newProps,说明属性变了!React 就会把 workInProgress 节点的 flags 加上 Update

幽默一下:
Flags 就像是学校里的“通知单”。beginWork 阶段,老师(React)给每个学生发了一张通知单,说“这个学生要升班了(Placement)”、“这个学生作业没写完(Update)”。但是,老师还要等所有学生都发完单子之后,再汇总到班长那里。completeWork 就是那个汇总的过程。


第五部分:副作用冒泡——从儿子到爸爸

这是 completeWork 最核心的魔法。你想想,如果我的 div 里的 span 变了,我的 div 会不会变?

当然会!因为 spandiv 的子节点,divinnerHTML 改了,div 本身不需要变(属性没变),但它必须知道自己的子节点变了,以便在提交阶段执行正确的 DOM 操作。

这个过程就叫副作用冒泡

源码逻辑概览:

completeWork 的最后,有一个 bubbleEffects 函数(或者直接在 completeWork 的逻辑里处理):

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  // ... 之前的创建 DOM 和更新属性逻辑 ...

  // 关键点:副作用冒泡
  if (workInProgress.subtreeFlags !== NoFlags) {
    // 如果有子节点的副作用,我们需要处理
    bubbleCompositeEffects(workInProgress);
  }

  return null;
}

function bubbleCompositeEffects(workInProgress: Fiber) {
  // 这是一个递归函数,从子节点向父节点遍历
  let subtreeFlags = NoFlags;

  // 1. 先处理子节点
  // 我们要遍历 workInProgress 的子节点(如果有)
  // 注意:这里不是直接遍历 children,而是遍历 effect list
  // React 维护了一个 nextEffect 指针链表
  let child = workInProgress.child;

  while (child !== null) {
    // 2. 合并子节点的 flags 到父节点
    // 这就是“冒泡”的本质
    subtreeFlags |= child.flags;

    // 3. 如果子节点有副作用,继续递归
    if (child.subtreeFlags !== NoFlags) {
      bubbleCompositeEffects(child);
    }

    child = child.sibling;
  }

  // 4. 将合并后的 flags 写回父节点
  workInProgress.flags |= subtreeFlags;
}

代码解析:

  1. nextEffect 链表:React 维护了一个链表,用于在提交阶段遍历需要执行副作用的节点。completeWork 会构建这个链表。
  2. 合并 FlagssubtreeFlags |= child.flags。这句代码是灵魂。子节点说:“我有 Placement(新增)的副作用。” 父节点一听,说:“行,那我也有 Placement 副作用了。”
  3. 为什么这么麻烦?
    • 提交阶段效率:提交阶段从根节点开始遍历。如果父节点不知道自己有副作用,它就不会进入提交逻辑,导致子节点的 DOM 变更无法生效。
    • Ref 处理:如果一个子节点有 Ref,它的父节点可能没有。但父节点必须把 Ref 的处理逻辑传递下去(或者由父节点统一处理子节点的 Ref)。

幽默一下:
想象一下,你在公司群里发通知。

  • beginWork:每个人(Fiber 节点)自己看自己的任务单。
  • completeWork:每个部门经理(父节点)去问下属:“你搞定了吗?搞定了吗?”,然后汇总到总监,总监再汇总到 CEO。
  • 如果下属说“搞定啦!”,总监必须记下来“搞定啦!”,因为 CEO 只看总监的报告。

第六部分:实战演练——追踪一个 <div><span>Hi</span></div>

为了让大家彻底明白,我们来追踪一下这个组件的 completeWork 过程。

组件:

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

1. div (HostComponent) 的 completeWork

  • 创建 DOM:调用 document.createElement('div')stateNode 指向这个 div
  • 属性更新updatePropertiesclassName 设置为 'container'
  • 子节点处理
    • divchild 指向 span
    • 递归调用 completeWork(span)
  • 副作用合并
    • 假设 spanPlacement 标志(新增)。
    • divsubtreeFlags 拿到 Placement
    • div.flags 也加上 Placement

2. span (HostComponent) 的 completeWork

  • 创建 DOM:调用 document.createElement('span')stateNode 指向这个 span
  • 属性更新span 没有额外属性,跳过。
  • 子节点处理span 没有子节点,结束。
  • 副作用合并span 没有子节点,结束。

3. completeWork 的返回:

  • completeWork(div) 返回到 completeWork(App)
  • AppHostRoot,它的 completeWork 会把 div 的副作用收集起来,挂载到 AppnextEffect 链表上。

4. 提交阶段:

  • React 从 AppnextEffect 链表开始遍历。
  • 遇到 App,发现它有副作用(虽然它自己没变,但它是根节点)。
  • 遍历到 div,发现 divPlacement 标志。
    • 动作:把 divstateNode(DOM)挂载到 rootContainerInstance#root)。
  • 遍历到 span,发现 spanPlacement 标志。
    • 动作:把 spanstateNode(DOM)插入到 divstateNode 里。

第七部分:深入 updateProperties —— 属性更新的细节

咱们刚才说了 updateProperties 的骨架,现在咱们来点血肉。React 在处理属性时,有很多“小心机”。

1. style 属性的特殊处理

React 不会直接把对象赋值给 DOM 的 style 属性,因为那样会覆盖行内样式(比如通过内联样式写死的 style={{ width: '100px' }} 就失效了)。

源码里是这样的逻辑:

if (propKey === STYLE) {
  // React 会遍历 style 对象的每一个键值对
  // 比如 { color: 'red', fontSize: 14 }
  // 最终生成 'color: red; font-size: 14px;'
  // 注意:React 会自动把数字转成带单位的字符串(除了 0)
  const styleUpdates = {};
  for (const styleName in newProps) {
    if (newProps[styleName] != null) {
      // 这里还有一堆逻辑处理 style 的特殊属性
      // 比如 'will-change', 'backface-visibility' 等
      styleUpdates[styleName] = newProps[styleName];
    }
  }
  // 最后赋值
  domElement.style.cssText = cssTextFromObject(styleUpdates);
}

2. dangerouslySetInnerHTML

这是 React 里唯一一个允许你直接操作 HTML 字符串的属性。它通常用于防止 XSS 攻击,但也需要开发者小心。

if (propKey === DANGEROUSLY_SET_INNER_HTML) {
  const html = propValue;
  if (html != null) {
    setInnerHTML(domElement, html);
  }
}

3. children vs children 数组

updateProperties 里,React 会检查 newProps.children。如果 children 是一个数组(比如 children={[<span>1</span>, <span>2</span>]}),React 会把它转换成一个文本节点数组,然后通过 appendChild 一个一个挂上去。

幽默一下:
updateProperties 就像个精细的化妆师。它不直接往脸上涂粉底(赋值 style),而是先调色、打底、修饰。对于 style,它保证颜色鲜艳(单位正确);对于 innerHTML,它保证不会把脸涂成绿色(防止 XSS)。它比你自己乱涂乱画要靠谱得多。


第八部分:HostText 节点的处理

除了 DOM 节点,React 还要处理文本节点。比如 <div>Hello</div> 里的 Hello

completeWork 里,有一个 HostText 的分支:

case HostText: {
  const textInstance = createTextInstance(
    newProps,
    rootContainerInstance,
    workInProgress,
    hydrateTextInstance,
  );

  workInProgress.stateNode = textInstance;

  // 如果是更新模式,且文本内容变了
  if (current !== null && current.memoizedProps !== newProps) {
    workInProgress.flags |= Update;
  }

  return null;
}

代码解析:

  1. createTextInstance:就是 document.createTextNode('Hello')
  2. memoizedProps:文本节点的 props 通常就是 children 的值。
  3. 对比:如果新文本是 “Hi”,旧文本是 “Hello”,那就标记 Update。在提交阶段,React 就会更新 nodeValue

第九部分:useEffectuseLayoutEffect 的标记

这是 completeWork 里比较高级的部分。这两个 Hook 的执行时机不同。

  1. useLayoutEffect:同步执行,在 DOM 变更后、浏览器绘制前。它需要被同步执行,所以它的标记必须在 completeWork 阶段就处理掉。
  2. useEffect:异步执行,在浏览器绘制后。它需要被异步执行,所以它的标记通常在 commit 阶段处理。

源码逻辑:

completeWorkHostComponent 分支里,如果发现节点上有 ref 或者 useLayoutEffect 的标记,React 会做特殊处理:

// 检查是否有 ref
if (workInProgress.ref !== null) {
  // ... 处理 ref 回调
  workInProgress.flags |= Ref;
}

// 检查是否有 useLayoutEffect
// React 会把这些副作用挂载到 workInProgress 的 nextEffect 链表上
// 这样在 commit 阶段,React 会按照顺序执行这些 effect

冒泡逻辑:

和 DOM 节点一样,useLayoutEffect 的标记也会冒泡。如果子组件有 useLayoutEffect,父组件的 nextEffect 链表里也会包含子组件的 effect。React 会按照 先子后父 的顺序执行这些 effect。

幽默一下:
useLayoutEffect 就像装修队,必须在油漆还没干之前就把柜子摆好,不能让油漆流到柜子上。useEffect 就像保洁阿姨,等油漆干了,家具摆好了,她再进来扫地。


第十部分:FragmentPortal 的特殊处理

React 还有两个特殊的 Fiber 类型:FragmentPortal

1. Fragment

<React.Fragment>...</React.Fragment> 在 DOM 里是不存在的。所以 completeWork 在处理 Fragment 时,不会创建 DOM 节点。

case Fragment: {
  // 直接遍历子节点
  const childLength = workInProgress.child?.length ?? 0;
  if (childLength !== 0) {
    // 把子节点的副作用冒泡上来
    for (let i = 0; i < childLength; i++) {
      bubbleCompositeEffects(workInProgress.child);
    }
  }
  return null;
}

2. Portal

Portal 是把 DOM 渲染到组件树之外的地方(比如 #root 外的 div)。

case Portal: {
  // Portal 的 stateNode 是一个 DOM 节点(容器)
  // 它的 children 是要渲染的内容
  // React 会把 Portal 的子节点的 DOM 挂载到 stateNode 上
  // 但这个挂载过程通常在 commit 阶段处理
}

幽默一下:
Fragment 就像快递盒里的泡沫填充物,虽然看不见,但必不可少,用来把里面的东西固定住。Portal 就像是个传送门,把东西从 A 地传到了 B 地。


第十一部分:completeWork 的递归与栈溢出

你可能注意到了,completeWork 主要是递归函数。如果 React 树非常深(比如几千层嵌套),会不会导致栈溢出?

答案是:不会。

React 的 Fiber 架构设计就是为了解决这个问题。completeWork 虽然是递归写的,但 React 会在 Fiber 树构建好之后,通过 迭代 的方式来遍历树。

但是,为了代码的可读性和逻辑的清晰,completeWork 的源码内部依然使用了递归。React 依靠 workInProgress 指针的移动来模拟栈帧的进出。


第十二部分:总结——completeWork 的灵魂

好了,咱们来总结一下 completeWork 的灵魂所在。

  1. 翻译官:把 Fiber 节点翻译成真实的 DOM 节点。
  2. 执行者:把属性挂载到 DOM 上,把事件监听器准备好。
  3. 汇总者:把子节点的副作用标记收集上来,合并到父节点上。
  4. 构建者:构建 nextEffect 链表,为提交阶段做准备。

为什么我们需要 completeWork
如果 completeWork 不做这些事,beginWork 只是个空架子。DOM 节点不会生成,属性不会更新,子节点的变化也不会通知父节点。React 的整个渲染流程就会瘫痪。

代码示例回顾(核心流程):

function completeWork(current, workInProgress, renderLanes) {
  const newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    case HostComponent: {
      // 1. 创建 DOM
      const instance = createInstance(
        workInProgress.type,
        newProps,
        rootContainerInstance,
        workInProgress,
        hydrateInstance,
      );

      workInProgress.stateNode = instance;

      // 2. 挂载子节点
      appendAllChildren(instance, workInProgress, false, false);

      // 3. 更新属性
      finalizeInitialChildren(
        instance,
        workInProgress.type,
        newProps,
        rootContainerInstance,
      );

      // 4. 标记副作用(如果有差异)
      if (current === null || current.memoizedProps !== newProps) {
        workInProgress.flags |= Update;
      }

      // 5. 副作用冒泡
      bubbleCompositeEffects(workInProgress);

      return null;
    }
    // ... 其他 case
  }
}

这就是 React completeWork 的完整源码解析。它充满了细节,充满了逻辑,但也充满了智慧。理解了 completeWork,你就理解了 React 是如何从抽象的树变成真实的页面的。

下次当你看到 React 报错说“Something went wrong”或者性能优化时,记得,这一切的背后,都是 completeWork 在默默地搬砖,默默地合并 flags,默默地为你构建那个美丽的 DOM 世界。

好了,今天的讲座就到这里。大家记得去源码里看看 ReactFiberCompleteWork.js,感受一下 React 大神们的代码魅力。谢谢大家!

发表回复

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