Read OSS

ホストコンフィグとDOM連携 — Reactとブラウザをつなぐ仕組み

上級

前提知識

  • 第1〜4回(リコンシラー・fiber・ワークループ・hooksの完全な理解)
  • 基本的なDOM API の知識(createElement、appendChild、addEventListener)
  • イベント委譲とイベントバブリングの理解

ホストコンフィグとDOM連携 — Reactとブラウザをつなぐ仕組み

ここまでの連載では、Reactの抽象的な内部機構(fiber、ワークループ、レーン、hooks)を見てきました。これらのコードはDOM要素やブラウザイベント、CSSについては何も知りません。リコンシラーは意図的にレンダラーへ依存しない設計となっており、「インスタンス」「テキストインスタンス」「コンテナ」といった抽象的な型で動作します。DOMノードにもなりえますし、ネイティブビューやターミナルのテキストにもなりえます。

この記事では、その抽象化の仕組みを解説します。すべてのレンダラーが実装しなければならないホストコンフィグの契約を確認し、react-dom-bindingsがブラウザ向けにそれをどう実装しているかを追います。また、createRootの配線、合成イベントシステムの詳細、そしてコミットフェーズにおけるDOM変更の適用まで順を追って解説します。

ホストコンフィグの契約

第1回で触れたように、ReactFiberConfig.js はエラーをスローするだけのファイルです。

throw new Error('This module must be shimmed by a specific renderer.');

フォークの仕組みにより、このファイルはビルド時にレンダラー固有の実装へ差し替えられます。DOM向けのフォークである ReactFiberConfig.dom.js は、react-dom-bindingsreact-client からエクスポートを再公開します。

export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';

ホストコンフィグは暗黙のインターフェースです――TypeScript/FlowのInterfaceとして明示的に定義されているわけではありません。リコンシラーが ./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
    }

リコンシラーはこれらの関数を決まったタイミングで呼び出します。createInstance は新しいfiberの completeWork 時に、commitUpdate はミューテーションコミットフェーズに、appendChildinsertBefore はPlacementエフェクトの処理時に、removeChild は削除時にそれぞれ呼ばれます。

DOMホストコンフィグの実装

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の差分アルゴリズムは2段階で動作します。completeWork 時に prepareUpdate が差分(どのpropsが変わったか)を計算し、コミットのミューテーションフェーズで commitUpdateupdateProperties を通じてその差分をDOMに適用します。この分割により、レンダーフェーズがコストのかかる比較処理を担い、コミットフェーズは最小限の作業だけを行います。コミットフェーズはメインスレッドをブロックする同期処理のため、この設計は重要です。

ヒント: react-dom-bindings パッケージが react-dom とは別のパッケージとして存在するのは、サーバーレンダリングのコードパス(Fizz)がクライアント側のリコンシラー全体を引き込まずにDOM固有のユーティリティをインポートできるようにするためです。

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. オプション(Strictモード、エラーハンドラー、トランジションコールバック)をパースする
  3. リコンシラーの createContainer を呼び出して FiberRoot と初期の HostRoot fiberを作成する(第2回で解説)
  4. 内部プロパティでコンテナをReactルートとしてマークする
  5. コンテナに対して listenToAllSupportedEvents を呼び出し、イベントシステムをセットアップする
  6. render()unmount() メソッドを持つ ReactDOMRoot オブジェクトを返す

root.render(<App />) を呼び出すと、リコンシラーの updateContainer が呼ばれます。これはHostRoot fiberに更新を作成し、レーンを割り当て、スケジューリングプロセスを開始します(第3回で追ったとおりです)。

hydrateRoot も同じフローを辿りますが、ルートをハイドレーション用にマークします。初回レンダー時は新しいDOMノードを作成するのではなく、サーバーレンダリング済みのHTMLをfiberと照合して既存のDOMノードを再利用しようとします。

イベントシステム — 委譲と合成イベント

Reactのイベントシステムは react-dom-bindings の中でも最も複雑な部分のひとつです。個々のDOMノードにイベントリスナーをアタッチする代わりに、Reactはすべてのイベントをルートコンテナに委譲します。

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イベントシステムとレーンモデルが連携します。クリックイベントは DiscreteEventPrioritySyncLane にマッピング)、スクロール・ドラッグは ContinuousEventPriorityInputContinuousLane にマッピング)、それ以外は 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 の呼び出しはレーンシステムによって自動的にバッチ処理される

コミットフェーズのDOM操作

第3回で見たように、コミットのミューテーションフェーズは MutationMask フラグを持つfiberを処理します。それらのフラグがDOM操作としてどう展開されるかを見てみましょう。

Placement — fiberが新たにマウントされるか、移動される場合です。コミットフェーズはホストコンフィグの appendChild または insertBefore を呼び出し、それぞれ parentNode.appendChild(domNode) または parentNode.insertBefore(domNode, beforeNode) に変換されます。

Update — propsが変化した場合です。commitMutationEffectsOnFibercommitUpdate を呼び出し、completeWork 時に計算された差分を適用します。DOMの属性、イベントハンドラー、スタイルなどが更新されます。

ChildDeletion — 子要素が削除された場合です。Reactは削除されたサブツリーを走査し、DOMから removeChild を呼び出してrefをアンマウントし、エフェクトのクリーンアップを実行します。

Ref — ミューテーションフェーズでは、アンマウントするfiberのrefがデタッチされます(nullが設定される)。レイアウトフェーズでは、新たにマウントまたは更新された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

レイアウトフェーズはDOMへの変更が済んだに実行されるため、useLayoutEffect コールバックや componentDidMount/componentDidUpdate は更新後のDOMを参照できます。refがアタッチされるのもこのタイミングです。そのため、useLayoutEffect 内で ref.current を読むと、新しくマウントされたDOMノードを取得できます。

他のレンダラー — React Native、テスト用、カスタム

react-dom-bindings が実装しているホストコンフィグの契約は、他のレンダラーも同様に実装しています。

レンダラー フォークファイル ホストインスタンスの型
React DOM ReactFiberConfig.dom.js DOM Element
React Native ReactFiberConfig.native.js ネイティブビューのハンドル
カスタム(npm) ReactFiberConfig.custom.js ユーザー定義の型

カスタムレンダラーのコンフィグには巧妙なトリックが使われています。スタンドアロンの react-reconciler npmパッケージは、リコンシラー全体をファクトリ関数でラップしています。

module.exports = function ($$$config) {
  /* reconciler code, with $$$config as the host config */
}

リコンシラーのコード内では $$$config はグローバル変数のように見えますが、実際にはカスタムレンダラーが渡すホストコンフィグの引数です。詳細はカスタムフォークファイルに記載されています。

ヒント: Canvas、WebGL、ターミナルなど向けにカスタムレンダラーを作る場合は、react-reconciler npmパッケージから始めましょう。実装が必要なのはホストコンフィグの操作だけです。fiberアーキテクチャ、ワークループ、hooksシステム、スケジューリングはすべてそのまま使えます。

次回予告

これでクライアントサイドの全体像が揃いました。公開API(react)から始まり、リコンシラーのfiberアーキテクチャとワークループを経て、DOMレンダラーのホストコンフィグとイベントシステムまで辿り着きました。最終回では、Reactのサーバーサイドアーキテクチャを探ります。ストリーミングSSR向けのFizz、React Server Components向けのFlight、そして use client / use server ディレクティブがバンドラー統合を通じてクライアントとサーバーの境界をどのように橋渡しするかを見ていきましょう。