Vite Dev Serverの内部構造:ミドルウェアスタック、変換パイプライン、モジュールグラフ
前提知識
- ›第1〜2回:アーキテクチャ、設定、プラグインシステム
- ›Node.js HTTPサーバーとミドルウェアパターン(connect/express)
- ›Rollupプラグインフックの概念(resolveId、load、transform)
Vite Dev Serverの内部構造:ミドルウェアスタック、変換パイプライン、モジュールグラフ
vite dev を実行すると、通常とは少し異なる仕組みでdev serverが起動します。ソースコードをバンドルするのではなく、ブラウザからのリクエストに応じてファイルをオンデマンドで変換しながら、各モジュールをネイティブESMとして個別に配信するのです。このアンバンドルなアプローチこそがVite dev serverを高速にしている理由ですが、その裏ではimportの書き換え、キャッシュ、HMRの無効化、依存関係の事前バンドルといった複雑な仕組みが動いています。まずはサーバーの作成から、その全体像を追っていきましょう。
サーバーの作成と環境の初期化
createServer() 関数は _createServer() の薄いラッパーに過ぎず、_createServer() 本体は476行目から始まります。サーバー作成の流れは次のとおりです。
sequenceDiagram
participant CLI as CLI / User Code
participant CS as _createServer()
participant Config as resolveConfig()
participant HTTP as HTTP Server
participant WS as WebSocket Server
participant Env as DevEnvironment (per-env)
participant MW as Middleware Stack
CLI->>CS: createServer(inlineConfig)
CS->>Config: resolveConfig(inlineConfig, 'serve')
Config-->>CS: ResolvedConfig
CS->>HTTP: resolveHttpServer(middlewares, https)
CS->>WS: createWebSocketServer(httpServer, config)
loop For each environment in config.environments
CS->>Env: createEnvironment(name, config, { ws })
Env->>Env: init({ watcher })
Note over Env: Creates moduleGraph,<br/>pluginContainer,<br/>depsOptimizer
end
CS->>MW: Assemble middleware stack
CS-->>CLI: ViteDevServer
562〜579行目の環境初期化ループでは、解決済みの設定に含まれるファクトリ関数を使って各 DevEnvironment を作成します。
const environment = await environmentOptions.dev.createEnvironment(
name, config, { ws },
)
environments[name] = environment
await environment.init({ watcher, previousInstance })
各 DevEnvironment は独自の EnvironmentModuleGraph、EnvironmentPluginContainer、そして必要に応じて DepsOptimizer を持ちます。ViteDevServer インターフェースは server.environments を通じて、すべての環境への統一されたアクセスを提供します。
ミドルウェアスタック
Viteはミドルウェアフレームワークとしてconnectを採用しています。ミドルウェアスタックは _createServer() の920〜1030行目で組み立てられており、セキュリティを最優先とした順序になっています。
flowchart TD
A["timeMiddleware (DEBUG only)"] --> B
B["rejectInvalidRequestMiddleware"] --> C
C["rejectNoCorsRequestMiddleware"] --> D
D["corsMiddleware"] --> E
E["hostValidationMiddleware"] --> F
F["configureServer pre-hooks"] --> G
G["cachedTransformMiddleware"] --> H
H["proxyMiddleware"] --> I
I["baseMiddleware"] --> J
J["launchEditorMiddleware"] --> K
K["viteHMRPingMiddleware"] --> L
L["servePublicMiddleware"] --> M
M["transformMiddleware ⭐"] --> N
N["serveRawFsMiddleware"] --> O
O["serveStaticMiddleware"] --> P
P["htmlFallbackMiddleware"] --> Q
Q["configureServer post-hooks"] --> R
R["indexHtmlMiddleware"] --> S
S["notFoundMiddleware"] --> T
T["errorMiddleware"]
style M fill:#f96,stroke:#333,stroke-width:2px
セキュリティ層では、不正なリクエストの拒否、CORSヘッダーのないクロスオリジンリクエストのブロック、DNSリバインディング攻撃を防ぐための Host ヘッダー検証を行います。957行目の cachedTransformMiddleware は、変換処理が始まる前にETagベースの304レスポンスを返します。
スタックの中心となるのがtransformMiddlewareです。JS、CSS、importクエリURLへのリクエストを横取りして transformRequest() を通じて変換し、その結果を返します。ただしこのミドルウェアが担うのはclient環境のみで、SSRの変換はモジュールランナーという別の経路を通ります(第5回で解説します)。
ヒント: プラグイン作者は
configureServerフックを使ってミドルウェアを追加できます。フックから直接返したミドルウェアは内部ミドルウェアの前に実行されます。フックから関数を返すと内部スタックの後ろにミドルウェアが追加され、キャッチオール的なルートの実装に便利です。
プラグインコンテナ
dev中は実際のRollup/Rolldownバンドル処理は行われません。代わりにViteはプラグインコンテナを通じてRollupのプラグイン実行モデルをエミュレートします。pluginContainer.tsにクレジットが記載されているとおり、WMRの実装をベースにしています。
各 DevEnvironment は environment.init() の中で独自の EnvironmentPluginContainer インスタンスを持ちます。このコンテナはビルド時にRolldownが使用するのと同じ resolveId、load、transform フックインターフェースを実装しており、PluginContext として以下を提供します。
this.environment— 現在のDevEnvironmentthis.resolve()— プラグイン間の解決処理this.emitFile()— アセットの出力this.parse()— RolldownのパーサーによるAST解析
classDiagram
class EnvironmentPluginContainer {
+resolveId(id, importer, options)
+load(id, options)
+transform(code, id, options)
+buildStart()
+close()
-plugins: Plugin[]
-environment: DevEnvironment
}
class DevEnvironment {
+pluginContainer: EnvironmentPluginContainer
+moduleGraph: EnvironmentModuleGraph
+transformRequest(url): TransformResult
}
DevEnvironment --> EnvironmentPluginContainer
プラグインコンテナは、Rollup/RolldownプラグインをDevモードで動かすための抽象化層です。実際のバンドル処理と同じフックを同じ順序で呼び出しながら、一度に1モジュールずつ処理します。
transformRequest():Dev モードの核心
transform middlewareが /src/App.tsx へのリクエストを受け取ると、environment.transformRequest(url) を呼び出し、それがtransformRequest()関数に委譲されます。
sequenceDiagram
participant MW as transformMiddleware
participant TR as transformRequest()
participant PC as pluginContainer
participant MG as moduleGraph
participant FS as File System
MW->>TR: transformRequest(url)
TR->>TR: Check pending requests (dedup)
TR->>PC: resolveId(url)
PC-->>TR: resolved id + metadata
TR->>MG: ensureEntryFromUrl(url)
TR->>PC: load(id)
alt Plugin provides code
PC-->>TR: code
else No plugin handles
TR->>FS: fs.readFile(id)
FS-->>TR: code
end
TR->>PC: transform(code, id)
PC-->>TR: transformed code + sourcemap
TR->>TR: Generate ETag
TR->>MG: Update transformResult
TR-->>MW: TransformResult { code, map, etag }
109〜130行目ではリクエストの重複排除が行われます。同じURLへのリクエストが同時に2つ届いた場合、2つ目は最初のリクエストの結果を待ちます。ただし細かい点があって、最初のリクエストが処理を開始してから2つ目が到着するまでの間にモジュールが無効化された場合、最初のリクエストの結果は古くなっています。そのため2つ目のリクエストは最初のリクエストをキャンセルし、新たに処理を開始します。
TransformResult 型はレスポンスを返すために必要なすべての情報を保持します。
export interface TransformResult {
code: string
map: SourceMap | { mappings: '' } | null
etag?: string
deps?: string[]
dynamicDeps?: string[]
}
Import解析:ネイティブESMとViteをつなぐ橋
transform フックが完了した後、dev専用プラグインであるimportAnalysisPlugin(約1100行)がブラウザ向けのimport書き換えという重要な処理を担います。次のようなソースコードを例に考えてみましょう。
import React from 'react'
import './styles.css'
import { helper } from './utils'
ブラウザは 'react' のようなベアimportを解決できませんし、CSSのimportも理解できません。import解析プラグインはこれを次のように変換します。
import React from '/node_modules/.vite/deps/react.js?v=abc123'
import './styles.css?import'
import { helper } from '/src/utils.ts?t=1234567890'
具体的には以下の処理を行います。
es-module-lexerでimportを解析- プラグインコンテナの
resolveIdを通じて各指定子を解決 - ベアimportを
.vite/deps/内の事前バンドル済みパスに書き換え - ファイル変更時のキャッシュ無効化のためにタイムスタンプクエリを付加
import.meta.hot.accept()を呼び出すモジュールにHMR境界マーカーを注入import.meta.envとimport.meta.globの置換を処理
flowchart LR
A["import React from 'react'"] -->|resolveId| B["/.vite/deps/react.js?v=abc"]
C["import './styles.css'"] -->|CSS detection| D["./styles.css?import&t=123"]
E["import { x } from './utils'"] -->|timestamp| F["./utils.ts?t=123"]
G["import.meta.hot.accept()"] -->|HMR injection| H["__vite__createHotContext(url)"]
ヒント: モジュールがホットアップデートされずにフルリロードになってしまう原因を調べたいときは、import解析プラグインのHMR境界検出を確認しましょう。
import.meta.hot.accept()を直接、またはフレームワークのHMR処理経由で呼び出していないモジュールは、そのインポーター側へと変更の伝播が強制されます。
モジュールグラフのデータ構造
EnvironmentModuleGraph は、ある環境内のすべてのモジュール関係を追跡するデータ構造です。高速アクセスのために3つのルックアップインデックスを持ちます。
urlToModuleMap: Map<string, EnvironmentModuleNode> // URL → module
idToModuleMap: Map<string, EnvironmentModuleNode> // resolved ID → module
fileToModulesMap: Map<string, Set<EnvironmentModuleNode>> // file → modules
1つのファイルが複数のモジュールに対応することがあります(クエリ文字列が異なる場合など)。そのため fileToModulesMap はSetを使って保持しています。
各EnvironmentModuleNodeが追跡する情報は次のとおりです。
classDiagram
class EnvironmentModuleNode {
+url: string
+id: string | null
+file: string | null
+type: "js" | "css" | "asset"
+importers: Set~EnvironmentModuleNode~
+importedModules: Set~EnvironmentModuleNode~
+acceptedHmrDeps: Set~EnvironmentModuleNode~
+acceptedHmrExports: Set~string~ | null
+isSelfAccepting?: boolean
+transformResult: TransformResult | null
+lastHMRTimestamp: number
+lastInvalidationTimestamp: number
+invalidationState: TransformResult | "HARD_INVALIDATED" | undefined
}
class EnvironmentModuleGraph {
+urlToModuleMap: Map
+idToModuleMap: Map
+fileToModulesMap: Map
+etagToModuleMap: Map
+getModuleByUrl(url)
+getModulesByFile(file)
+invalidateModule(mod, ...)
+onFileChange(file)
}
EnvironmentModuleGraph --> EnvironmentModuleNode : manages
EnvironmentModuleNode --> EnvironmentModuleNode : importers / importedModules
importers と importedModules のSetは双方向グラフを形成しています。ファイルが変更されると、Viteは fileToModulesMap でそのモジュールを探し、importers を上方向にたどってHMR境界を見つけます。acceptedHmrDeps には、そのモジュールが import.meta.hot.accept(deps, callback) を通じて受け入れを宣言したインポート先モジュールが記録されます。
invalidationState フィールドは、ソフト無効化(タイムスタンプの更新だけが必要で変換結果はまだ有効)とハード無効化(コードが変わったため再変換が必要)を区別します。この最適化により、HMRチェーン内での不要な再変換を避けることができます。
後方互換性のために、Viteは server.moduleGraph にclientとSSR環境のグラフをマージしたModuleGraph(mixedModuleGraph.ts からインポート)も提供しています。ただしこのアクセスには将来の非推奨警告が付いており、新しいコードでは server.environments.client.moduleGraph を直接使うことが推奨されます。
次回予告
ここまでで、ブラウザからのリクエストがミドルウェアスタックを通り、変換パイプラインへと渡り、モジュールグラフが構築される流れを追いました。では、ファイルを保存したときに何が起きるのでしょうか。次回はHMRサイクルの全体像を追います。chokidarによるファイル検出からモジュールグラフのトラバース、WebSocketによるディスパッチ、クライアント側のモジュール再フェッチまでを解説し、さらに依存関係の事前バンドルがnpmパッケージをどのように検出・バンドル・配信するかも探っていきます。