Read OSS

ソースから静的ファイルへ:ビルドパイプラインとバンドラー抽象化

上級

前提知識

  • プラグインライフサイクルとルート生成の理解(第2回)
  • Webpack の基礎知識(ローダー、プラグイン、バンドル)
  • Node.js の worker_threads に関する基本的な知識

ソースから静的ファイルへ:ビルドパイプラインとバンドラー抽象化

ルートが生成され、コードファイルが .docusaurus/ に書き出されると、ビルドパイプラインが動き始めます。その役割は、ブラウザ向けのクライアントバンドルのコンパイル、レンダリング用のサーバーバンドルのコンパイル、HTML ファイルを生成する Static Site Generation(SSG)の実行、そしてビルド後の検証です。本記事では、docusaurus build から本番用の build/ ディレクトリが完成するまでの全工程を追っていきます。

その過程で、Docusaurus が Webpack と Rspack を切り替えられるきれいなバンドラー抽象化、マルチロケールビルドへの現実的なアプローチ、そしてワーカースレッドを使ってページレンダリングを並列化できる設定可能な SSG エグゼキューターについても見ていきます。

マルチロケールビルドのオーケストレーション

build.ts#L23-L56build コマンドは、設定されているすべてのロケールのビルドを調整します。アプローチはシーケンシャルで、mapAsyncSequential() を使ってロケールを1つずつ順番にビルドします。

flowchart TD
    BUILD["docusaurus build"] --> LOCALES["getLocalesToBuild()"]
    LOCALES --> L1["buildLocale('en')"]
    L1 --> L2["buildLocale('fr')"]
    L2 --> L3["buildLocale('ja')"]
    L3 --> DONE["Build complete"]
    
    style L1 fill:#e1f5fe
    style L2 fill:#e1f5fe
    style L3 fill:#e1f5fe

なぜシーケンシャルなのでしょうか。build.ts#L120-L131 のコメントに経緯が記されています。チームは worker_threads でロケールビルドを並列実行しようとしたものの、SIGSEGVSIGBUS のクラッシュに悩まされました。child_process では動作しましたが、メモリ制限やログ管理が複雑になってしまいました。シーケンシャル方式は現実的な落としどころです。安定して動作し、各ロケールのビルドが Webpack のコンパイルと SSG パスに CPU をフルに使えます。

デフォルトロケールは必ず最初にビルドされます(60〜75行目)。これは微妙なバグを防ぐためです。デフォルトロケールは通常 build/ のルートに出力されますが、他のロケールは build/<locale>/ に書き出されます。デフォルトロケールを最後にビルドすると、ローカライズ済みのサブディレクトリが上書きされてしまうのです。

ロケールごとのビルドパイプライン

buildLocale.ts#L43-L150 の各ロケールビルドは、5つのステップで構成されています。

flowchart TD
    BL["buildLocale()"] --> LOAD["1. loadSite()"]
    LOAD --> CONFIGS["2. Create client + server configs (parallel)"]
    CONFIGS --> COMPILE["3. compile() both bundles"]
    COMPILE --> SSG["4. executeSSG()"]
    SSG --> POST["5. postBuild() + handleBrokenLinks()"]
    
    CONFIGS --> CLEAR["Clear output dir (parallel with config creation)"]

ステップ1:loadSite() — 第1・2回で解説したとおり、サーバーパイプライン全体を実行します。設定の読み込み、プラグインライフサイクル、コード生成が行われます。サイト設定の翻訳に対応するため、DOCUSAURUS_CURRENT_LOCALE 環境変数が回避策として設定されます。

ステップ2:バンドラー設定の作成 — クライアントとサーバーの Webpack 設定が、80〜100行目で並列に作成されます。注目すべき点として、出力ディレクトリのクリアもこのステップで行われます。設定に依存せず時間がかかるため、3つ目の並列 Promise として実行されます。

ステップ3:compile() — 両方のバンドルをまとめてコンパイルします。hash ルーターモードでは SSG が不要なためクライアントバンドルのみが必要となり、サーバー設定はスキップされます。

