Read OSS

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-bindingsreact-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 会在特定时机调用这些函数:createInstancecompleteWork 阶段处理新 fiber 时调用,commitUpdate 在 mutation commit 阶段调用,appendChildinsertBefore 用于处理 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() }

该函数依次完成以下工作:

  1. 验证容器是否为真实的 DOM 元素
  2. 解析配置项(严格模式、错误处理器、transition 回调)
  3. 调用 reconciler 的 createContainer,创建 FiberRoot 及其初始 HostRoot fiber(详见第 2 篇)
  4. 通过内部属性将容器标记为 React root
  5. 初始化事件系统,在容器上调用 listenToAllSupportedEvents
  6. 返回带有 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 会:

  1. 从原生事件中找到目标 DOM 节点
  2. 通过缓存的双向引用查找对应的 fiber
  3. 沿 fiber 树向上遍历,收集事件处理函数(模拟冒泡行为)
  4. 创建 SyntheticEvent 包装器
  5. 按顺序调用处理函数,其中所有 setState 调用都会自动批处理

Commit 阶段的 DOM 操作

如第 3 篇所述,commit mutation 阶段处理带有 MutationMask 标志的 fiber。接下来看看这些标志对应的 DOM 操作。

Placement — fiber 是新挂载的或被移动的。commit 阶段调用 host config 的 appendChildinsertBefore,最终转换为 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-reconciler npm 包入手即可。你只需要实现 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 集成来贯通客户端与服务端的边界。