Read OSS

ViteにおけるHot Module ReplacementとDependency Pre-Bundling

上級

前提知識

  • 第3回:Dev server・module graph・transform pipeline
  • WebSocketプロトコルの基礎知識
  • HMRの概念(モジュール境界、self-acceptingモジュール)への理解

ViteにおけるHot Module ReplacementとDependency Pre-Bundling

Viteの高速な開発体験を支えるシステムは2つあります。Hot Module Replacement(HMR)はページ全体をリロードせずにアプリを即座に更新します。dependency pre-bundlingはnpmパッケージをブラウザが扱いやすい最適化済みESMに変換して、import React from 'react' の一行のために何百ものリクエストが走るのを防ぎます。どちらのシステムも、第3回で解説したmodule graphと深く結びついています。それぞれの仕組みをエンドツーエンドで追っていきましょう。

ファイル変更の検出とhandleHMRUpdate

第3回で触れたように、_createServer() はchokidarのウォッチャーを起動します。890行目では、ファイルが変更されると以下の処理が走ります。

watcher.on('change', async (file) => {
  file = normalizePath(file)
  // notify all environments' plugin containers
  await Promise.all(
    Object.values(server.environments).map((environment) =>
      environment.pluginContainer.watchChange(file, { event: 'update' }),
    ),
  )
  // invalidate module graph cache
  for (const environment of Object.values(server.environments)) {
    environment.moduleGraph.onFileChange(file)
  }
  await onHMRUpdate('update', file)
})

この処理は handleHMRUpdate() に委譲され、まず特殊なファイル種別のチェックが行われます。

sequenceDiagram
    participant FS as chokidar
    participant HMR as handleHMRUpdate()
    participant ENV as Each DevEnvironment
    participant UM as updateModules()
    participant WS as WebSocket

    FS->>HMR: file changed
    alt Config file / env file changed
        HMR->>HMR: Restart server
    else Vite client code changed
        HMR->>WS: full-reload (all environments)
    else Normal source file
        loop Each environment
            HMR->>ENV: Run hotUpdate plugin hooks
            ENV-->>HMR: Filtered modules
            HMR->>UM: updateModules(environment, file, modules)
            UM->>WS: Send update/full-reload payload
        end
    end

変更されたファイルがViteの設定ファイルや環境変数ファイルであれば、サーバー自体が再起動します(389〜406行目)。通常のソースファイルの場合は、各environmentのmodule graphから影響を受けるモジュールを特定し、hotUpdate プラグインフックを全environmentで並行して実行します。

Module Graphのトラバーサルと境界検出

HMRのコアロジックは updateModules()propagateUpdate() に実装されています。変更されたモジュールごとに、propagateUpdate() はimporterのチェーンを上向きにたどっていきます。

flowchart TD
    A["Changed Module"] --> B{"isSelfAccepting?"}
    B -->|yes| C["✅ Boundary found:<br/>module accepts its own updates"]
    B -->|no| D{"Has importers?"}
    D -->|no| E["❌ Dead end → full reload"]
    D -->|yes| F["Check each importer"]
    F --> G{"Importer accepts<br/>this dep via hot.accept()?"}
    G -->|yes| H["✅ Boundary: importer"]
    G -->|no| I{"Importer isSelfAccepting?"}
    I -->|yes| J["Continue upward anyway"]
    I -->|no| K["Recurse: propagate to<br/>importer's importers"]
    K --> D

このアルゴリズムは深さ優先探索で「伝播境界」を収集します。伝播境界とは、import.meta.hot.accept() を通じて更新を受け付けると宣言しているモジュールのことです。探索がルートモジュール(importerを持たないモジュール)まで到達しても境界が見つからなければ、「dead end」と判断してページのフルリロードが発生します。

細かい点ですが、この関数は循環importも追跡しています(785行目)。循環参照のチェーン内にある境界には isWithinCircularImport: true フラグが付与され、クライアント側で循環依存の更新を慎重に処理できるようになっています。

境界の収集が完了すると、updateModules()679〜694行目Update[] ペイロードを組み立て、hot channelを通じて送信します。

hot.send({ type: 'update', updates })

HotChannelの抽象化とWebSocketトランスポート

HotChannel インターフェースは、トランスポート層を抽象化したものです。ViteはWebSocketに固定されておらず、このインターフェースが要求するのは send()on()off()listen()close() の5つのメソッドだけです。そのため、WebSocketが利用できないSSR workerのような環境でも、カスタムトランスポートを実装できます。

