Read OSS

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 は独自の EnvironmentModuleGraphEnvironmentPluginContainer、そして必要に応じて 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の実装をベースにしています。

DevEnvironmentenvironment.init() の中で独自の EnvironmentPluginContainer インスタンスを持ちます。このコンテナはビルド時にRolldownが使用するのと同じ resolveIdloadtransform フックインターフェースを実装しており、PluginContext として以下を提供します。

  • this.environment — 現在の DevEnvironment
  • this.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'

具体的には以下の処理を行います。

  1. es-module-lexer でimportを解析
  2. プラグインコンテナの resolveId を通じて各指定子を解決
  3. ベアimportを .vite/deps/ 内の事前バンドル済みパスに書き換え
  4. ファイル変更時のキャッシュ無効化のためにタイムスタンプクエリを付加
  5. import.meta.hot.accept() を呼び出すモジュールにHMR境界マーカーを注入
  6. import.meta.envimport.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

importersimportedModules のSetは双方向グラフを形成しています。ファイルが変更されると、Viteは fileToModulesMap でそのモジュールを探し、importers を上方向にたどってHMR境界を見つけます。acceptedHmrDeps には、そのモジュールが import.meta.hot.accept(deps, callback) を通じて受け入れを宣言したインポート先モジュールが記録されます。

invalidationState フィールドは、ソフト無効化(タイムスタンプの更新だけが必要で変換結果はまだ有効)とハード無効化(コードが変わったため再変換が必要)を区別します。この最適化により、HMRチェーン内での不要な再変換を避けることができます。

後方互換性のために、Viteは server.moduleGraph にclientとSSR環境のグラフをマージしたModuleGraphmixedModuleGraph.ts からインポート)も提供しています。ただしこのアクセスには将来の非推奨警告が付いており、新しいコードでは server.environments.client.moduleGraph を直接使うことが推奨されます。

次回予告

ここまでで、ブラウザからのリクエストがミドルウェアスタックを通り、変換パイプラインへと渡り、モジュールグラフが構築される流れを追いました。では、ファイルを保存したときに何が起きるのでしょうか。次回はHMRサイクルの全体像を追います。chokidarによるファイル検出からモジュールグラフのトラバース、WebSocketによるディスパッチ、クライアント側のモジュール再フェッチまでを解説し、さらに依存関係の事前バンドルがnpmパッケージをどのように検出・バンドル・配信するかも探っていきます。