Read OSS

アーキテクチャ概要と React ソースコードの読み方

中級

前提知識

  • JavaScript の基礎とモジュールシステム (import/export) の理解
  • React の利用経験(コンポーネント、JSX、hooks)
  • ビルドツール全般の概念的理解(バンドラー、モジュール解決)

アーキテクチャ概要と React ソースコードの読み方

React のソースコードは一見すると近寄りがたい印象を与えます。個々のファイルが特別に複雑なわけではありません。問題は、約 38 個のパッケージがカスタムビルドパイプラインによって結び付けられた、ひとつの大きなエコシステムになっているという点です。このパイプラインは、コンパイル時にモジュールを差し替えるという仕組みを持っています。リコンシリエーションの仕組みや hooks の状態管理を理解する前に、まず「何がどこにあり、どうつながっているか」という全体像を頭に入れておく必要があります。この記事では、その地図を提供します。

モノレポのレイアウトを俯瞰し、パッケージ間の依存グラフを追ったうえで、React 最大の特徴的なアーキテクチャパターンである フォークシステム を詳しく掘り下げます。フォークシステムは、ビルド時にモジュール全体を差し替えることで、オープンソース向け・Meta の Web プロパティ向け (FB_WWW)・React Native 向けといった異なるバンドルを生成する仕組みです。

モノレポのレイアウトと各パッケージの役割

React は Yarn workspaces を使ったモノレポ構成です。ルートの package.json には、ワークスペースの glob が1つだけ定義されています。

{
  "private": true,
  "workspaces": ["packages/*"]
}

公開対象のパッケージはすべて packages/ 以下に置かれています。バージョン番号は ReactVersions.js に一元管理されており、現在の最新バージョンは 19.3.0 です。各パッケージ名とバージョンの対応もここで定義されているため、モノレポ全体でバージョンがずれることを防げます。