デフォルトの実装は WebSocketServerws npmパッケージを使用)で、NormalizedHotChannel インターフェースを拡張しています。normalizeHotChannel() ラッパー(171〜328行目)は利便性を高めるレイヤーを追加しています。オーバーロードされた send() を持つ正規化済みクライアントオブジェクトと、クライアントからサーバー関数(fetchModule など)を呼び出せる invokeHandler の仕組みを提供します。

flowchart LR
    subgraph "Server (Node.js)"
        A[DevEnvironment.hot] -->|"NormalizedHotChannel"| B[WebSocketServer]
        A2[SSR Environment.hot] -->|"NormalizedHotChannel"| C[ServerHotChannel<br/>in-process]
    end
    subgraph "Browser"
        D[HMR Client]
    end
    subgraph "SSR Runner"
        E[ModuleRunner]
    end
    B <-->|WebSocket| D
    C <-->|Direct calls| E

ヒント: WebSocketの接続URLにはセキュリティのため token クエリパラメータが付与されます(ws://host:port?token=xxx)。これにより、悪意のあるページがdev serverのWebSocketに接続するのを防いでいます。このトークンは clientInjectionsPlugin によってクライアントコードに埋め込まれます。

クライアントサイドのHMR処理

ブラウザ側のHMRクライアントは src/client/client.ts にあります。注入された設定定数(__HMR_PROTOCOL____HMR_HOSTNAME____HMR_PORT____WS_TOKEN__)を使ってWebSocket接続を確立し、共有モジュールから HMRClient インスタンスを生成します。

const transport = normalizeModuleRunnerTransport(
  createWebSocketModuleRunnerTransport({
    createConnection: () =>
      new WebSocket(`${socketProtocol}://${socketHost}?token=${wsToken}`, 'vite-hmr'),
    pingInterval: hmrTimeout,
  })
)

src/shared/hmrHandler.tscreateHMRHandler が受信ペイロードを処理します。type: 'update' メッセージに対しては、タイムスタンプを付与したdynamic importで更新モジュールを再取得し、登録済みのHMRコールバックを実行します。

共有HMRロジック:HMRClientとHMRContext

src/shared/hmr.ts にある HMRClientHMRContext クラスは、HMRの統合的な抽象化レイヤーです。HMRContext はユーザーコードが操作する import.meta.hot APIを実装しています。

export class HMRContext implements ViteHotContext {
  accept(deps?: any, callback?: any): void {
    if (typeof deps === 'function' || !deps) {
      this.acceptDeps([this.ownerPath], ([mod]) => deps?.(mod))
    } else if (typeof deps === 'string') {
      this.acceptDeps([deps], ([mod]) => callback?.(mod))
    } else if (Array.isArray(deps)) {
      this.acceptDeps(deps, callback)
    }
  }
  // ...
}

同じ HMRClient クラスが、ブラウザクライアントとSSR HMR用の ModuleRunner の両方でインスタンス化され、それぞれ異なるトランスポートが渡されます。この共有設計により、HMRプロトコルのセマンティクスが全環境で一貫して保たれます。

classDiagram
    class HMRClient {
        +hotModulesMap: Map
        +dataMap: Map
        +customListenersMap: Map
        +notifyListeners(event, data)
    }
    class HMRContext {
        +accept(deps?, callback?)
        +dispose(callback)
        +invalidate(message?)
        +on(event, callback)
        +send(event, data?)
    }
    class BrowserClient["Browser (client.ts)"]
    class ModuleRunner["SSR (runner.ts)"]
    HMRClient --> HMRContext : creates per module
    BrowserClient --> HMRClient : uses with WebSocket transport
    ModuleRunner --> HMRClient : uses with server transport

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

次は2つ目のシステム、dependency pre-bundlingを見ていきましょう。Viteが import React from 'react' のようなベアインポートに遭遇すると、.vite/deps/ に事前バンドル済みのESMが必要になります。その最初のステップがスキャン——アプリが使用している依存関係を洗い出すプロセスです。

ScanEnvironment クラスは、Rolldownの scan() API(rolldown/experimental から取得)を使ってエントリーポイントをクロールする軽量なenvironmentです。PluginContainer は生成しますが、module graphやdeps optimizerは持たず、importを解決するための最低限のインフラだけを備えています。

import { scan } from 'rolldown/experimental'

export class ScanEnvironment extends BaseEnvironment {
  mode = 'scan' as const
  // ...
}

スキャナーはHTMLエントリーポイントから静的importをたどってmodule graphを走査し、検出したすべてのベアインポートを記録します。見つかった依存関係はそれぞれ ExportsData エントリーとして登録され、exportのリストとESモジュール構文の有無が保持されます。

flowchart TD
    A["index.html"] -->|"scan"| B["src/main.ts"]
    B --> C["import React from 'react'"]
    B --> D["import { useState } from 'react'"]
    B --> E["import dayjs from 'dayjs'"]
    C --> F["Discovered: react"]
    D --> F
    E --> G["Discovered: dayjs"]
    F --> H["ExportsData { hasModuleSyntax, exports }"]
    G --> H

依存関係のバンドルと配信

依存関係が検出されると、runOptimizeDeps() がRolldownを使ってバンドルを行います。各依存関係はキャッシュディレクトリ(.vite/deps/)内の単一のESMファイルに変換され、元のフォーマットにかかわらず適切な名前付きexportが提供されます。

optimizedDepsPlugin は、これらの事前バンドル済みファイルへのリクエストをインターセプトします。

export function optimizedDepsPlugin(): Plugin {
  return {
    name: 'vite:optimized-deps',
    applyToEnvironment(environment) {
      return !isDepOptimizationDisabled(environment.config.optimizeDeps)
    },
    resolveId(id) {
      if (environment.depsOptimizer?.isOptimizedDepFile(id)) return id
    },
    async load(id) {
      if (depsOptimizer?.isOptimizedDepFile(id)) {
        // read from .vite/deps/ cache
      }
    },
  }
}

applyToEnvironment フックはEnvironment APIの好例です。dependency最適化をenvironmentごとに選択的に無効化できる柔軟さがここに表れています。

ランタイム検出と再最適化

すべての依存関係を静的に検出できるわけではありません。dynamic import、条件付きrequire、プラグインが生成するimportなど、ランタイムになって初めて明らかになる依存関係もあります。createDepsOptimizer()debounce付きの再最適化戦略でこれに対応しています。

const debounceMs = 100

export function createDepsOptimizer(environment: DevEnvironment): DepsOptimizer {
  // ...
  const depsOptimizer: DepsOptimizer = {
    init,
    metadata,
    registerMissingImport,
    run: () => debouncedProcessing(0),
    // ...
  }
}

import analysisプラグインが最適化済みdepsのメタデータに存在しないベアインポートを検出すると、depsOptimizer.registerMissingImport() を呼び出します。この依存関係はキューに積まれ、新しい検出がない状態が100ms続く(debounce期間)と、既知の全依存関係の再バンドルが実行され、ブラウザが新しい事前バンドル版を取得できるようにページがリロードされます。

holdUntilCrawlEnd オプション(デフォルトで有効)はもう一つの工夫で、初回ページロード中は静的importグラフが完全にクロールされるまで検出をまとめて保留します。これにより起動時に再最適化が何度も発生するのを防ぎます。

sequenceDiagram
    participant IA as importAnalysis plugin
    participant DO as DepsOptimizer
    participant RD as Rolldown
    participant BR as Browser

    IA->>DO: registerMissingImport('lodash-es')
    Note over DO: Start 100ms debounce timer
    IA->>DO: registerMissingImport('date-fns')
    Note over DO: Reset debounce timer
    Note over DO: 100ms elapsed, no new deps
    DO->>RD: Re-bundle all deps
    RD-->>DO: New optimized files
    DO->>BR: full-reload (new dep hashes)

ヒント: 開発中に「new dependencies optimized」というメッセージが繰り返し表示される場合は、頻繁に見逃される依存関係を設定の optimizeDeps.include に追加しましょう。該当パッケージのランタイム検出とリロードのサイクルをなくすことができます。

次回予告

Viteのdev modeを支える2つのシステム——即座のフィードバックをもたらすHMRと、npmパッケージの互換性を確保するdependency pre-bundling——を一通り追ってきました。最終回では vite build を実行したときに何が起きるかを掘り下げます。createBuilder() がRolldownを通じてマルチenvironmentのプロダクションビルドをどのように調整するか、そしてModule RunnerがHMRサポート付きのSSRモジュール実行をどのように実現しているかを見ていきましょう。