09|DOM Renderer 的 Host 层:从 Fiber 到真实 DOM 的落地

09|DOM Renderer 的 Host 层:从 Fiber 到真实 DOM 的落地

本栏目是「React 源码剖析」系列:我会以源码为证据、以架构为线索,讲清 React 从运行时到核心算法的关键设计。开源仓库:https://github.com/facebook/react

在第 08 篇我们把 commit 阶段的"分层事务模型"讲清了:

  • BeforeMutation / Mutation / Layout / Passive 的职责边界
  • flags/subtreeFlags + *Mask 的剪枝
  • 以及一个很容易被忽视的分支:commit 也能 suspend(suspensey commit)

这一篇我们把镜头推到更"接地气"的地方:

Commit 阶段的 commitHostPlacement/commitHostUpdate/commitHostTextUpdate 到底是如何落到真实 DOM 上的?

如果你读源码时只盯 Reconciler(react-reconciler),你会看到大量"抽象的 Host 操作"。

真正把抽象变成 appendChild/removeChild/setAttribute/style.xxx 的,是 DOM Renderer 的 HostConfig:

  • packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
  • packages/react-dom-bindings/src/client/ReactDOMComponent.js
  • 以及用于事件系统与 Fiber 映射的 ReactDOMComponentTree.js

本文的目标是:

  • 把 Reconciler 与 Renderer 的"契约边界"讲清楚
  • 把 Placement/Update/Deletion 三类 DOM 变更的关键路径串起来
  • 解释一些你看似"无关"的细节为什么必须存在 (比如 updateFiberPropstrapClickOnNonInteractiveElementsupportsMoveBefore

0) 先立规矩:Reconciler 不知道 DOM,DOM Renderer 不知道 Fiber 算法

React 这套工程分层的核心是:

  • Reconciler 负责"算出变化"(Fiber + flags + lanes + effect lists)
  • Renderer 负责"把变化落到平台对象上"(DOM / RN / ART / custom renderer)

二者通过 HostConfig 连接。

你可以把 HostConfig 看成一份"接口清单",包含:

  • 创建/挂载createInstance/createTextInstance/appendInitialChild/finalizeInitialChildren
  • 更新commitUpdate(DOM 的 props diff 发生在这里)
  • 插入/移动appendChild/insertBefore/appendChildToContainer/insertInContainerBefore
  • 删除removeChild/removeChildFromContainer
  • commit 的前后环境prepareForCommit/resetAfterCommit(选区、事件开关等)

这就解释了一个非常重要的"风格差异":

  • 在 Reconciler 里,你经常只看到 commitHostUpdate(fiber, newProps, oldProps) 这种"抽象动作"。
  • 真正的 DOM 细节(属性名映射、事件处理、style diff、受控组件细节)全部在 DOM bindings 里。

1) render 阶段:HostComponent 什么时候决定"需要 Update"?

文件:packages/react-reconciler/src/ReactFiberCompleteWork.js

HostComponent 的 complete 分支可以概括为两件事:

  • mount:创建 DOM instance、append children、设置初始 props
  • update:在 mutation 模式下 ,只要 oldProps !== newProps,就 markUpdate(workInProgress)

关键点在 updateHostComponent

js 复制代码
function updateHostComponent(current, workInProgress, type, newProps, renderLanes) {
  if (supportsMutation) {
    const oldProps = current.memoizedProps;
    if (oldProps === newProps) return;
    markUpdate(workInProgress);
  }
}

这里非常"粗糙",但这是有意为之:

  • DOM renderer 的更新 diff 不是在 render 阶段算出来的
  • render 阶段只负责:
    • 标记"commit 阶段需要做事"
    • 并确保 commit 遍历不会把它剪掉

也正因为如此,在 DOM 这条实现里你看不到 prepareUpdate 这种"render 时算 diff payload"的路径;

更新的真正 diff 全都发生在 commit 的 commitUpdate 里。


2) Mutation:Placement(插入/重排)如何落到 DOM

Placement 的入口在 commitReconciliationEffects

  • if (flags & Placement) commitHostPlacement(finishedWork);

2.1 commitHostPlacementcommitPlacement

