Read OSS

ホットモジュールリプレースメントと依存関係のプリバンドル

上級

前提知識

  • 第1回:アーキテクチャとコードベースの読み方
  • 第2回:設定と Environment システム
  • 第3回:開発サーバーと Transform パイプライン
  • 第4回:プラグインシステムとコアプラグイン

ホットモジュールリプレースメントと依存関係のプリバンドル

Vite の開発体験を特徴づける機能は大きく2つあります。ひとつはホットモジュールリプレースメント(HMR)による即時更新、もうひとつはシームレスな依存関係のプリバンドルです。HMR を使えば、ページをフルリロードすることなくコードの変更をすぐに反映できます。プリバンドルは、node_modules 内の膨大な CommonJS ファイルをまとめて、ブラウザが効率よく取得できる ESM バンドルに変換します。

どちらも一見シンプルに見えて、内部はかなり複雑です。HMR では、モジュールグラフを辿って更新の境界を特定し、循環インポートを処理しながら、WebSocket 越しにサーバーとクライアントを連携させる必要があります。プリバンドルは依存関係を発見して Rolldown でバンドルし、ランタイムに新たな依存が見つかった場合でもシームレスに処理しなければなりません。

サーバーサイド HMR:ファイル変更から更新ペイロードまで

ファイルを保存すると、chokidar が変更を検知し、Vite のウォッチャーが handleHMRUpdate を呼び出します。この関数がサーバーサイド HMR 全体を統括します。

sequenceDiagram
    participant FS as File System
    participant Chokidar
    participant handleHMRUpdate
    participant PluginHooks
    participant updateModules
    participant WebSocket

    FS->>Chokidar: File changed
    Chokidar->>handleHMRUpdate: type, file, server
    handleHMRUpdate->>handleHMRUpdate: Is config/env file? → restart server
    handleHMRUpdate->>handleHMRUpdate: Is Vite client code? → full reload
    handleHMRUpdate->>handleHMRUpdate: Look up modules in each environment's moduleGraph
    handleHMRUpdate->>PluginHooks: Run hotUpdate hooks (plugins can filter modules)
    PluginHooks-->>handleHMRUpdate: Filtered module list
    handleHMRUpdate->>updateModules: propagate updates per environment
    updateModules->>updateModules: Find HMR boundaries via graph traversal
    updateModules->>WebSocket: Send update payload

391〜416行目では、設定ファイルや環境ファイルの変更を検出した場合にサーバー全体を再起動する処理が入っています。変更されたファイルが Vite のクライアントディレクトリ内であれば、すべての environment にフルリロードが送信されます。

通常のモジュール変更の場合、すべての environment をループしながら moduleGraph.getModulesByFile() で影響を受けるモジュールを調べ、hotUpdate プラグインフックを実行します。このフックを使うと、Vue の SFC コンパイラのようなフレームワークが更新範囲を絞り込めます。たとえば <template> ブロックだけが変更された場合は、テンプレートの再レンダリングだけで済むよう制御できます。

フックの実行は2段階に分かれています。最初にクライアント environment(481〜559行目)に対して実行され、その後それ以外の environment に対して実行されます。これは、混合モジュールグラフを対象とする非推奨の handleHotUpdate フックとの後方互換性を保つための設計です。

HMR 境界の伝播

updateModules 関数は、HMR の境界を特定するためにモジュールグラフを中心的に走査します。

graph TD
    CHANGED["Changed Module<br/>(src/utils.ts)"] --> IMP1["Importer A<br/>(src/App.tsx)"]
    CHANGED --> IMP2["Importer B<br/>(src/Header.tsx)"]
    IMP1 -->|"self-accepting ✓"| BOUNDARY1["HMR Boundary<br/>Update App.tsx"]
    IMP2 --> IMP3["Importer C<br/>(src/main.tsx)"]
    IMP3 -->|"not accepting"| DEADEND["Dead End → Full Reload"]

    style BOUNDARY1 fill:#4ade80
    style DEADEND fill:#f87171