ディレクトリ 役割
packages/react 公開 API — createElement、hooks スタブ、コンポーネント型
packages/react-reconciler Fiber リコンシラー — 全レンダラーが共有するコアロジック
packages/react-dom DOM レンダラーのエントリポイント(createRoothydrateRoot
packages/react-dom-bindings DOM 固有のホスト設定、イベントシステム、props 処理
packages/react-server サーバーレンダリングエンジン — Fizz (SSR) と Flight (RSC)
packages/react-client Flight クライアント — RSC ペイロードのデシリアライズ
packages/scheduler 優先度キューによる協調スケジューリング
packages/shared 横断的なユーティリティ、feature flags、共通型定義
packages/react-native-renderer React Native のホスト設定とレンダラー
compiler/ React Compiler(旧 React Forget)— 独立したビルドシステム

compiler/ ディレクトリは独自の package.json とビルドパイプラインを持つ完全に別個のプロジェクトです。Yarn workspaces には含まれていません。

パッケージ依存グラフ

React のパッケージアーキテクチャは、レンダラーに依存しない設計を実現するために、厳密なレイヤー構造を採用しています。ソースコードを読み解くうえで、このグラフを把握しておくことは欠かせません。

graph TD
    react["react<br/>(Public API)"]
    shared["shared<br/>(Utilities, Types, Feature Flags)"]
    reconciler["react-reconciler<br/>(Fiber Engine)"]
    dom["react-dom<br/>(DOM Renderer)"]
    domBindings["react-dom-bindings<br/>(DOM Host Config + Events)"]
    native["react-native-renderer<br/>(Native Renderer)"]
    scheduler["scheduler<br/>(Priority Queue)"]

    dom --> reconciler
    dom --> domBindings
    dom --> react
    native --> reconciler
    native --> react
    reconciler --> shared
    reconciler --> scheduler
    react --> shared
    domBindings --> shared

ここで重要なのは、react パッケージがどのレンダラーにも依存していないという点です。useState のような hooks スタブは、現在インストールされているディスパッチャーに処理を委譲するだけで、そのディスパッチャーが react-dom から来たものか react-native-renderer から来たものかを一切知りません。

ReactClient.js./ReactHooks から hooks をインポートしており、ReactSharedInternals.H を介して現在のディスパッチャーを参照します。hooks の実際の実装はリコンシラーの中にあります。この間接参照こそが、React のマルチレンダラーアーキテクチャを支える基盤です。

shared/ にある ReactSharedInternals.js は、単純に react からインポートして再エクスポートするブリッジモジュールです。

import * as React from 'react';
const ReactSharedInternals =
  React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
export default ReactSharedInternals;

ただし、これは react パッケージ自体をビルドするときに循環依存を引き起こします。reactreact をインポートできないからです。この問題を解決するのがフォークシステムです。

フォークシステム — コンパイル時の依存性注入

React の最もユニークなアーキテクチャパターンが、scripts/rollup/forks.js に定義された フォークシステム です。バンドルタイプとエントリポイントに応じて、ビルド時にモジュールパスを差し替えます。

forks オブジェクトは、ソースファイルのパスを、置換先パスを返す関数にマッピングしています。

flowchart LR
    A["Import: shared/ReactSharedInternals"] --> B{Which bundle?}
    B -->|"entry = 'react'"| C["react/src/ReactSharedInternalsClient.js"]
    B -->|"entry = 'react/src/ReactServer.js'"| D["react/src/ReactSharedInternalsServer.js"]
    B -->|"condition = 'react-server'"| E["react-server/src/ReactSharedInternalsServer.js"]
    B -->|"Other packages"| F["shared/ReactSharedInternals.js (no replacement)"]

主要なフォークは 3 種類あります。

1. ReactSharedInternals — 循環依存の解消。react パッケージをビルドするとき、shared/ReactSharedInternals のインポートは、直接 ReactSharedInternalsClient.js に差し替えられます。このファイルは、短縮されたプロパティ名を持つ共有状態オブジェクトを定義しています(H は hooks ディスパッチャー、A は async ディスパッチャー、T は transition、S は startTransition コールバック、G は gesture)。

2. ReactFeatureFlags — 環境ごとのフラグ値。packages/shared/ReactFeatureFlags.js がデフォルト値を定義していますが、各バンドルタイプは独自のフォークを持ちます。forks.js における feature flags の処理 は、エントリポイントとバンドルタイプに基づいて分岐します。

3. ReactFiberConfig — ホスト設定の注入。ソースファイル ReactFiberConfig.js は、エラーをスローするだけのセンチネルファイルです。

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

このファイルはビルド時に必ず差し替えられなければなりません。フォークシステムは inlinedHostConfigs のエントリからレンダラーを特定し、対応するフォークファイルを探します。DOM の場合は ReactFiberConfig.dom.js が使われ、これが react-dom-bindings を再エクスポートします。

Tips: リコンシラーのコードを読んでいて ./ReactFiberConfig からのインポートが出てきたら、それは抽象的な操作であることを思い出しましょう。実行時にスローセンチネルに解決されるわけではなく、ビルドシステムがレンダラー固有の実装に差し替えています。これが React 流の依存性注入です。

アーキテクチャとしての Feature Flags

React は、オープンソース (npm)・Meta の Web プロパティ (FB_WWW)・Meta 内部の React Native (RN_FB)・React Native オープンソース (RN_OSS) という 4 つの異なる環境に同時にデプロイされます。Feature flags はこれらすべてで段階的なロールアウトを可能にするための仕組みです。

マスターの feature flags ファイルでは、ライフサイクルステージごとにフラグが整理され、明確なコメントが付いています。

ライフサイクルステージ 意味
Land or remove (zero effort) クリーンアップ済み (現在は空)
Killswitch 直近でリリース済み。リグレッション時にオフにできる disableSchedulerTimeoutInWorkLoop など
Ongoing experiments 現在開発中 enableYieldingBeforePassiveenableGestureTransition
Ready for next major 次のメジャーバージョンで公開予定 __NEXT_MAJOR__ 系のフラグ各種

Meta の Web プロパティ向けフォークである ReactFeatureFlags.www.js を見ると、require('ReactFeatureFlags') を使って実行時にフラグを動的にロードしながら、一部の値は静的に上書きするハイブリッドな実装になっています。これにより、Meta のインフラが社員単位やパーセンテージベースのロールアウトを実現できます。

ビルド時には、feature flags が true または false の定数にコンパイルされます。バンドラーのデッドコード削除により、無効化されたフィーチャーのコードは本番バンドルに一切含まれません。

ビルドパイプラインとバンドルタイプ

ビルドパイプラインは scripts/rollup/bundles.js によって制御されており、2 種類の重要な列挙型が定義されています。

Module Types は、ビルド対象のパッケージの種類を分類します。

flowchart TD
    ISO["ISOMORPHIC<br/>react, react-jsx-runtime<br/>Works everywhere"]
    REND["RENDERER<br/>react-dom, react-native-renderer<br/>Bundles the reconciler"]
    RECON["RECONCILER<br/>react-reconciler (standalone npm)<br/>For custom renderers"]
    RUTIL["RENDERER_UTILS<br/>Helpers accessing renderer internals"]

Bundle Types は、ターゲット環境とモードを定義します。

バンドルタイプ ターゲット
NODE_DEV / NODE_PROD オープンソース npm の開発/本番用 react-dom.development.js
FB_WWW_DEV / FB_WWW_PROD Meta 社内 Web 動的フラグを使ったカスタムビルド
RN_FB_DEV / RN_FB_PROD Meta 内部の React Native 社内モバイルビルド
RN_OSS_DEV / RN_OSS_PROD React Native オープンソース npm に公開されるビルド
ESM_DEV / ESM_PROD ES module ビルド モダンなバンドラー向け

inlinedHostConfigs.js は、レンダラーの短縮名をエントリポイントとインポート可能なパスにマッピングしています。たとえば dom-browser の設定には react-domreact-dom/client といったエントリポイントが含まれます。加えて、許可されたインポートパスの一覧も定義されています。

flowchart LR
    Source["Source Files<br/>(Flow-typed JS)"] --> Rollup["Rollup Build"]
    Rollup --> Forks["Fork System<br/>(Module Replacement)"]
    Forks --> Flags["Feature Flag Inlining<br/>(Dead Code Elimination)"]
    Flags --> Bundles["Output Bundles"]
    Bundles --> NPM["npm packages<br/>(NODE_DEV/PROD)"]
    Bundles --> FB["Meta internal<br/>(FB_WWW)"]
    Bundles --> RN["React Native<br/>(RN_FB/RN_OSS)"]

ソースから公開パッケージに至るまでの完全な流れは次のとおりです。まず Rollup がエントリポイントを読み込みます。次にフォークシステムが (bundleType, entry) の組み合わせに基づいてモジュールを差し替えます。Babel が Flow 型を除去してトランスパイルし、feature flags がインライン化されます。最後に Closure Compiler(一部バンドル)または Terser がミニファイし、出力が build/ ディレクトリに生成されます。

Tips: コードを読んでいてインポートが「おかしなファイル」に解決されているように見えたら、まず scripts/rollup/forks.js を確認しましょう。実際にレンダラーが使うモジュールは、ディスク上のインポートパスとまったく別のファイルである可能性があります。

次のステップ

この全体像を頭に入れることで、React の各パッケージがどこにあり、どう関連し合い、ビルドパイプラインがそれをどう変換するかが見えてきたはずです。次の記事では、コードベース全体で最も基本的なデータ構造である Fiber ノード にフォーカスします。React ツリー上のすべてのコンポーネント・DOM 要素・バウンダリを表す、あのミュータブルな JavaScript オブジェクトです。