嘿,各位前端界的“搬砖工”们,大家好!
欢迎来到今天的技术讲座。今天我们不聊那些花里胡哨的 Hooks,也不聊那个还没出来的 React 19,咱们要钻进 React 内核最核心、最硬核的地方——completeWork 阶段。
如果你觉得 React 的 render 阶段是“画图纸”和“定计划”,那 completeWork 就是真正的“干脏活累活”。如果说 beginWork 是那个戴着眼镜、拿着清单的工长,那 completeWork 就是那个满身油污、拿着扳手和锤子,在工地上把活儿干完的包工头。
今天,我们就来扒开这个包工头的衣服(源码),看看他是怎么创建 DOM 节点、更新属性,以及怎么把副作用标志像滚雪球一样冒泡上来的。
准备好了吗?我们要开始“修仙”了。
第一部分:completeWork 是个啥?
在 React 的 Fiber 架构里,整个协调过程就像是一个庞大的工厂。
beginWork:这是“计划阶段”。我们从根节点开始,根据workInProgress(工作指针),判断该干什么活儿。比如,这是个div,那就创建一个div的 Fiber 节点;这是个span,那就创建一个span的 Fiber 节点。在这个过程中,我们会根据新旧 Fiber 的差异,决定给这个节点打上什么“Flag”(副作用标记)。completeWork:这是“完成阶段”。beginWork把子节点都递归完了,现在轮到我们了。我们需要拿着workInProgress(也就是新节点),去处理它。具体来说,就是创建真实的 DOM,把属性挂上去,把子节点的副作用收集起来。
简单点说,beginWork 负责生成 Fiber 树,completeWork 负责把 Fiber 树翻译成 DOM 树。
第二部分:创建 DOM 节点——从 Fiber 到 DOM
当 beginWork 完成后,你的 workInProgress 节点已经准备好了,它的 type(比如 'div')和 tag(HostComponent)都已经定好了。现在,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;
}
// ...
}
}
代码解析:
createInstance:这就像document.createElement('div')。React 会根据type拿到对应的 DOM 构造函数。比如遇到'div',它就调用divConstructor。appendAllChildren:这是递归地干活。如果你的div里面有个span,那我们得先把这个span的 DOM 创建好,然后塞到div里面去。finalizeInitialChildren:这不仅仅是设置className或id,它还会处理一些特殊的属性,比如onMouseEnter、onMouseLeave(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;
}
}
}
}
}
代码解析:
- 事件监听器的处理:React 在
completeWork阶段并不会直接给 DOM 挂载成百上千个addEventListener。相反,它会收集变更,生成一个updatePayload。这个 Payload 包含了需要添加的事件和需要移除的事件。这样在提交阶段,React 可以批量处理这些事件,性能炸裂。 classNamevsclass:React 内部统一使用className,但在updateProperties里,它会检查浏览器是否支持这个属性(比如老旧浏览器),如果不支持,它可能会偷偷帮你转成class。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(水合)阶段。
源码逻辑概览:
在 completeWork 的 HostComponent 分支里,有一行非常关键的代码:
if (current === null || current.memoizedProps !== newProps) {
workInProgress.flags |= Update;
}
代码解析:
currentvsworkInProgress:current是“旧树”,workInProgress是“新树”。memoizedProps:这是旧树里节点保存的属性快照。newProps:这是新树里节点要挂载的属性。
如果 current.memoizedProps !== newProps,说明属性变了!React 就会把 workInProgress 节点的 flags 加上 Update。
幽默一下:
Flags 就像是学校里的“通知单”。beginWork 阶段,老师(React)给每个学生发了一张通知单,说“这个学生要升班了(Placement)”、“这个学生作业没写完(Update)”。但是,老师还要等所有学生都发完单子之后,再汇总到班长那里。completeWork 就是那个汇总的过程。
第五部分:副作用冒泡——从儿子到爸爸
这是 completeWork 最核心的魔法。你想想,如果我的 div 里的 span 变了,我的 div 会不会变?
当然会!因为 span 是 div 的子节点,div 的 innerHTML 改了,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;
}
代码解析:
nextEffect链表:React 维护了一个链表,用于在提交阶段遍历需要执行副作用的节点。completeWork会构建这个链表。- 合并 Flags:
subtreeFlags |= child.flags。这句代码是灵魂。子节点说:“我有Placement(新增)的副作用。” 父节点一听,说:“行,那我也有Placement副作用了。” - 为什么这么麻烦?
- 提交阶段效率:提交阶段从根节点开始遍历。如果父节点不知道自己有副作用,它就不会进入提交逻辑,导致子节点的 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。 - 属性更新:
updateProperties把className设置为'container'。 - 子节点处理:
div的child指向span。- 递归调用
completeWork(span)。
- 副作用合并:
- 假设
span有Placement标志(新增)。 div的subtreeFlags拿到Placement。div.flags也加上Placement。
- 假设
2. span (HostComponent) 的 completeWork:
- 创建 DOM:调用
document.createElement('span')。stateNode指向这个span。 - 属性更新:
span没有额外属性,跳过。 - 子节点处理:
span没有子节点,结束。 - 副作用合并:
span没有子节点,结束。
3. completeWork 的返回:
completeWork(div)返回到completeWork(App)。App是HostRoot,它的completeWork会把div的副作用收集起来,挂载到App的nextEffect链表上。
4. 提交阶段:
- React 从
App的nextEffect链表开始遍历。 - 遇到
App,发现它有副作用(虽然它自己没变,但它是根节点)。 - 遍历到
div,发现div有Placement标志。- 动作:把
div的stateNode(DOM)挂载到rootContainerInstance(#root)。
- 动作:把
- 遍历到
span,发现span有Placement标志。- 动作:把
span的stateNode(DOM)插入到div的stateNode里。
- 动作:把
第七部分:深入 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;
}
代码解析:
createTextInstance:就是document.createTextNode('Hello')。memoizedProps:文本节点的 props 通常就是children的值。- 对比:如果新文本是 “Hi”,旧文本是 “Hello”,那就标记
Update。在提交阶段,React 就会更新nodeValue。
第九部分:useEffect 和 useLayoutEffect 的标记
这是 completeWork 里比较高级的部分。这两个 Hook 的执行时机不同。
useLayoutEffect:同步执行,在 DOM 变更后、浏览器绘制前。它需要被同步执行,所以它的标记必须在completeWork阶段就处理掉。useEffect:异步执行,在浏览器绘制后。它需要被异步执行,所以它的标记通常在commit阶段处理。
源码逻辑:
在 completeWork 的 HostComponent 分支里,如果发现节点上有 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 就像保洁阿姨,等油漆干了,家具摆好了,她再进来扫地。
第十部分:Fragment 和 Portal 的特殊处理
React 还有两个特殊的 Fiber 类型:Fragment 和 Portal。
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 的灵魂所在。
- 翻译官:把 Fiber 节点翻译成真实的 DOM 节点。
- 执行者:把属性挂载到 DOM 上,把事件监听器准备好。
- 汇总者:把子节点的副作用标记收集上来,合并到父节点上。
- 构建者:构建
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 大神们的代码魅力。谢谢大家!