変更されたモジュールごとに、propagateUpdate 関数がインポーターチェーンを遡りながら セルフアクセプティングモジュールimport.meta.hot.accept() を呼び出しているモジュール)を探します。これらが HMR の境界になります。走査中は次の情報を追跡します。

  • PropagationBoundary: 境界となるモジュール、accept されたモジュール、更新パスに循環インポートが含まれるかどうかを保持します。
  • 循環インポートの検出: すでに訪問済みのモジュールを再び検出した場合、境界に isWithinCircularImport: true をセットします。クライアント側ではこれを受けてインポートエラーをキャッチし、フルリロードにフォールバックします。
  • デッドエンド: accept できる境界が見つからないままモジュールグラフの根まで到達した場合、ページのフルリロードが発生します。

最終的な Update ペイロードには、モジュールパス、accept されたパス、タイムスタンプが含まれます。CSS の更新には専用の type: 'css-update' ペイロードが使われ、モジュールの再評価ではなくスタイルシートの差し替えが行われます。

ブラウザの HMR クライアント

第1回で見たように、ブラウザサイドの HMR クライアントは src/client/client.ts にあります。このファイルでは、ビルド時に clientInjectionsPlugin によって置換されるコンパイル時定数を宣言しています。

declare const __BASE__: string
declare const __SERVER_HOST__: string
declare const __HMR_PROTOCOL__: string | null
declare const __HMR_TIMEOUT__: number
declare const __HMR_ENABLE_OVERLAY__: boolean
declare const __WS_TOKEN__: string
declare const __BUNDLED_DEV__: boolean

クライアントは共有モジュールから HMRClient インスタンスを生成し、WebSocket トランスポートに接続します。204〜336行目handleMessage 関数は、受信したペイロードをタイプ別に処理します。

  • update: JS の更新では、キャッシュを回避するためにタイムスタンプのクエリパラメータを付加してモジュールを再インポートします。CSS の更新では、スタイルが未適用になる瞬間(FOUC)を防ぐために <link> タグをクローンして差し替えます。
  • full-reload: 連続した変更をまとめて処理するために、20ms のデバウンスを挟んでページをリロードします。
  • prune: インポートされなくなったモジュールを削除します。
  • error: スタックトレースとコードフレームを含むエラーオーバーレイを表示します。

細かいポイントとして、クライアントは __BUNDLED_DEV__ モードを別途処理します。バンドルモードでは、ネイティブ ESM の import() ではなく、Rolldown のランタイム(globalThis.__rolldown_runtime__.loadExports(acceptedPath))を通じて更新されたモジュールを読み込みます。

ヒント: クライアントの waitForSuccessfulPing 関数は SharedWorker を使ってタブ間の再接続を調整しています。開発サーバーを再起動すると、各タブが個別にポーリングするのではなく、開いているすべてのタブが同時に再接続します。

Rolldown による依存関係スキャン

開発サーバーがリクエストを処理し始める前に、Vite は依存関係をプリバンドルします。スキャン処理は optimizer/scan.ts にあり、Rolldown のネイティブ scan() 関数を使って実装されています。

flowchart TD
    A["scanImports()"] --> B["Create ScanEnvironment"]
    B --> C["rolldown/experimental scan()"]
    C --> D["rolldownDepPlugin filters imports"]
    D --> E["Discovered bare imports<br/>(react, vue, lodash-es, ...)"]
    E --> F["Return deps map"]

ScanEnvironment は軽量な environment(mode: 'scan')で、独自のプラグインコンテナを持ちますが、モジュールグラフや hot チャンネルは必要としません。devToScanEnvironment ヘルパーは DevEnvironment をスキャン専用に制限したビューを作成します。設定と名前は公開しますが、開発専用の機能へのアクセスはブロックされます。