文件:packages/react-reconciler/src/ReactFiberCommitHostEffects.js

commitPlacement 做了三件事:

  1. 向上找 host parent fiber(HostComponent / HostRoot / HostPortal / HostSingleton(scope))
  2. 找到一个插入锚点 before = getHostSibling(finishedWork)
  3. 递归把所有 terminal host nodes 插入到 parent(insertOrAppendPlacementNode*

其中最"值钱"的部分是:

  • 锚点怎么找?
  • 为什么要递归插入?

2.2 getHostSibling:为什么 Placement 会导致"指数搜索"?

getHostSibling 的注释就写出了问题本质:

  • 连续插入多个兄弟时,需要跳过那些也带 Placement 的 fiber
  • 于是必须不断向前找"第一个稳定的 host 节点"作为 before

它做的事情可以用伪码描述:

txt 复制代码
node = fiber
loop:
  向右找 sibling,找不到就向上回溯
  对每个 sibling 子树:
    一路向下找 HostComponent/HostText/DehydratedFragment
    但如果遇到 (flags & Placement) 的节点,说明它还没插入,跳过这棵子树
  找到第一个不带 Placement 的 host 节点,返回其 stateNode

这也是为什么 Placement 在 mutation 阶段会被清掉:

  • 插入完成后,后续阶段如果还依赖 Placement,会得到错误结论。

2.3 DOM HostConfig:appendChild/insertBefore 的细节

文件:packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

你会看到 DOM 对插入动作做了一个"现代浏览器优化":

  • 如果存在 Element.prototype.moveBefore(并且 child 已经有 parentNode),会优先用它
js 复制代码
const supportsMoveBefore = enableMoveBefore && typeof Element.prototype.moveBefore === 'function';

export function appendChild(parentInstance, child) {
  if (supportsMoveBefore && child.parentNode !== null) {
    parentInstance.moveBefore(child, null);
  } else {
    parentInstance.appendChild(child);
  }
}

export function insertBefore(parentInstance, child, beforeChild) {
  if (supportsMoveBefore && child.parentNode !== null) {
    parentInstance.moveBefore(child, beforeChild);
  } else {
    parentInstance.insertBefore(child, beforeChild);
  }
}

这里你可以把 moveBefore 理解成:

  • "同一套 API 同时覆盖 insert 与 move"
  • 并且对某些浏览器实现更快/更稳定

2.4 container 的特殊情况:Document / Comment / HTML

appendChildToContainer/insertInContainerBefore/removeChildFromContainer 会对 container 做分支:

  • Document:插到 document.body
  • HTML:插到 ownerDocument.body
  • Comment(开启 disableCommentsAsDOMContainers 时才允许):把 comment 视为锚点

这解释了一个你在调试时可能遇到的"奇怪 DOM":

  • React root 可能是一个注释节点(unstable mount point)
  • 这会影响插入位置与事件冒泡路径

3) Mutation:Update(属性更新)如何落到 DOM

3.1 commitHostUpdate → HostConfig commitUpdate

文件:

  • packages/react-reconciler/src/ReactFiberCommitHostEffects.js
  • packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

调用链非常直接:
HostComponent.flags & Update
commitHostUpdate
HostConfig.commitUpdate
updateProperties(domElement, type, oldProps, newProps)
updateFiberProps(domElement, newProps)

DOM HostConfig 的实现:

js 复制代码
export function commitUpdate(domElement, type, oldProps, newProps, internalHandle) {
  updateProperties(domElement, type, oldProps, newProps);
  updateFiberProps(domElement, newProps);
}

注意顺序:先更新 DOM 属性,再更新 props handle。

  • 这保证"当前事件处理函数"与"当前 DOM 状态"在 commit 后保持一致

3.2 updateProperties:props diff 的真正发生地

文件:packages/react-dom-bindings/src/client/ReactDOMComponent.js

updateProperties 的结构非常工程化:

  • 先按 tag 分 special cases(input/select/textarea/option/void elements/custom element)
  • 否则走通用 diff:
    • 对 lastProps:删除 nextProps 没有的键(置空)
    • 对 nextProps:对比变更的键(setProp)