ステップ4:executeSSG() — サーバーバンドルを読み込み、すべてのルートを HTML にレンダリングします。詳細は後述します。

ステップ5:ビルド後処理postBuild() プラグインライフサイクルが実行され、プラグインはレンダリング済みの出力にアクセスできます。続いて handleBrokenLinks() が、サイト全体で収集されたすべてのリンクをアンカーと照合します。

ヒント: デバッグ用にパイプラインを制御できる環境変数がいくつかあります。DOCUSAURUS_SKIP_BUNDLINGDOCUSAURUS_RETURN_AFTER_LOADINGDOCUSAURUS_EXIT_AFTER_LOADINGDOCUSAURUS_EXIT_AFTER_BUNDLING を使うと、任意のステップでビルドを停止できます。これらは buildLocale.ts#L38-L41 で定義されています。

バンドラー抽象化レイヤー

Docusaurus は共通のインターフェースを通じて Webpack と Rspack の両方をサポートしています。この抽象化は docusaurus-bundler パッケージに実装されており、中核となる関数 getCurrentBundler()currentBundler.ts#L27-L42 にあります。

export async function getCurrentBundler({siteConfig}): Promise<CurrentBundler> {
  if (isRspack(siteConfig)) {
    return {
      name: 'rspack',
      instance: (await importRspack()) as unknown as typeof webpack,
    };
  }
  return {
    name: 'webpack',
    instance: webpack,
  };
}

CurrentBundler 型はシンプルに {name: 'webpack' | 'rspack', instance: typeof webpack} として定義されています。Rspack は Webpack との API 互換を目指しているため、同じ型にキャストされます。これにより、下流のコードはどちらのバンドラーが使われているかを意識せず currentBundler.instance を使えます。

graph TD
    CB[getCurrentBundler] -->|rspackBundler: true| RS[Rspack instance]
    CB -->|rspackBundler: false| WP[Webpack instance]
    RS --> COMMON["CurrentBundler {name, instance}"]
    WP --> COMMON
    COMMON --> CSS[getCSSExtractPlugin]
    COMMON --> COPY[getCopyPlugin]
    COMMON --> PROG[getProgressBarPlugin]

ただし、バンドラーによって異なるプラグインも存在します。currentBundler.ts#L57-L103 のヘルパー関数 getCSSExtractPlugingetCopyPlugingetProgressBarPlugin は、それぞれ適切な実装を返します。Rspack の場合、CSS の抽出には組み込みの CssExtractRspackPlugin が使われ、プログレスバーは WebpackBar の name/color API を模倣するために rspack.ProgressPlugin をラップしたカスタムクラスで実装されています。

プラグインの Webpack 設定のマージは configure.ts#L55-L78 で行われます。各プラグインの configureWebpack() の戻り値は webpack-merge を使って既存の設定にディープマージされ、配列やオブジェクトのマージ挙動を細かく制御するためのオプション mergeStrategy も用意されています。

SSG:Static Site Generation

ssgExecutor.ts の SSG エグゼキューターは、future.faster.ssgWorkerThreads の値に基づいて2つのモードを切り替えます。

シンプルモードcreateSimpleSSGExecutor、39〜58行目)は、現在の Node.js プロセス内ですべてのページを順番にレンダリングします。これがデフォルトで、小〜中規模のサイトでは十分機能します。

プールモードcreatePooledSSGExecutor、101〜176行目)は、Tinypool を使ってページのレンダリングをワーカースレッドに分散させます。スレッド数は動的に計算されます。

flowchart TD
    EX["executeSSG()"] --> CHECK{ssgWorkerThreads?}
    CHECK -->|false| SIMPLE["Simple: single thread"]
    CHECK -->|true| POOL["Pooled: Tinypool"]
    POOL --> CALC["inferNumberOfThreads()"]
    CALC --> |"pageCount / 100 vs cpuCount"| THREADS["min(workload, cpus)"]
    THREADS -->|"== 1"| SIMPLE
    THREADS -->|"> 1"| SPAWN["Spawn thread pool"]
    SPAWN --> CHUNK["Chunk pages by SSGWorkerThreadTaskSize"]
    CHUNK --> RENDER["Parallel rendering"]

