Read OSS

設定の解決とマルチ環境システム

上級

前提知識

  • 第 1 回: アーキテクチャとコードベースのナビゲーション
  • JavaScript の Proxy オブジェクトの理解
  • Rollup/Rolldown のプラグインフック概念への慣れ

設定の解決とマルチ環境システム

Vite の設定システムは、単に vite.config.ts を読み込むだけではありません。client・SSR・フレームワークが定義するカスタム環境など、複数の環境にまたがって設定を解決・マージ・検証・フリーズするまでを担います。この処理は、ユーザープラグイン・環境フック・後方互換レイヤーが絡み合う、全 10 ステップの精密なパイプラインとして実行されます。

このパイプラインを理解することは非常に重要です。Vite の他のほぼすべてのサブシステムが、このパイプラインが生成する ResolvedConfig オブジェクトを参照しているからです。「environment.config.build とトップレベルの config.build が異なるのはなぜか」「esbuild のオプションがいつの間にか Rolldown の同等オプションに変換されるのはなぜか」といった疑問への答えも、ここにあります。

resolveConfig パイプライン

resolveConfig 関数は、設定解決の唯一のエントリーポイントです。InlineConfig、コマンド('serve' または 'build')、そして任意の内部パラメーターを受け取ります。パイプライン全体の流れは次のとおりです。

flowchart TD
    A["1. Setup Rollup compat shims"] --> B["2. Load config file"]
    B --> C["3. Merge file config with inline config"]
    C --> D["4. Filter plugins by 'apply' field"]
    D --> E["5. Sort plugins: pre / normal / post"]
    E --> F["6. Run plugin 'config' hooks"]
    F --> G["7. Ensure default environments (client, ssr)"]
    G --> H["8. Resolve sub-configs<br/>(server, build, CSS, etc.)"]
    H --> I["9. Run 'configResolved' hooks"]
    I --> J["10. Return frozen ResolvedConfig"]

ステップ 1 では、build・worker・optimizeDeps の各設定に対してすぐに setupRollupOptionCompat が呼ばれます。これが Rollup から Rolldown への移行における最初の防衛線となり、古い rollupOptions のフィールドを Rolldown の対応するフィールドにマッピングします。

ステップ 4〜5 では、プラグインのソートが行われます。まず apply フィールドによってフィルタリングされ(現在のコマンドと一致するプラグインのみを残す)、次に enforce の値に応じて 3 つのグループに振り分けられます。

const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawPlugins)

ステップ 6 では、各プラグインの config フックが実行されます。プラグインが設定解決の前にユーザー設定を変更できる機会です。@vitejs/plugin-react などのプラグインが Rolldown の transform オプションを注入するのも、このタイミングです。

ステップ 7 は環境システムにとって特に重要です。1447〜1462 行目で、Vite は clientssr の環境が常に存在することを保証しています。

if (!config.environments.client) {
  config.environments = { client: {}, ...config.environments }
}

スプレッドの順序にも意味があります。client を先頭に置くことで、Object.keys() によるイテレーションがカスタム環境より先に client を処理するようになります。

環境の階層構造

Vite 8 では環境のクラス階層が導入されており、各環境が独自のモジュールグラフ・プラグインコンテナ・依存関係オプティマイザー・ホットチャンネルを持つ構造になっています。

classDiagram
    class PartialEnvironment {
        +name: string
        +config: ResolvedConfig & ResolvedEnvironmentOptions
        +logger: Logger
        -_topLevelConfig: ResolvedConfig
        -_options: ResolvedEnvironmentOptions
        +getTopLevelConfig(): ResolvedConfig
    }
    class BaseEnvironment {
        +plugins: readonly Plugin[]
        -_initiated: boolean
    }
    class UnknownEnvironment {
        +mode: "unknown"
    }
    class DevEnvironment {
        +mode: "dev"
        +moduleGraph: EnvironmentModuleGraph
        +depsOptimizer?: DepsOptimizer
        +hot: NormalizedHotChannel
        +pluginContainer: EnvironmentPluginContainer
    }
    class BuildEnvironment {
        +mode: "build"
        +isBuilt: boolean
    }
    class ScanEnvironment {
        +mode: "scan"
        +pluginContainer: EnvironmentPluginContainer
    }

    PartialEnvironment <|-- BaseEnvironment
    BaseEnvironment <|-- UnknownEnvironment
    BaseEnvironment <|-- DevEnvironment
    BaseEnvironment <|-- BuildEnvironment
    BaseEnvironment <|-- ScanEnvironment

PartialEnvironment がルートクラスです。環境名・トップレベル設定への参照・環境固有のオプション・Proxy ベースのマージ済み設定(詳しくは後述)を保持します。また、環境名をカラー付きプレフィックスとして付与するカスタムロガーも、ここで生成されます。

BaseEnvironment はプラグインへのアクセスと初期化フラグを追加します。

DevEnvironment は開発時の中心的存在です。モジュールグラフ・プラグインコンテナ・依存関係オプティマイザー・保留中のリクエスト追跡・ホットチャンネルを自身で管理します。各環境は完全に自己完結しています。

ユニオン型は environment.ts で定義されています。

export type Environment =
  | DevEnvironment
  | BuildEnvironment
  | ScanEnvironment
  | UnknownEnvironment