伪码:

txt 复制代码
for key in lastProps:
  if lastProps[key] != null && key 不在 nextProps:
    setProp(key, null)

for key in nextProps:
  if nextProp !== lastProp 且(任一不为 null):
    setProp(key, nextProp)

3.3 setProp:为什么 React 要自己维护一份"属性语义表"?

setProp 里包含了大量"键级别"的语义:

  • classNameclass
  • tabIndextabindex
  • stylesetValueForStyles(并带 prevStyles 做 diff)
  • children(string/number/bigint)→ setTextContent
  • URL 类属性 src/hrefsanitizeURL
  • dangerouslySetInnerHTML 的结构校验
  • 一大堆 boolean/overloaded boolean/numeric 的 attribute 规则

你可以把它理解成:

DOM 不是一个"统一的属性字典",而是一堆历史包袱 + 浏览器差异 + 安全策略。

React 选择在 renderer 里集中维护这套规则,换取:

  • 行为稳定
  • 安全默认(例如 URL sanitize)
  • 可对特殊标签做性能/一致性优化

3.4 style diff:为什么 style 更新不走通用 attribute 路径

文件:packages/react-dom-bindings/src/client/CSSPropertyOperations.js

setValueForStyles(node, styles, prevStyles) 里会:

  • 删除 prevStyles 里有、next 没有的属性
  • 对比 nextStyles 中发生变化的属性
  • 每次实际写入都会 trackHostMutation()

这套实现还会在 DEV 做 shorthand/longhand 冲突警告(避免样式 bug)。


4) Mutation:Text 更新与 TextContent Reset

4.1 commitHostTextUpdate

文件:

  • packages/react-reconciler/src/ReactFiberCommitHostEffects.js
  • packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Text 节点更新非常直接:

js 复制代码
export function commitTextUpdate(textInstance, oldText, newText) {
  textInstance.nodeValue = newText;
}

这就是 React 常见的一个"微优化"原则:

  • 有 Text 节点就改 nodeValue
  • 不用 element.textContent = ... 去触发整段重建

4.2 ContentReset:为什么 Placement 前要清空 parent 的文本

commitPlacement 里如果 parent fiber 带 ContentReset

  • resetTextContent(parent)
  • 再做 insert
  • 然后清掉 ContentReset

这是为了解决一种很常见的 DOM 冲突:

  • 上一次渲染 parent 是纯文本(shouldSetTextContent = true
  • 下一次渲染 parent 变成 element children

如果不 reset,会造成"旧 text 节点仍然存在"的脏状态。


5) Mutation:Deletion(删除)与实例清理

文件:packages/react-reconciler/src/ReactFiberCommitWork.js

删除路径的关键点是:

  • 必须先跑卸载副作用、detach refs
  • 但 DOM 节点的真正 remove 要保证只删每条分支最上层 host child(否则重复删)

commitDeletionEffectsOnFiber 在 HostComponent/HostText 分支里,会把 hostParent 暂时置空:

  • 表示"更深层的 host nodes 不要再 remove 了"
  • 等递归卸载完成后,再对当前 host node 做 removeChild

删除的最终落点:

  • commitHostRemoveChildremoveChild(parentInstance, child)
  • commitHostRemoveChildFromContainerremoveChildFromContainer(container, child)

同时还有一个非常关键但经常被忽略的步骤:

  • detachFiberAfterEffects(在 passive 阶段更彻底清理 fiber 指针)
  • detachDeletedInstance(hostInstance)(删除 DOM 节点上 React 内部字段映射)

5.1 detachDeletedInstance:删除的不只是 DOM,也包括 React 的"暗桩"

文件:packages/react-dom-bindings/src/client/ReactDOMComponentTree.js

React 会在 DOM 节点上挂内部字段(或 WeakMap):

  • fiber 映射
  • props 映射(事件处理用)
  • event handles 集合等

删除节点时必须清理这些,否则会:

  • 造成内存泄漏
  • 或让事件系统在"已经不属于 React 的 DOM 节点"上读到过期 fiber

6) 事件处理函数为什么要 updateFiberProps

这段是很多人读 DOM bindings 时的"顿悟点"。

