Host Config 与 DOM Bindings — 连接 React 与浏览器
前置知识
- ›第 1–4 篇(全面理解 reconciler、fiber、work loop 与 hooks)
- ›基础 DOM API 知识(createElement、appendChild、addEventListener)
- ›理解事件委托与事件冒泡
Host Config 与 DOM Bindings — 连接 React 与浏览器
在前几篇文章中,我们一直在探索 React 的抽象机制——fiber、work loop、lane、hooks。这些代码对 DOM 元素、浏览器事件和 CSS 一无所知。reconciler 被刻意设计成与渲染器无关:它只谈论"实例"、"文本实例"和"容器"这些抽象类型,背后可以是 DOM 节点、原生视图,也可以是终端文本。
本文将解释这套抽象是如何运作的。我们会梳理每个渲染器都必须实现的 host config 契约,追踪 react-dom-bindings 如何为浏览器履行这一契约,逐步拆解 createRoot 的连接逻辑,深入剖析合成事件系统,并跟随 commit 阶段观察 DOM 变更的应用过程。
Host Config 契约
正如第 1 篇所介绍的,ReactFiberConfig.js 是一个抛出哨兵错误的占位文件:
throw new Error('This module must be shimmed by a specific renderer.');
构建时,fork 系统会将这个文件替换为特定渲染器的实现。对于 DOM 渲染器,ReactFiberConfig.dom.js 会从 react-dom-bindings 和 react-client 重新导出:
export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';
host config 是一个隐式接口——没有单独的 TypeScript/Flow 接口定义。reconciler 从 ./ReactFiberConfig 导入特定的具名导出,每个渲染器必须提供这些导出。核心操作如下:
classDiagram
class HostConfig {
+createInstance(type, props, root, context) Instance
+createTextInstance(text, root, context) TextInstance
+appendChild(parent, child) void
+appendChildToContainer(container, child) void
+insertBefore(parent, child, before) void
+removeChild(parent, child) void
+commitUpdate(instance, type, oldProps, newProps) void
+commitTextUpdate(textInstance, oldText, newText) void
+prepareUpdate(instance, type, oldProps, newProps) UpdatePayload
+shouldSetTextContent(type, props) boolean
+getCurrentUpdatePriority() EventPriority
+setCurrentUpdatePriority(priority) void
}
reconciler 会在特定时机调用这些函数:createInstance 在 completeWork 阶段处理新 fiber 时调用,commitUpdate 在 mutation commit 阶段调用,appendChild 和 insertBefore 用于处理 Placement effect,removeChild 用于处理删除操作。
DOM Host Config 的实现
实际的 DOM 实现位于 react-dom-bindings 包中体量庞大的 ReactFiberConfigDOM.js 文件里。来看看 createInstance 的实现:
export function createInstance(
type: string,
props: Props,
rootContainerInstance: Container,
hostContext: HostContext,
internalInstanceHandle: Object,
): Instance {
// ... validation in DEV
const ownerDocument = getOwnerDocumentFromRootContainer(rootContainerInstance);
// Creates the actual DOM element
const domElement = createElement(type, props, ownerDocument, hostContext);
// Caches the fiber reference on the DOM node
precacheFiberNode(internalInstanceHandle, domElement);
// Caches the props for later diffing
updateFiberProps(domElement, props);
return domElement;
}
该函数委托给标准的 document.createElement(SVG 则使用 createElementNS),并将内部引用存储在 DOM 节点自身上。fiber 与 DOM 节点之间的这种双向链接对事件系统至关重要——当 DOM 事件触发时,React 需要通过它找到对应的 fiber,进而定位事件处理函数。
在更新阶段,props 的 diff 算法分两步执行。在 completeWork 期间,prepareUpdate 计算差异(哪些 props 发生了变化);在 commit mutation 阶段,commitUpdate 通过 updateProperties 将差异应用到 DOM 元素上。这种拆分设计使得渲染阶段承担开销较大的比较工作,而 commit 阶段只需做最少的操作——这一点很重要,因为 commit 阶段是同步的,会阻塞主线程。
提示:
react-dom-bindings作为独立于react-dom的单独包存在,是为了让服务端渲染代码路径(Fizz)能够导入 DOM 相关的工具函数,而无需引入整个客户端 reconciler。
createRoot 与 hydrateRoot — 将一切串联起来
createRoot 是将所有模块连接在一起的入口:
sequenceDiagram
participant User as User Code
participant CR as createRoot()
participant FR as createFiberRoot()
participant Events as listenToAllSupportedEvents()
participant Root as ReactDOMRoot
User->>CR: createRoot(document.getElementById('root'))
CR->>CR: Validate container
CR->>FR: createFiberRoot(container, ConcurrentRoot, ...)
Note over FR: Creates FiberRoot + HostRoot fiber
CR->>Events: listenToAllSupportedEvents(container)
Note over Events: Attaches delegated event listeners
CR->>Root: new ReactDOMRoot(root)
Root-->>User: { render(), unmount() }
该函数依次完成以下工作:
- 验证容器是否为真实的 DOM 元素
- 解析配置项(严格模式、错误处理器、transition 回调)
- 调用 reconciler 的
createContainer,创建FiberRoot及其初始HostRootfiber(详见第 2 篇) - 通过内部属性将容器标记为 React root
- 初始化事件系统,在容器上调用
listenToAllSupportedEvents - 返回带有
render()和unmount()方法的ReactDOMRoot对象
当你调用 root.render(<App />) 时,它会调用 reconciler 的 updateContainer,在 HostRoot fiber 上创建一个更新,分配 lane,并启动调度流程(详见第 3 篇)。
hydrateRoot 的流程与之相同,但会将 root 标记为需要 hydration。在初次渲染时,reconciler 不会创建新的 DOM 节点,而是尝试将 fiber 匹配到已有的服务端渲染 HTML 节点,从而复用它们。
事件系统 — 委托与合成事件
React 的事件系统是 react-dom-bindings 中最复杂的部分之一。React 不将事件监听器绑定到各个 DOM 节点上,而是将所有事件委托到根容器。
listenToAllSupportedEvents 遍历所有已知的原生事件并附加监听器:
export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
if (!(rootContainerElement: any)[listeningMarker]) {
(rootContainerElement: any)[listeningMarker] = true;
allNativeEvents.forEach(domEventName => {
if (domEventName !== 'selectionchange') {
if (!nonDelegatedEvents.has(domEventName)) {
listenToNativeEvent(domEventName, false, rootContainerElement);
}
listenToNativeEvent(domEventName, true, rootContainerElement);
}
});
}
}
每个监听器都通过 createEventListenerWrapperWithPriority 包装相应的优先级:
export function createEventListenerWrapperWithPriority(
targetContainer, domEventName, eventSystemFlags,
): Function {
const eventPriority = getEventPriority(domEventName);
let listenerWrapper;
switch (eventPriority) {
case DiscreteEventPriority:
listenerWrapper = dispatchDiscreteEvent;
break;
case ContinuousEventPriority:
listenerWrapper = dispatchContinuousEvent;
break;
// ...
}
}
这里将 DOM 事件系统与 lane 模型连接起来:click 事件对应 DiscreteEventPriority(映射到 SyncLane),scroll/drag 对应 ContinuousEventPriority(映射到 InputContinuousLane),其余事件则对应 DefaultEventPriority。
flowchart TD
NE["Native DOM Event<br/>(click on button)"] --> RL["Root Listener<br/>(delegated)"]
RL --> GT["getEventTarget()<br/>Find DOM node"]
GT --> GF["getClosestInstanceFromNode()<br/>Find fiber"]
GF --> DP["dispatchEventForPluginEventSystem<br/>Walk fiber tree for handlers"]
DP --> SE["Create SyntheticEvent"]
SE --> HH["Call handler(syntheticEvent)"]
HH --> SU["State updates batched<br/>via lane system"]
当原生事件触发时,React 会:
- 从原生事件中找到目标 DOM 节点
- 通过缓存的双向引用查找对应的 fiber
- 沿 fiber 树向上遍历,收集事件处理函数(模拟冒泡行为)
- 创建 SyntheticEvent 包装器
- 按顺序调用处理函数,其中所有
setState调用都会自动批处理
Commit 阶段的 DOM 操作
如第 3 篇所述,commit mutation 阶段处理带有 MutationMask 标志的 fiber。接下来看看这些标志对应的 DOM 操作。
Placement — fiber 是新挂载的或被移动的。commit 阶段调用 host config 的 appendChild 或 insertBefore,最终转换为 parentNode.appendChild(domNode) 或 parentNode.insertBefore(domNode, beforeNode)。
Update — props 发生了变化。commitMutationEffectsOnFiber 调用 commitUpdate,将 completeWork 阶段计算好的差异应用到 DOM 上,可能涉及属性、事件处理函数或样式的更新。
ChildDeletion — 子节点被移除。React 遍历被删除的子树,对 DOM 调用 removeChild,卸载 ref,并执行 effect 的清理函数。
Ref — 在 mutation 阶段,正在卸载的 fiber 上的 ref 会被解除(设为 null);在 layout 阶段,新挂载或更新的 fiber 上的 ref 会被赋值(设为 DOM 实例)。
flowchart LR
subgraph "Mutation Phase"
P["Placement<br/>appendChild / insertBefore"]
U["Update<br/>commitUpdate (apply prop diff)"]
D["ChildDeletion<br/>removeChild + cleanup"]
RD["Ref Detach<br/>ref.current = null"]
end
subgraph "Layout Phase"
RA["Ref Attach<br/>ref.current = domNode"]
LE["useLayoutEffect<br/>Run callbacks"]
end
P --> RA
U --> LE
layout 阶段在 DOM 变更之后运行,因此 useLayoutEffect 回调以及 componentDidMount/componentDidUpdate 能够读取到更新后的 DOM。ref 也是在这个阶段赋值的,这就是为什么可以在 useLayoutEffect 内部通过 ref.current 访问到新挂载的 DOM 节点。
其他渲染器 — React Native、Test 与自定义渲染器
react-dom-bindings 实现的这套 host config 契约同样被其他渲染器所实现:
| 渲染器 | Fork 文件 | Host 实例类型 |
|---|---|---|
| React DOM | ReactFiberConfig.dom.js |
DOM Element |
| React Native | ReactFiberConfig.native.js |
原生视图 handle |
| 自定义(npm) | ReactFiberConfig.custom.js |
用户自定义类型 |
自定义渲染器的配置采用了一个巧妙的技巧。独立发布的 react-reconciler npm 包将整个 reconciler 包裹在一个工厂函数中:
module.exports = function ($$$config) {
/* reconciler code, with $$$config as the host config */
}
$$$config 在 reconciler 内部看起来像一个全局变量,但实际上是自定义渲染器传入的 host config 参数。这一机制在自定义 fork 文件中有详细说明。
提示: 如果你想构建自定义渲染器(例如 Canvas、WebGL 或终端渲染器),直接从
react-reconcilernpm 包入手即可。你只需要实现 host config 中的各项操作,完整的 fiber 架构、work loop、hooks 系统和调度机制都会随之获得。
下一步
至此,我们已经完整地梳理了客户端的全貌——从公共 API(react),经过 reconciler 的 fiber 架构与 work loop,再到 DOM 渲染器的 host config 与事件系统。在最后一篇文章中,我们将探索 React 的服务端架构:用于流式 SSR 的 Fizz、用于 React Server Components 的 Flight,以及 use client / use server 指令如何通过 bundler 集成来贯通客户端与服务端的边界。