ヒント: プラグインコードを書く際は、this.environment.mode を確認して何が利用可能かを判断しましょう。moduleGraphdepsOptimizer を持つのは DevEnvironment だけです。dev モードの判定に mode !== 'build' を使うのは避け、mode === 'dev' と明示的に書きましょう。UnknownEnvironment クラスは、まさにこのアンチパターンを防ぐために存在しています。

Proxy ベースの環境設定マージ

Vite のコードベースの中でも特に巧妙なパターンのひとつが、環境設定とトップレベル設定のマージ方法です。各 PartialEnvironment が持つ config プロパティは、実際には JavaScript の Proxy になっています。

sequenceDiagram
    participant Plugin
    participant Proxy (environment.config)
    participant EnvOptions
    participant TopLevelConfig

    Plugin->>Proxy (environment.config): Read config.resolve.alias
    Proxy (environment.config)->>EnvOptions: Has 'resolve'?
    alt Property exists in environment options
        EnvOptions-->>Proxy (environment.config): Return env-specific value
    else Property only in top-level
        Proxy (environment.config)->>TopLevelConfig: Return top-level value
    end
    Proxy (environment.config)-->>Plugin: Resolved value

baseEnvironment.ts の実際の実装は次のとおりです。

this.config = new Proxy(
  options as ResolvedConfig & ResolvedEnvironmentOptions,
  {
    get: (target, prop: keyof ResolvedConfig) => {
      if (prop === 'logger') {
        return this.logger
      }
      if (prop in target) {
        return this._options[prop as keyof ResolvedEnvironmentOptions]
      }
      return this._topLevelConfig[prop]
    },
  },
)

ロジックはシンプルで明快です。プロパティが環境固有のオプションに存在すればそちらを使い、なければトップレベルの設定にフォールバックします。これにより、プラグインコードは this.environment.config.build.outDir と書くだけでよく、outDir が環境単位で設定されているのかグローバルに設定されているのかを気にする必要がありません。Proxy がフォールバックを透過的に処理してくれます。

この設計は、環境ごとに設定全体をディープクローンしてマージするという代替手段を避けるためのものでもあります。そうしたアプローチはコストが高く、バグを生みやすいからです。

esbuild から Rolldown への互換レイヤー

Vite 8 には、レガシーな optimizeDeps.esbuildOptions を Rolldown の対応オプションにマッピングする互換レイヤーが備わっています。設定解決パイプラインの 1190〜1302 行目 がその実装です。

変換は体系的に行われます。各 esbuild オプションがチェックされ、対応する Rolldown オプションにマッピングされます。

esbuild オプション Rolldown の対応オプション 備考
minify rolldownOptions.output.minify 直接マッピング
treeShaking rolldownOptions.treeshake 直接マッピング
define rolldownOptions.transform.define 直接マッピング
loader rolldownOptions.moduleTypes copycssdefaultfilelocal-css を除外
preserveSymlinks rolldownOptions.resolve.symlinks 真偽値を反転
resolveExtensions rolldownOptions.resolve.extensions 直接マッピング
mainFields rolldownOptions.resolve.mainFields 直接マッピング
conditions rolldownOptions.resolve.conditionNames 直接マッピング
keepNames rolldownOptions.output.keepNames 直接マッピング
platform rolldownOptions.platform 直接マッピング

注目すべきは、変換できないオプションが丁寧にアノテーションされている点です。1267〜1301 行目 のコメントでは、変換不可なオプションを「根本的にマッピング不可能なもの」「マッピングは可能だが優先度が低いもの」「変換する意味がないもの」の 3 種類に分類しています。

このレイヤーは esbuild オプションが検出された場合に警告を出し、optimizeDeps.rolldownOptions への移行をユーザーに促します。esbuild オプションを使用しているエコシステムのプラグインが移行期間中も動き続けるための、実用的な橋渡しです。

perEnvironmentState ユーティリティ

環境ごとの状態管理が必要なプラグイン作者向けに、Vite は environment.ts にユーティリティを用意しています。

export function perEnvironmentState<State>(
  initial: (environment: Environment) => State,
): (context: PluginContext) => State {
  const stateMap = new WeakMap<Environment, State>()
  return function (context: PluginContext) {
    const { environment } = context
    let state = stateMap.get(environment)
    if (!state) {
      state = initial(environment)
      stateMap.set(environment, state)
    }
    return state
  }
}

WeakMap をバッキングストアとして使い、環境ごとの状態を遅延初期化するアクセサーを生成します。プラグインフックは getState(this) を呼ぶだけで、その環境に紐付いた状態オブジェクトを取得できます。WeakMap を使うことで、環境が破棄されたときに状態も自動的にガベージコレクトされます。

次回予告

ここまでで、Vite がユーザーの設定をどのように受け取り、環境ごとのオプションを Proxy で透過的に扱いながらフリーズされた ResolvedConfig へと解決していくかを見てきました。次回は、この設定を実際に動かす部分を追いかけます。createServer が HTTP サーバーを組み立て、18 層のミドルウェアを決められた順序で登録する流れ、そして .ts ファイルへの HTTP リクエストが transform パイプラインを経て import 書き換え済みの JavaScript として返されるまでの仕組みを解説します。