React 的事件系统大体是:

  • DOM 上只需要做一次(或少量)事件监听注册
  • 事件触发时,React 通过 DOM→Fiber 映射 找到目标 fiber
  • 然后通过 DOM 节点上保存的 props 读取当前 handler(onClick 等)

所以 commitUpdate 里必须有:

  • updateFiberProps(domElement, newProps)

否则就会出现:

  • DOM 属性更新了
  • 但事件回调仍然是旧的闭包(读到旧 state/旧 props)

你可以把它理解成:

React 的事件回调并不是通过 addEventListener 直接更新的;它是"事件委托 + 运行时查表"。


7) trackHostMutation:为什么 DOM 层要上报"我改过 host tree"?

文件:packages/react-reconciler/src/ReactFiberMutationTracking.js

trackHostMutation() 是一个极热的函数(源码里明确要求 inline)。

在 DOM renderer 的多个地方会调用它:

  • commitTextUpdate
  • commitPlacement 插入后
  • updateProperties 里某些属性变更(input/select 的关键字段)
  • setValueForStyles 的每一次实际写入

它的用途取决于 feature flags:

  • ViewTransition:用来判断"本次 commit 是否真的发生了 host 变更",从而决定动画/过渡行为
  • DefaultTransitionIndicator:同理

这也是一个很典型的"横切关注点"落点:

  • Reconciler 不关心 DOM mutation 的细粒度
  • Renderer 最清楚自己有没有做真正的 DOM 写入

8) 常见误解(以及源码如何反驳)

误解 1:render 阶段会计算 DOM diff(像虚拟 DOM 那样生成 patch)

  • 在 DOM 的 mutation renderer 里不是
  • render 阶段只用 oldProps !== newProps 决定是否 markUpdate
  • 真正 diff 发生在 commit 的 updateProperties/setProp

误解 2:事件处理函数是靠 add/removeEventListener 更新的

  • React 更多是:
    • "一次性注册事件监听"
    • "事件发生时从 DOM 节点读取最新 props"
  • 证据:commitUpdateupdateFiberProps(domElement, newProps)

误解 3:删除 DOM 节点就是 removeChild,不涉及更多清理

  • 删除还必须清理 React 内部映射(detachDeletedInstance
  • Fiber 的更彻底断链在 passive 阶段(detachFiberAfterEffects

总结:DOM Host 层的本质是"把抽象语义翻译成浏览器现实"

把这一篇压缩成一句话:

Reconciler 负责算出"要做什么"(flags),DOM Renderer 负责回答"怎么做"(HostConfig + props diff + DOM 细节),并且为了事件与一致性,React 会在 DOM 节点上维护必要的内部映射与当前 props。


下一篇预告

第 10 篇我们会顺着本文最后一段继续推进:

  • 事件系统为什么要插件化
  • 委托事件 vs 非委托事件
  • 为什么有些事件要 listenToNonDelegatedEvent
  • 以及 batching 与优先级包装如何进入事件路径
相关推荐
xuyuan19981 小时前
超越Selenium:自动化测试框架Cypress在现代前端测试中的卓越实践(windows版本)三
前端·windows·测试工具·系统架构·cypress
企业对冲系统官1 小时前
价格风险管理平台审批角色配置与权限矩阵设计
大数据·运维·开发语言·前端·网络·数据库·矩阵
步步为营DotNet1 小时前
深度剖析.NET 中CancellationToken:精准控制异步操作的关键
java·前端·.net
thinkQuadratic1 小时前
CSS给文本添加背景颜色等效果
前端·css
波波鱼દ ᵕ̈ ૩1 小时前
AJAX(1)
前端·javascript·ajax
毕设十刻1 小时前
基于Vue的酒店管理系统4yv4w(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js
梦6501 小时前
Vue3 响应式原理与响应式属性 详解
前端·javascript·vue.js
程序员的程1 小时前
我用 stock-sdk 做了个 A 股股票看板
前端·javascript·typescript
IT_陈寒2 小时前
5 个现代 JavaScript 特性让你彻底告别老旧写法,编码效率提升 50%
前端·人工智能·后端