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のような環境でも、カスタムトランスポートを実装できます。
デフォルトの実装は WebSocketServer(ws 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.ts の createHMRHandler が受信ペイロードを処理します。type: 'update' メッセージに対しては、タイムスタンプを付与したdynamic importで更新モジュールを再取得し、登録済みのHMRコールバックを実行します。
共有HMRロジック:HMRClientとHMRContext
src/shared/hmr.ts にある HMRClient と HMRContext クラスは、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モジュール実行をどのように実現しているかを見ていきましょう。