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.jspackages/react-dom-bindings/src/client/ReactDOMComponent.js- 以及用于事件系统与 Fiber 映射的
ReactDOMComponentTree.js
本文的目标是:
- 把 Reconciler 与 Renderer 的"契约边界"讲清楚
- 把 Placement/Update/Deletion 三类 DOM 变更的关键路径串起来
- 解释一些你看似"无关"的细节为什么必须存在 (比如
updateFiberProps、trapClickOnNonInteractiveElement、supportsMoveBefore)
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 commitHostPlacement → commitPlacement
文件:packages/react-reconciler/src/ReactFiberCommitHostEffects.js
commitPlacement 做了三件事:
- 向上找 host parent fiber(HostComponent / HostRoot / HostPortal / HostSingleton(scope))
- 找到一个插入锚点
before = getHostSibling(finishedWork) - 递归把所有 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.jspackages/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 里包含了大量"键级别"的语义:
className→classtabIndex→tabindexstyle→setValueForStyles(并带 prevStyles 做 diff)children(string/number/bigint)→setTextContent- URL 类属性
src/href→sanitizeURL 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.jspackages/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
删除的最终落点:
commitHostRemoveChild→removeChild(parentInstance, child)- 或
commitHostRemoveChildFromContainer→removeChildFromContainer(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"
- 证据:
commitUpdate里updateFiberProps(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 与优先级包装如何进入事件路径