ssgExecutor.ts#L65-L78 のスレッド数の推定では、minPagesPerCpu の閾値として 100 が使われています。つまり、ページ数が50ならば CPU 数に関わらずスレッドは1つになります。小規模サイトでスレッド作成のオーバーヘッドを避けるための工夫です。推定スレッド数が1になった場合は、シンプルモードにフォールバックします。

プールにはメモリ管理の仕組みも組み込まれています。136行目の maxMemoryLimitBeforeRecycle によってメモリ使用量が高くなるとスレッドを再生成でき、issue #11161 で報告された SSG のメモリリークに対処しています。

実際のレンダリングは serverEntry.tsx で行われます。各ページについて、ルートデータのプリロード、StaticRouterHelmetProvider による <App /> のラップ、HTML へのレンダリング、そしてリンク切れデータ(ページ上のすべての <a> href と id アンカー)の収集が行われます。収集されたデータはビルド後の検証のために HTML とともに返されます。

future.faster パフォーマンスフラグ

future.faster 設定オプションは、一連のパフォーマンス最適化を制御します。デフォルト値は configValidation.ts#L76-L99 で定義されています。

フラグ デフォルト 効果
swcJsLoader false JS のトランスパイルに Babel の代わりに SWC を使用
swcJsMinimizer false JS の minification に SWC を使用
swcHtmlMinimizer false HTML の minification に SWC を使用
lightningCssMinimizer false CSS の minification に Lightning CSS を使用
mdxCrossCompilerCache false クライアント/サーバービルド間で MDX コンパイルをキャッシュ
rspackBundler false Webpack の代わりに Rspack を使用
rspackPersistentCache false Rspack の永続ディスクキャッシュを有効化
ssgWorkerThreads false ワーカースレッドで SSG を並列化
gitEagerVcs false VCS メタデータの先行読み込み

ショートカットとして future: {faster: true} と指定すると、すべてのフラグが一括で有効になります(89〜99行目)。また、前方互換性のための future.v4 フラグセットも用意されています。

graph TD
    FASTER["future.faster"] -->|true| ALL["All faster flags enabled"]
    FASTER -->|object| PICK["Pick individual flags"]
    V4["future.v4"] -->|true| ALL_V4["All v4 flags enabled"]
    V4 -->|object| PICK_V4["Pick individual flags"]
    V4 -->|fasterByDefault: true| DEFAULT["faster flags default to true"]

future.v4 フラグは、v4 で予定されている破壊的変更を今すぐオプトインできるものです。注目すべき依存関係として、ssgWorkerThreads を使うには v4.removeLegacyPostBuildHeadAttribute の有効化が必須です。これは configValidation.ts#L622-L637 でバリデーションされています。理由は、postBuild() のレガシー head 属性にシリアライズできない Helmet の状態が含まれており、ワーカースレッド間で渡せないためです。

configValidation.ts#L570-L651 のポスト処理ロジックは、v4.fasterByDefault と個々の faster フラグの相互作用を解決します。fasterByDefaulttrue の場合、明示的に設定されていない faster フラグはデフォルトで true になります。

ヒント: 設定ファイルに future: {faster: true, v4: true} と記述すれば、今すぐ v4 への移行を始められます。すべてのパフォーマンス最適化と前方互換性フラグが有効になり、次のメジャーバージョンへの準備を進めながら最速のビルドを実現できます。

次回予告

CLI の呼び出しからマルチロケールのオーケストレーション、バンドラーのコンパイル、SSG レンダリングまで、ビルドパイプライン全体を追ってきました。ただし、バンドラーの内部で何が起きているか、具体的には Markdown や MDX ファイルがどのようにして React コンポーネントになるかについては触れていません。次回は、MDX 処理パイプラインと、docs・blog・pages を支えるコンテンツプラグインを詳しく掘り下げます。