Read OSS

Vite 如何解析配置并构建插件流水线

高级

前置知识

  • 第 1 篇:架构概览与代码库导航
  • 了解 Rollup/Rolldown 插件钩子模型(resolveId、load、transform)
  • TypeScript 泛型与工具类型

Vite 如何解析配置并构建插件流水线

Vite 的配置系统是整个代码库中最复杂的部分,这背后有其充分的理由。它需要将多种来源的配置协调统一:用户的 vite.config.ts(可能是函数、Promise 或普通对象)、来自编程调用方的内联配置、各环境的覆盖选项、向后兼容的 SSR 选项,以及插件对配置的修改——最终将所有这些冻结为一个 ResolvedConfig,供系统其余部分以不可变对象的方式使用。这一切都发生在 src/node/config.ts 中,这个文件超过 2,700 行。

配置文件的加载策略

在解析配置之前,Vite 需要先找到并加载用户的配置文件。loadConfigFromFile() 函数支持三种加载策略,由 configLoader 选项控制:

  1. bundle(默认)——使用 Rolldown 将 vite.config.ts 编译为临时 JS 文件后再导入。这是最健壮的方式,能透明地处理 TypeScript、路径别名以及来自 node_modules 的导入。
  2. runner(实验性)——使用 Vite 自带的 module runner 在运行时处理配置文件,无需生成临时文件。
  3. native(实验性)——依赖 Node.js 原生 ESM 加载器,适用于配置文件本身已是纯 JavaScript 或使用了 Node.js 内置 TypeScript 支持的场景。

CLI 通过 --configLoader 暴露此选项:

// From cli.ts, line 182-183
.option('--configLoader <loader>',
  `[string] use 'bundle' to bundle the config with Rolldown, ...`)

配置文件的查找使用 DEFAULT_CONFIG_FILES 列表,包括 vite.config.jsvite.config.mjsvite.config.tsvite.config.cjsvite.config.mtsvite.config.cts,按顺序匹配,第一个命中的文件生效。

flowchart TD
    A["loadConfigFromFile()"] --> B{"configFile provided?"}
    B -->|yes| C["Use specified file"]
    B -->|no| D["Search DEFAULT_CONFIG_FILES"]
    C --> E{"configLoader?"}
    D --> E
    E -->|bundle| F["Rolldown-compile to temp .js"]
    E -->|runner| G["Vite module runner eval"]
    E -->|native| H["Node.js native import()"]
    F --> I["import(tempFile)"]
    G --> I
    H --> I
    I --> J{"Export is function?"}
    J -->|yes| K["Call with ConfigEnv"]
    J -->|no| L["Use object directly"]
    K --> M["Return { path, config, dependencies }"]
    L --> M

defineConfig() 是一个仅用于类型推断的恒等函数——它原样返回传入的参数,但提供了 TypeScript 的自动补全支持。它兼容所有导出形式:普通对象、Promise 或函数:

export function defineConfig(config: UserConfigExport): UserConfigExport {
  return config
}

resolveConfig() 流水线

配置系统的核心是 resolveConfig(),它通过一条多步骤流水线,将用户输入转换为完全解析、已冻结的配置对象。

flowchart TD
    A["resolveConfig(inlineConfig, command)"] --> B["Setup Rollup compat shims"]
    B --> C["loadConfigFromFile()"]
    C --> D["mergeConfig(fileConfig, inlineConfig)"]
    D --> E["Filter plugins by 'apply' field"]
    E --> F["Sort into pre/normal/post"]
    F --> G["Run plugin 'config' hooks"]
    G --> H["Ensure client & ssr environments"]
    H --> I["Resolve sub-options:<br/>server, build, CSS, SSR, preview"]
    I --> J["Resolve per-environment options"]
    J --> K["Run 'configEnvironment' hooks"]
    K --> L["Resolve plugins via resolvePlugins()"]
    L --> M["Run 'configResolved' hooks"]
    M --> N["Return frozen ResolvedConfig"]

让我们逐步梳理关键步骤。函数从第 1356 行开始:

export async function resolveConfig(
  inlineConfig: InlineConfig,
  command: 'build' | 'serve',
  defaultMode = 'development',
  defaultNodeEnv = 'development',
  isPreview = false,
  patchConfig, patchPlugins,
): Promise<ResolvedConfig> {

patchConfigpatchPlugins 是内部钩子,供 createBuilder() 在多环境构建时按环境覆盖 config.build 使用。

加载文件配置后,插件会根据 apply 属性进行过滤(第 1418–1428 行):

const filterPlugin = (p: Plugin | FalsyPlugin): p is Plugin => {
  if (!p) return false
  if (!p.apply) return true
  if (typeof p.apply === 'function') {
    return p.apply({ ...config, mode }, configEnv)
  }
  return p.apply === command
}

这就是为什么你可以给插件设置 apply: 'build' 来在开发模式下跳过它,或者使用函数实现更复杂的条件逻辑。

接下来是第 1443–1460 行的关键步骤:Vite 确保 clientssr 环境始终存在于配置中,并强制约束它们的顺序:

config.environments ??= {}
if (!config.environments.ssr && (!isBuild || config.ssr || config.build?.ssr)) {
  config.environments = { ssr: {}, ...config.environments }
}
if (!config.environments.client) {
  config.environments = { client: {}, ...config.environments }
}

这种"解构重排"的写法确保 client 排在最前,其次是 ssr,再之后是其他自定义环境——系统其余部分依赖这一固定顺序。

UserConfig → ResolvedConfig 的类型转换

理解类型系统有助于看清配置解析的契约。UserConfig 是用户编写的配置——几乎所有字段都是可选的:

export interface UserConfig extends DefaultEnvironmentOptions {
  root?: string
  base?: string
  publicDir?: string | false
  cacheDir?: string
  mode?: string
  plugins?: PluginOption[]
  css?: CSSOptions
  server?: ServerOptions
  environments?: Record<string, EnvironmentOptions>
  // ... 30+ more optional fields
}

ResolvedConfig 是系统内部消费的配置——字段均为必填、只读,且已完整解析:

export interface ResolvedConfig extends Readonly<
  Omit<UserConfig, 'plugins' | 'css' | 'json' | ...> & {
    root: string                              // was optional, now required
    base: string                              // resolved to absolute
    plugins: readonly Plugin[]                // flattened and sorted
    css: ResolvedCSSOptions                   // all defaults filled
    environments: Record<string, ResolvedEnvironmentOptions>
    // ...
  } & PluginHookUtils
> {}
classDiagram
    class UserConfig {
        root?: string
        base?: string
        plugins?: PluginOption[]
        environments?: Record~string, EnvironmentOptions~
        server?: ServerOptions
        build?: BuildEnvironmentOptions
    }
    class ResolvedConfig {
        root: string
        base: string
        plugins: readonly Plugin[]
        environments: Record~string, ResolvedEnvironmentOptions~
        server: ResolvedServerOptions
        build: ResolvedBuildOptions
        +getSortedPlugins()
        +getSortedPluginHooks()
    }
    UserConfig ..> ResolvedConfig : resolveConfig()

PluginHookUtils mixin 提供了 getSortedPlugins()getSortedPluginHooks() 方法,它们会缓存按钩子排序后的插件列表,在请求处理阶段实现高效访问。

环境选项的解析

每个环境的选项通过 resolveEnvironmentOptions() 解析,该函数将全局默认值与环境特定的覆盖配置合并。函数根据环境名称判断 consumer 类型(client 或 server):

const consumer = options.consumer ?? (isClientEnvironment ? 'client' : 'server')

开发模式专用的选项由 resolveDevEnvironmentOptions() 处理,它会根据 consumer 类型选取不同的默认值:client 环境使用 preTransformRequests: truerecoverable: true,server 环境则使用 moduleRunnerTransform: true

如第 1 篇所述,PartialEnvironment 构造函数的 Proxy 模式让这一解析过程在运行时透明呈现。当插件读取 this.environment.config.build.outDir 时,Proxy 检测到 build 存在于环境选项中,便返回环境特定的值;而当读取 this.environment.config.root 时,由于该字段不在 ResolvedEnvironmentOptions 中,Proxy 会向上透传到顶层配置。

提示: configDefaults 冻结对象记录了 Vite 使用的所有默认值,是"当用户未指定某项配置时会发生什么"的唯一权威来源。

内部插件的排序

配置解析完成后,resolvePlugins() 负责组装完整的插件数组。其排序是精确且有意为之的:

flowchart TD
    subgraph "Pre-user plugins"
        A1[optimizedDeps]
        A2[watchPackageData]
        A3[preAlias]
        A4[alias - native or JS]
    end
    subgraph "User pre plugins"
        B[enforce: 'pre' plugins]
    end
    subgraph "Core plugins"
        C1[modulePreloadPolyfill]
        C2[oxcResolve]
        C3[htmlInlineProxy]
        C4[css]
        C5[oxc - TS/JSX transform]
        C6[json - native]
        C7[wasm / webWorker / asset]
    end
    subgraph "User normal plugins"
        D[normal plugins]
    end
    subgraph "Post-core plugins"
        E1[wasmFallback]
        E2[define]
        E3[cssPost]
        E4[buildHtml]
        E5[workerImportMetaUrl]
        E6[dynamicImportVars / importGlob]
    end
    subgraph "User post plugins"
        F[enforce: 'post' plugins]
    end
    subgraph "Build post plugins"
        G1[importAnalysisBuild / terser]
        G2[license / manifest / reporter]
    end
    subgraph "Dev-only tail"
        H1[clientInjections]
        H2[cssAnalysis]
        H3[importAnalysis]
    end
    A1 --> B --> C1 --> D --> E1 --> F --> G1 --> H1

注意末尾追加的三个仅限开发模式的插件(第 126–132 行):clientInjectionsPlugincssAnalysisPluginimportAnalysisPlugin。这是开发模式下最重要的三个插件——其中 importAnalysisPlugin 负责将源码中的每一个 import 重写为无打包 ESM 的形式——它们必须排在最后,以便处理所有其他 transform 的输出结果。

除了全局排序外,每个插件内部的钩子还可以指定 order: 'pre' | 'post'getSortedPluginsByHook() 函数通过高效的原地插入方式处理这种逐钩子的排序。

Vite 专有的插件钩子

Vite 的 Plugin 接口 在 Rolldown 的 RolldownPlugin 基础上扩展了多个 Rollup/Rolldown 中没有对应项的钩子:

钩子 触发时机 用途
config 解析前 修改或扩展原始用户配置
configEnvironment 每个环境 修改环境特定配置
configResolved 解析后 读取最终冻结后的配置
configureServer 服务器创建时 添加 middleware、保存服务器引用
configurePreviewServer 预览服务器创建时 同上,用于预览服务器
transformIndexHtml HTML 响应时 注入标签、转换 HTML 内容
hotUpdate 文件变更时 控制 HMR 更新的传播
buildApp 构建开始时 编排多环境构建流程

enforce 属性(第 202–214 行)控制用户插件相对于 Vite 内部插件的插入位置:

enforce?: 'pre' | 'post'
// Plugin invocation order:
// - alias resolution
// - `enforce: 'pre'` plugins
// - vite core plugins
// - normal plugins
// - vite build plugins
// - `enforce: 'post'` plugins
// - vite build post plugins

applyToEnvironment 钩子(第 227–229 行)是 Environment API 新增的能力——它允许插件按环境条件激活,甚至为不同环境返回完全不同的插件实例:

applyToEnvironment?: (
  environment: PartialEnvironment,
) => boolean | Promise<boolean> | PluginOption

提示: sharedDuringBuild 标志(第 184 行)控制在执行 vite build --app 时,插件实例是否跨环境共享。默认情况下,为了向后兼容,每个环境都会重新创建插件实例。将其设置为 true 可以启用更高效的共享模式。

下一步

理解了配置解析流水线和插件排序机制后,接下来我们将探索浏览器向开发服务器请求模块时究竟发生了什么。下一篇文章将完整追踪一次请求的生命周期:从 connect middleware 栈,经过 transform 流水线,穿越 plugin container,最终进入跟踪所有模块关系、支撑 HMR 运作的模块图。