スキャン結果は、ベアインポートの指定子から解決済みのファイルパスへのマップとして返されます。このマップが最適化ステップへの入力となります。

DepsOptimizer のライフサイクル

createDepsOptimizer 関数は、依存関係の最適化全体を管理する DepsOptimizer を生成します。

flowchart TD
    INIT["init()"] --> CACHE{"Cached metadata exists?"}
    CACHE -->|Yes| LOAD["Load cached optimized deps"]
    CACHE -->|No| SCAN["scanImports() → discover bare imports"]
    SCAN --> BUNDLE["runOptimizeDeps() → Rolldown bundle"]
    BUNDLE --> SERVE["Serve pre-bundled from .vite/deps/"]
    SERVE --> RUNTIME{"New dep discovered at runtime?"}
    RUNTIME -->|Yes| DEBOUNCE["Debounce 100ms"]
    DEBOUNCE --> REBUNDLE["Re-bundle with new dep"]
    REBUNDLE --> RELOAD["Full page reload"]
    RUNTIME -->|No| SERVE

オプティマイザーは Rolldown API の rolldown() を使って各依存関係を単一の ESM ファイルにバンドルします。rolldownDepPlugin はバンドル処理を担当し、各エントリーポイントを解決して外部依存をマークします。

ランタイムでの依存関係の発見は、この仕組みの巧妙な部分です。インポート解析プラグインが最適化済みの依存関係に存在しないベアインポートに遭遇すると、オプティマイザーにそれを登録します。複数の発見をまとめて処理するために 100ms のデバウンスを挟んだ後、オプティマイザーが再バンドルを実行してページをフルリロードします。holdUntilCrawlEnd オプションを使うと、起動時の不要な再バンドルを減らすために、初回の静的インポートクロールが完了するまでこの処理を遅延できます。

実験的機能:フルバンドル開発モード

Vite 8 には実験的な FullBundleDevEnvironment が追加されました。Rolldown の DevEngine API を使って、開発中もフルバンドルされた出力を提供します。--experimentalBundle フラグで有効化できます。

graph TD
    subgraph "Standard Dev Mode"
        REQ1["Browser request"] --> TRANSFORM["Per-module transform"]
        TRANSFORM --> SERVE1["Serve individual module"]
    end
    subgraph "Full Bundle Dev Mode"
        CHANGE["File change"] --> ENGINE["Rolldown DevEngine"]
        ENGINE --> MEMORY["MemoryFiles (lazy eval)"]
        REQ2["Browser request"] --> MIDDLEWARE["memoryFilesMiddleware"]
        MIDDLEWARE --> MEMORY
        MEMORY --> SERVE2["Serve bundled output"]
    end

MemoryFiles クラスは、バンドルされた出力をメモリ上に遅延評価で保持します。エントリーはアクセスされるまで実体化しないサンクとして保存できるため、一度も参照されないファイルの etag 計算やエンコーディング処理を省けます。

このモードでは Rolldown がすべてのバンドルを担うため、deps オプティマイザーは完全に無効化されます(disableDepsOptimizer: true を渡す)。HMR は Vite のモジュールグラフベースの手法ではなく、Rolldown のネイティブ HMR API を通じて機能します。

現時点ではクライアント environment のみに限定されていますが、この機能は Vite の将来像を示しています。Rolldown による完全なバンドル開発モードが実現すれば、開発環境とビルド環境の差を完全に解消できるでしょう。

次回予告

これで Vite の開発体験を支える2つの柱、高速な反復のための HMR と npm 依存関係配信のためのプリバンドルを解説し終えました。最終回ではプロダクションビルドを掘り下げます。buildEnvironment が Rolldown のオプションをどう解決するか、ViteBuilder.buildApp() がマルチ environment ビルドをどう調整するかを見ていきます。さらに、HMR サポートとクロス environment トランスポートを備えた ModuleRunner システムについても探ります。