Vite 设计与实现

第3章 配置系统

作者 杨艺韬 · 12,543 字 · 发布于 · 更新于

"Simplicity is the ultimate sophistication." -- Leonardo da Vinci

本章要点

  • 深入剖析 config.ts(2703 行),理解 Vite 配置系统的完整架构
  • 掌握 UserConfig 到 ResolvedConfig 的转换过程及其设计动因
  • 理解配置文件的多种加载策略:bundle、runner 和 native
  • 剖析 resolveConfig() 函数的完整流程——从内联配置到最终的解析配置
  • 理解 .env 文件的加载机制与环境变量暴露策略
  • 掌握配置合并的层级关系:插件 config 返回值 > 内联 > 配置文件 > 默认值

3.1 config.ts:一个 2703 行的单文件

packages/vite/src/node/config.ts 有 2703 行,在 src/node/ 下仅次于 plugins/css.ts(3552 行),比 build.ts(1922 行)和 utils.ts(1795 行)都大。这并非偶然——配置系统是连接用户意图与内部实现的桥梁,它需要处理类型定义、默认值、多层合并、环境适配、向后兼容等诸多关切。在任何复杂的软件系统中,配置层往往是最容易膨胀的部分,因为它要同时满足"对新手友好"(合理的默认值)和"对专家开放"(细粒度的控制能力)两个看似矛盾的需求。

为什么没有将这个文件拆分成多个小文件?源码没有说明,笔者推测主要是出于内聚性的考虑。UserConfig 接口被 Vite 几乎所有模块引用,如果将其与 ResolvedConfig、resolveConfig、loadConfigFromFile 等密切相关的定义分开,会引入大量的跨文件导入,增加理解成本。此外,TypeScript 的类型在同一文件中更容易管理和导出。尽管代码量大,但该文件内部有着清晰的逻辑分区。

让我们首先了解这个文件的整体组织:

graph TB
    subgraph "config.ts 文件结构 (2703 行)"
        direction TB
        A["1-130 行<br/>导入与类型引用"] --> B
        B["131-183 行<br/>ConfigEnv / defineConfig<br/>配置辅助类型"] --> C
        C["184-620 行<br/>DevEnvironmentOptions<br/>EnvironmentOptions<br/>UserConfig 接口定义"] --> D
        D["624-727 行<br/>ResolvedConfig 接口"] --> E
        E["762-870 行<br/>configDefaults<br/>全局默认配置"] --> F
        F["870-1356 行<br/>辅助函数<br/>(resolveEnvironmentOptions<br/>resolveResolveOptions 等)"] --> G
        G["1356-2143 行<br/>resolveConfig() 核心函数<br/>(约 790 行)"] --> H
        H["2142-2212 行<br/>resolveBaseUrl<br/>sortUserPlugins"] --> I
        I["2213-2590 行<br/>loadConfigFromFile<br/>bundleConfigFile<br/>loadConfigFromBundledFile"] --> J
        J["2591-2703 行<br/>runConfigHook<br/>runConfigEnvironmentHook<br/>向后兼容处理"]
    end
    style G fill:#ff9800,color:white
    style I fill:#2196f3,color:white
    style D fill:#4caf50,color:white

3.2 UserConfig:用户的配置空间

UserConfig 接口(约第 339 行)定义了用户可以在 vite.config.ts 中配置的完整选项集。它继承自 DefaultEnvironmentOptions,后者包含可以在环境级别覆盖的选项(如 define、resolve、optimizeDeps、dev、build):

// 文件: packages/vite/src/node/config.ts

export interface UserConfig extends DefaultEnvironmentOptions {
  root?: string               // 项目根目录,默认 process.cwd()
  base?: string               // 公共基础路径,默认 '/'
  publicDir?: string | false  // 静态资源目录,默认 'public'
  cacheDir?: string           // 缓存目录,默认 'node_modules/.vite'
  mode?: string               // 运行模式
  plugins?: PluginOption[]    // 插件数组
  html?: HTMLOptions          // HTML 相关选项
  css?: CSSOptions            // CSS 相关选项
  json?: JsonOptions          // JSON 加载选项
  esbuild?: ESBuildOptions | false  // (已弃用) esbuild 转换选项
  oxc?: OxcOptions | false    // Oxc 转换选项
  assetsInclude?: string | RegExp | (string | RegExp)[]  // 额外的资源类型
  server?: ServerOptions      // 开发服务器选项
  preview?: PreviewOptions    // 预览服务器选项
  builder?: BuilderOptions    // 构建器选项
  worker?: { ... }            // Web Worker 选项
  ssr?: SSROptions            // SSR 选项
  envDir?: string | false     // .env 文件目录
  envPrefix?: string | string[]  // 暴露到客户端的环境变量前缀,默认 'VITE_'
  environments?: Record<string, EnvironmentOptions>  // 环境级配置
  experimental?: ExperimentalOptions
  future?: FutureOptions | 'warn'
  legacy?: LegacyOptions
  logLevel?: LogLevel
  customLogger?: Logger
  clearScreen?: boolean
  appType?: AppType           // 应用类型: 'spa' | 'mpa' | 'custom'
  devtools?: boolean | DevToolsConfig  // Vite DevTools
}

UserConfig 的设计体现了一个重要原则:所有选项都是可选的。用户可以从空对象 {} 开始,只配置自己关心的选项,其余由 Vite 的默认值系统填充。这种设计遵循了"约定优于配置"的哲学——Vite 为大多数选项提供了默认值(另有一些要在解析时推断,不在默认值表里,见 packages/vite/src/node/config.ts:761 的注释),大多数项目无需任何配置即可正常工作。

值得注意的是 UserConfig 继承自 DefaultEnvironmentOptions 而非直接定义所有字段。DefaultEnvironmentOptions 包含了可以在环境级别(environments.client、environments.ssr)覆盖的选项,如 define、resolve、optimizeDeps、dev、build。这意味着顶级配置中设置的这些选项实际上是环境的默认值(optimizeDeps 例外,只作为 client 环境的默认值,见 3.2.1),每个环境可以选择性地覆盖。这种继承关系是 Vite 6 引入的 Environment API 的基础设计。

另一个值得关注的类型设计是 UserConfigExport 联合类型。它不仅接受普通对象,还接受函数和 Promise 形式。函数形式使得配置可以根据运行时上下文(如 command、mode)动态生成,Promise 形式支持异步的配置初始化(如从远程获取配置)。这种灵活性使得 Vite 配置能够适应各种复杂的工程场景。

3.2.1 Environment API 带来的配置层级

Vite 6.0 引入 Environment API 后,配置形成了两层结构:

graph TB
    subgraph "顶级配置 (UserConfig)"
        Root["root, base, publicDir..."]
        TopResolve["resolve (默认值)"]
        TopBuild["build (默认值)"]
        TopDev["dev (默认值)"]
        TopOptDeps["optimizeDeps (默认值)"]
        TopPlugins["plugins"]
        TopServer["server"]
    end
    subgraph "环境级配置 (environments)"
        subgraph "client"
            CResolve["resolve"]
            CBuild["build"]
            CDev["dev"]
            COptDeps["optimizeDeps"]
        end
        subgraph "ssr"
            SResolve["resolve"]
            SBuild["build"]
            SDev["dev"]
            SOptDeps["optimizeDeps"]
        end
        subgraph "自定义环境"
            XResolve["resolve"]
            XBuild["build"]
        end
    end
    TopResolve -->|"继承"| CResolve
    TopResolve -->|"继承"| SResolve
    TopBuild -->|"继承"| CBuild
    TopBuild -->|"继承"| SBuild
    TopDev -->|"继承"| CDev
    TopDev -->|"继承"| SDev
    TopOptDeps -->|"继承"| COptDeps
    style client fill:#e3f2fd,stroke:#1565c0
    style ssr fill:#e8f5e9,stroke:#2e7d32

顶级配置中的 define、resolve、build、dev 作为所有环境的默认值(getDefaultEnvironmentOptions,packages/vite/src/node/config.ts:979-993)。client 环境额外继承顶级 resolve 里的 mainFields 和 conditions,以及顶级 optimizeDeps;非客户端环境拿不到这三项,而且 dev.createEnvironment、dev.warmup、build.createEnvironment 会被清空,默认值更保守(packages/vite/src/node/config.ts:1545-1563)。

这种层级设计解决了一个实际问题:在引入 Environment API 之前,用户想要为 SSR 设置不同的模块解析策略(例如不同的 conditions 或 external 列表),需要使用分散的 ssr.external、ssr.noExternal 等选项。现在,用户可以在 environments.ssr.resolve 中集中配置 SSR 相关的解析选项,同时让 environments.client 继承顶级的 resolve 配置。如果需要创建自定义环境(如边缘运行时),只需在 environments 下添加一个新的键值对,它会自动继承顶级默认值。

3.3 defineConfig():类型辅助的精妙设计

defineConfig 是 Vite 提供给用户的类型辅助函数。在 JavaScript 中,它完全没有运行时效果;在 TypeScript 中,它通过精心设计的类型重载为配置对象提供完整的智能提示和类型检查。这是一种在现代前端工具中广泛使用的模式——通过一个恒等函数为用户提供类型安全的编写体验。

让我们看看它的多重载签名:

// 文件: packages/vite/src/node/config.ts

export function defineConfig(config: UserConfig): UserConfig
export function defineConfig(config: Promise<UserConfig>): Promise<UserConfig>
export function defineConfig(config: UserConfigFnObject): UserConfigFnObject
export function defineConfig(config: UserConfigFnPromise): UserConfigFnPromise
export function defineConfig(config: UserConfigFn): UserConfigFn
export function defineConfig(config: UserConfigExport): UserConfigExport
export function defineConfig(config: UserConfigExport): UserConfigExport {
  return config
}

这个函数的实现仅仅是 return config——它不做任何运行时处理。它的全部价值在于 TypeScript 类型推断:

// 用法一:直接配置对象,获得完整的类型提示
export default defineConfig({
  server: { port: 3000 },
})

// 用法二:函数形式,可以根据命令和模式动态配置
export default defineConfig(({ command, mode }) => {
  if (command === 'serve') {
    return { /* 开发配置 */ }
  } else {
    return { /* 构建配置 */ }
  }
})

// 用法三:异步函数
export default defineConfig(async ({ command }) => {
  const data = await someAsyncOperation()
  return {  }
})

ConfigEnv 对象传递给配置函数,包含关键的上下文信息:

// 文件: packages/vite/src/node/config.ts

export interface ConfigEnv {
  command: 'build' | 'serve'  // 当前执行的命令
  mode: string                 // 运行模式(development, production, 自定义)
  isSsrBuild?: boolean        // 是否为 SSR 构建
  isPreview?: boolean          // 是否为预览模式
}

3.4 configDefaults:默认值系统

默认值系统是配置架构的根基。好的默认值意味着用户不需要理解每一个选项就能开始工作,同时又不会在遇到特殊需求时被限制住。

Vite 的默认配置定义在一个冻结的对象中(约第 762 行),防止顶层默认值被意外改写(浅冻结,见 3.12.2):

// 文件: packages/vite/src/node/config.ts

const configDefaults = Object.freeze({
  define: {},
  dev: {
    warmup: [],
    sourcemap: { js: true },
    sourcemapIgnoreList: undefined,
  },
  build: buildEnvironmentOptionsDefaults,
  resolve: {
    externalConditions: [...DEFAULT_EXTERNAL_CONDITIONS],
    extensions: DEFAULT_EXTENSIONS,  // ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json']
    dedupe: [],
    noExternal: [],
    external: [],
    preserveSymlinks: false,
    tsconfigPaths: false,
    alias: [],
  },

  base: '/',
  publicDir: 'public',
  plugins: [],
  html: { cspNonce: undefined },
  css: cssConfigDefaults,
  json: { namedExports: true, stringify: 'auto' },
  assetsInclude: undefined,
  builder: builderOptionsDefaults,
  server: serverConfigDefaults,
  preview: { port: DEFAULT_PREVIEW_PORT },
  experimental: {
    importGlobRestoreExtension: false,
    renderBuiltUrl: undefined,
    hmrPartialAccept: false,
    bundledDev: false,
  },
  future: {
    removePluginHookHandleHotUpdate: undefined,
    removePluginHookSsrArgument: undefined,
    removeServerModuleGraph: undefined,
    removeServerHot: undefined,
    removeServerTransformRequest: undefined,
    removeServerWarmupRequest: undefined,
    removeSsrLoadModule: undefined,
  },
  legacy: { skipWebSocketTokenCheck: false },
  logLevel: 'info',
  customLogger: undefined,
  clearScreen: true,
  envDir: undefined,
  envPrefix: 'VITE_',
  worker: {
    format: 'iife',
    plugins: (): never[] => [],
  },
  optimizeDeps: {
    include: [],
    exclude: [],
    needsInterop: [],
    rolldownOptions: {},
    extensions: [],
    disabled: 'build',
    holdUntilCrawlEnd: true,
    force: false,
    ignoreOutdatedRequests: false,
  },
  ssr: ssrConfigDefaults,
  environments: {},
  appType: 'spa',
} satisfies UserConfig)

注意几个值得关注的默认值:

  • json.stringify: 'auto':超过 10 kB(rolldown 的 crates/rolldown_plugin_utils/src/constants.rs:12,THRESHOLD_SIZE = 10 * 1000)时改用 JSON.parse() 代替对象字面量,提升加载性能。注意它和同表的 namedExports: true 互相牵制:整模块的 JSON.parse 分支要求 !is_name_exports(crates/rolldown_plugin_vite_json/src/lib.rs:47-50),所以在默认的具名导出下,一个顶层是对象的 JSON 再大也走不到这条路,只有其中体积超阈值的对象属性值会被逐个包成 JSON.parse(...)(crates/rolldown_plugin_utils/src/data_to_esm.rs:9-17)
  • worker.format: 'iife':Web Worker 默认输出为 IIFE 格式,确保最广泛的浏览器兼容性
  • optimizeDeps.holdUntilCrawlEnd: true:冷启动时先扣住首次预构建的结果,等静态导入全部爬取完毕再交给浏览器,减少新依赖被发现后触发整页重载的概率

3.5 resolveConfig():配置解析的核心引擎

resolveConfig 是整个配置系统的核心函数,位于第 1356 行,长达约 790 行。这是 Vite 启动过程中最先执行的关键函数之一——无论是 vite 开发命令还是 vite build 构建命令,第一步都是调用 resolveConfig 来获得完整的配置。

该函数接收用户的 InlineConfig(来自命令行参数和 API 调用的配置),经过十余个步骤,生成最终的 ResolvedConfig。这个过程涉及文件加载、插件执行、多层合并、选项标准化等复杂操作,是理解 Vite 配置系统的关键路径。

flowchart TB
    Start["resolveConfig(inlineConfig, command, defaultMode)"] --> Step1
    Step1["1. 初始化<br/>设置 NODE_ENV<br/>创建 ConfigEnv"] --> Step2
    Step2["2. loadConfigFromFile<br/>加载配置文件"] --> Step3
    Step3["3. mergeConfig<br/>合并内联配置与文件配置<br/>(内联优先级更高)"] --> Step4
    Step4["4. 确定最终 mode<br/>inlineConfig.mode > config.mode > defaultMode"] --> Step5
    Step5["5. sortUserPlugins<br/>按 enforce 分类插件<br/>(pre / normal / post)"] --> Step6
    Step6["6. runConfigHook<br/>执行所有插件的 config 钩子"] --> Step7
    Step7["7. 环境配置初始化<br/>确保 client 和 ssr 环境存在<br/>合并默认环境配置"] --> Step8
    Step8["8. runConfigEnvironmentHook<br/>执行 configEnvironment 钩子"] --> Step9
    Step9["9. 解析子选项<br/>resolve / server / build / css<br/>optimizeDeps / ssr 等"] --> Step10
    Step10["10. loadEnv<br/>加载 .env 文件"] --> Step11
    Step11["11. 组装 ResolvedConfig<br/>合并所有解析后的选项"] --> Step12
    Step12["12. resolvePlugins<br/>解析完整插件列表"] --> Step13
    Step13["13. configResolved 钩子<br/>通知所有插件配置已解析完成"] --> Step14
    Step14["14. resolveEnvironmentPlugins<br/>为每个环境解析专属插件"] --> Step15
    Step15["15. 配置验证与警告"] --> End
    End["返回 ResolvedConfig"]
    style Start fill:#1976d2,color:white
    style End fill:#388e3c,color:white
    style Step6 fill:#ff9800,color:white
    style Step11 fill:#9c27b0,color:white

让我们逐步追踪这个函数的执行过程。每个步骤都有其存在的必要性,跳过任何一步都可能导致配置的不完整或不一致。

3.5.1 步骤一:初始化与兼容性处理

// 文件: packages/vite/src/node/config.ts

export async function resolveConfig(
  inlineConfig: InlineConfig,
  command: 'build' | 'serve',
  defaultMode = 'development',
  defaultNodeEnv = 'development',
  isPreview = false,
  patchConfig?: (config: ResolvedConfig) => void,
  patchPlugins?: (resolvedPlugins: Plugin[]) => void,
): Promise<ResolvedConfig> {
  let config = inlineConfig
  // 向后兼容:确保 rollupOptions 和 rolldownOptions 同步
  config.build ??= {}
  setupRollupOptionCompat(config.build, 'build')
  config.worker ??= {}
  setupRollupOptionCompat(config.worker, 'worker')
  config.optimizeDeps ??= {}
  setupRollupOptionCompat(config.optimizeDeps, 'optimizeDeps')
  // ...(config.ssr.optimizeDeps 同样处理;configFileDependencies、packageCache 初始化,略)

  let mode = inlineConfig.mode || defaultMode
  const isNodeEnvSet = !!process.env.NODE_ENV

  // 尽早设置 NODE_ENV,因为部分依赖(如 @vue/compiler-*)依赖它
  if (!isNodeEnvSet) {
    process.env.NODE_ENV = defaultNodeEnv
  }

  const configEnv: ConfigEnv = {
    mode,
    command,
    isSsrBuild: command === 'build' && !!config.build?.ssr,
    isPreview,
  }
  // ...
}

函数签名中的 patchConfig 和 patchPlugins 参数标记为 @internal,它们是供 Vite 内部的构建器(Builder)使用的回调。在多环境构建场景下,Builder 需要在配置组装完成后用当前环境的 build 选项覆盖顶层 config.build(patchConfig),并让声明了共享的插件复用同一实例(patchPlugins,见 packages/vite/src/node/build.ts:1872-1898),这两个回调提供了一个安全的注入点,避免了对 resolveConfig 流程的侵入式修改。

setupRollupOptionCompat 函数处理了 Vite 从 Rollup 到 Rolldown 的命名迁移。由于许多用户和插件仍在使用 rollupOptions 这个名称,该函数把 rollupOptions 变成 rolldownOptions 的访问器别名:只设了 rollupOptions 时先把它的值搬到 rolldownOptions,此后读写任一名称都落到同一个对象;两者都设时以 rolldownOptions 为准(packages/vite/src/node/utils.ts:1252-1285)。

3.5.2 步骤二:加载配置文件

// packages/vite/src/node/config.ts:1397
let { configFile } = config
if (configFile !== false) {
  const loadResult = await loadConfigFromFile(
    configEnv,
    configFile,
    config.root,
    config.logLevel,
    config.customLogger,
    config.configLoader,
  )
  if (loadResult) {
    config = mergeConfig(loadResult.config, config)
    configFile = loadResult.path
    configFileDependencies = loadResult.dependencies
  }
}

注意 mergeConfig 的参数顺序:mergeConfig(loadResult.config, config) 意味着内联配置(config,即命令行参数)优先于文件配置(loadResult.config)。这确保了 vite build --mode production 能覆盖配置文件中的 mode 设置。

3.5.3 步骤三:解析 mode

// 用户可能在配置文件中设置了 mode,但 --mode 标志优先级更高
mode = inlineConfig.mode || config.mode || mode
configEnv.mode = mode

mode 的优先级链为:命令行 --mode > 配置文件 config.mode > 默认值(development 或 production)。

mode 在 Vite 中扮演着多重角色。首先,它决定了 .env 文件的加载规则——mode 为 staging 时会加载 .env.staging 文件。其次,它影响了 import.meta.env.MODE 的值,客户端代码可以据此调整行为。第三,它通过 ConfigEnv 传递给配置函数,使得用户可以根据 mode 返回不同的配置。需要注意的是,mode 与 NODE_ENV 是独立的概念——mode 是 Vite 的抽象,NODE_ENV 是 Node.js 生态的约定。虽然通常 development mode 对应 NODE_ENV=development,但用户完全可以创建自定义 mode(如 staging),此时 NODE_ENV 仍是按命令给出的默认值;.env 文件只能在进程未设 NODE_ENV 时把它改成 development,写其他值只会得到警告(见 3.8.2)。

3.5.4 步骤四:插件分类与 config 钩子

// 过滤插件:移除 falsy 值,检查 apply 条件
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
}

const rawPlugins = (await asyncFlatten(config.plugins || [])).filter(filterPlugin)

// 按 enforce 字段分类
const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawPlugins)

sortUserPlugins 函数实现简洁明了:

// 文件: packages/vite/src/node/config.ts

export function sortUserPlugins(
  plugins: (Plugin | Plugin[])[] | undefined,
): [Plugin[], Plugin[], Plugin[]] {
  const prePlugins: Plugin[] = []
  const postPlugins: Plugin[] = []
  const normalPlugins: Plugin[] = []

  if (plugins) {
    plugins.flat().forEach((p) => {
      if (p.enforce === 'pre') prePlugins.push(p)
      else if (p.enforce === 'post') postPlugins.push(p)
      else normalPlugins.push(p)
    })
  }

  return [prePlugins, normalPlugins, postPlugins]
}

接着执行所有插件的 config 钩子,允许插件修改或扩展配置:

const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins]
config = await runConfigHook(config, userPlugins, configEnv)

runConfigHook 的实现揭示了插件如何影响配置:

// 文件: packages/vite/src/node/config.ts

async function runConfigHook(
  config: InlineConfig,
  plugins: Plugin[],
  configEnv: ConfigEnv,
): Promise<InlineConfig> {
  let conf = config

  const tempLogger = createLogger(config.logLevel, {
    allowClearScreen: config.clearScreen,
    customLogger: config.customLogger,
  })
  const context = new BasicMinimalPluginContext<
    Omit<PluginContextMeta, 'watchMode'>
  >(basePluginContextMeta, tempLogger)

  for (const p of getSortedPluginsByHook('config', plugins)) {
    const hook = p.config
    const handler = getHookHandler(hook)
    const res = await handler.call(context, conf, configEnv)
    if (res && res !== conf) {
      // 检查弃用选项并发出警告
      if (hasBothRollupOptionsAndRolldownOptions(res)) {
        context.warn(
          `Both \`rollupOptions\` and \`rolldownOptions\` were specified by ${JSON.stringify(p.name)} plugin. ` +
            `\`rollupOptions\` specified by that plugin will be ignored.`,
        )
      }
      if (res.esbuild) {
        context.warn(
          `\`esbuild\` option was specified by ${JSON.stringify(p.name)} plugin. ` +
            `This option is deprecated, please use \`oxc\` instead.`,
        )
      }
      if (res.optimizeDeps?.esbuildOptions) {
        context.warn(/* 同上:请改用 optimizeDeps.rolldownOptions */)
      }
      conf = mergeConfig(conf, res)
    }
  }

  return conf
}

每个插件的 config 钩子返回值与当前配置进行深度合并。插件依次执行,后执行的插件能看到前面插件的修改结果。

3.5.5 步骤五:环境配置初始化

这是 Vite 6.0 随 Environment API 新增的关键步骤,体现了 Environment API 的核心设计。在旧版 Vite 中,SSR 配置散落在多个顶级选项中(config.ssr、config.resolve、config.optimizeDeps),既不直观也难以扩展。Environment API 将不同运行环境的配置集中到 config.environments 对象下,每个环境是一个独立的命名空间。

首先确保 client 和 ssr 环境始终存在:

// packages/vite/src/node/config.ts:1445
// 确保默认的 client 和 ssr 环境存在
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: {}, ...config.environments } 确保 client 排在第一位,保证环境遍历时的确定性顺序。

随后,顶级配置作为默认值合并到每个环境配置中:

// packages/vite/src/node/config.ts:1545
const defaultEnvironmentOptions = getDefaultEnvironmentOptions(config)

// 客户端环境继承顶级的 resolve 和 optimizeDeps
const defaultClientEnvironmentOptions: UserConfig = {
  ...defaultEnvironmentOptions,
  resolve: config.resolve,
  optimizeDeps: config.optimizeDeps,
}
// 非客户端环境使用更保守的默认值
const defaultNonClientEnvironmentOptions: UserConfig = {
  ...defaultEnvironmentOptions,
  dev: {
    ...defaultEnvironmentOptions.dev,
    createEnvironment: undefined,
    warmup: undefined,
  },
  build: {
    ...defaultEnvironmentOptions.build,
    createEnvironment: undefined,
  },
}

for (const name of Object.keys(config.environments)) {
  config.environments[name] = mergeConfig(
    name === 'client'
      ? defaultClientEnvironmentOptions
      : defaultNonClientEnvironmentOptions,
    config.environments[name],
  )
}

这一步之后,执行 configEnvironment 钩子,允许插件针对特定环境修改配置:

await runConfigEnvironmentHook(
  config.environments,
  userPlugins,
  logger,
  configEnv,
  config.ssr?.target === 'webworker',
)

3.5.6 步骤六:解析子选项

到这一步,用户配置、文件配置、插件修改和环境默认值已经全部合并完毕。接下来需要将各个子配置从宽松的用户格式转换为严格的内部格式。每个子模块都有专门的解析函数,负责填充默认值、验证选项、转换类型:

// packages/vite/src/node/config.ts:1589
// 解析 resolve 选项
const resolvedDefaultResolve = resolveResolveOptions(config.resolve, logger)

// 解析每个环境的选项
const resolvedEnvironments: Record<string, ResolvedEnvironmentOptions> = {}
for (const environmentName of Object.keys(config.environments)) {
  resolvedEnvironments[environmentName] = resolveEnvironmentOptions(
    config.environments[environmentName],
    resolvedDefaultResolve.alias,
    resolvedDefaultResolve.preserveSymlinks,
    inlineConfig.forceOptimizeDeps,
    logger,
    environmentName,
    isBundledDev,
    config.ssr?.target === 'webworker',
    config.server?.preTransformRequests,
  )
}

// 解析 build 选项
const resolvedBuildOptions = resolveBuildEnvironmentOptions(
  config.build ?? {}, logger, undefined, isBundledDev,
)

// 解析 SSR 选项(向后兼容)
const ssr = resolveSSROptions(patchedConfigSsr, resolvedDefaultResolve.preserveSymlinks)

server、builder、preview 等选项则要等到下一步 .env 加载完毕、base 与 cacheDir 解析之后才调用 resolveServerOptions、resolveBuilderOptions、resolvePreviewOptions 解析;css 选项直接在组装 ResolvedConfig 时以 resolveCSSOptions(config.css) 内联解析。

3.5.7 步骤七:加载环境变量

// 确定 envDir
let envDir = config.envFile === false ? false : config.envDir
if (envDir !== false) {
  envDir = config.envDir
    ? normalizePath(path.resolve(resolvedRoot, config.envDir))
    : resolvedRoot
}

const userEnv = loadEnv(mode, envDir, resolveEnvPrefix(config))

环境变量加载发生在配置文件与插件 config 钩子都合并完毕之后,因此配置文件和插件设置的 envDir、envPrefix 都已可用。注意 envFile: false 会被当作 envDir: false 处理,这是一个向后兼容分支。

3.5.8 步骤八:组装 ResolvedConfig

这是函数中最长的一步,将所有解析后的选项组装成最终的 ResolvedConfig 对象:

// 文件: packages/vite/src/node/config.ts (约第 1878 行)

resolved = {
  configFile: configFile ? normalizePath(configFile) : undefined,
  configFileDependencies: configFileDependencies.map((name) =>
    normalizePath(path.resolve(name)),
  ),
  inlineConfig,
  root: resolvedRoot,
  base,                            // 已处理的 base URL (带尾斜杠)
  decodedBase: decodeBase(base),
  rawBase: resolvedBase,           // 未添加尾斜杠的原始 base
  publicDir: resolvedPublicDir,
  cacheDir,
  command,
  mode,
  isBundled: config.experimental?.bundledDev || isBuild,
  isWorker: false,
  mainConfig: null,
  bundleChain: [],
  isProduction,
  plugins: userPlugins,            // 占位符,后续被替换
  css: resolveCSSOptions(config.css),
  json: mergeWithDefaults(configDefaults.json, config.json ?? {}),
  oxc: /* ... Oxc/esbuild 转换处理 ... */,
  server,
  builder,
  preview,
  envDir,
  env: {
    ...userEnv,                    // 用户 .env 文件中的变量
    BASE_URL,                      // 注入 BASE_URL
    MODE: mode,                    // 注入 MODE
    DEV: !isProduction,            // 注入 DEV
    PROD: isProduction,            // 注入 PROD
  },
  assetsInclude(file: string) {
    return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file)
  },
  logger,
  packageCache,
  worker: resolvedWorkerOptions,
  appType: config.appType ?? 'spa',
  experimental,
  future,
  ssr,
  optimizeDeps: backwardCompatibleOptimizeDeps,
  resolve: resolvedDefaultResolve,
  dev: resolvedDevEnvironmentOptions,
  build: resolvedBuildOptions,
  environments: resolvedEnvironments,
  webSocketToken: Buffer.from(
    crypto.getRandomValues(new Uint8Array(9)),
  ).toString('base64url'),         // 随机 WebSocket 认证令牌
  // ... 更多内部属性
}

// 确保用户配置中的所有选项都被包含
resolved = { ...config, ...resolved }

最后一行 { ...config, ...resolved } 是一个精妙的设计——它确保用户在配置中设置的任何"未知"属性都会被保留在最终配置中。这为插件提供了自定义配置透传的能力:插件可以定义自己的配置键(如 myPlugin: { option1: true }),即使这个键不在 UserConfig 的类型定义中,它也不会在合并过程中丢失,插件在 configResolved 钩子里仍能从最终配置读到它。这种开放性使得 Vite 的配置系统具有良好的可扩展性。

注意 env 对象中自动注入了四个内置变量:BASE_URL(应用的基础路径)、MODE(当前运行模式)、DEV 和 PROD(二者互为取反,都取自上面由 NODE_ENV 算出的 isProduction,并不是 MODE 的简写)。这些变量在客户端代码中可以通过 import.meta.env.BASE_URL 等方式访问,它们与用户定义的 VITE_ 前缀变量一起构成了完整的客户端环境变量集合。

3.5.9 步骤九:解析插件并通知完成

// packages/vite/src/node/config.ts:2023
// 解析完整的插件列表
const resolvedPlugins = await resolvePlugins(
  resolved, prePlugins, normalPlugins, postPlugins,
)
patchPlugins?.(resolvedPlugins)
;(resolved.plugins as Plugin[]) = resolvedPlugins

// 绑定插件排序工具
Object.assign(resolved, createPluginHookUtils(resolved.plugins))

// 调用所有插件的 configResolved 钩子
await Promise.all(
  resolved
    .getSortedPluginHooks('configResolved')
    .map((hook) => hook.call(resolvedConfigContext, resolved)),
)

// 为每个环境解析专属插件
for (const name of Object.keys(resolved.environments)) {
  resolved.environments[name].plugins = await resolveEnvironmentPlugins(
    new PartialEnvironment(name, resolved),
  )
}

configResolved 钩子与 config 钩子的关键区别在于:前者接收的是已解析的完整配置(只读),后者接收的是用户配置(可修改)。这意味着插件应该在 config 钩子中修改配置,在 configResolved 钩子中读取最终配置并初始化自身状态。例如,一个构建分析插件会在 config 钩子中注入必要的 Rolldown 选项,然后在 configResolved 钩子中读取最终的 outDir 和 base 来初始化报告生成器。

还有一个值得注意的细节:configResolved 钩子中的 resolved 对象被 Readonly<> 包装,TypeScript 层面上阻止了修改。但由于 JavaScript 没有真正的不可变性,一些生态插件仍然会在此钩子中修改配置(例如 Vike 框架会修改 build.ssrEmitAssets)。Vite 团队通过在关键位置添加向后兼容逻辑来容忍这种行为,但不鼓励新插件这样做。

3.6 ResolvedConfig:驱动系统的最终配置

如果说 UserConfig 是用户向 Vite 表达"我想要什么"的语言,那么 ResolvedConfig 就是 Vite 内部表达"系统将如何运行"的语言。从 UserConfig 到 ResolvedConfig 的转换,本质上是从"声明式意图"到"命令式指令"的翻译过程。在 ResolvedConfig 中,所有的模糊性都被消除了:可选字段变为必填,相对路径变为绝对路径,字符串模式变为函数,默认值已经填充。

ResolvedConfig 接口(第 624 行)定义了配置解析完成后的完整形态:

// 文件: packages/vite/src/node/config.ts

export interface ResolvedConfig extends Readonly<
  Omit<UserConfig, 'plugins' | 'css' | 'json' | 'assetsInclude'
    | 'optimizeDeps' | 'worker' | 'build' | 'dev'
    | 'environments' | 'experimental' | 'future'
    | 'server' | 'preview' | 'devtools'
  > & {
    configFile: string | undefined
    configFileDependencies: string[]
    inlineConfig: InlineConfig
    root: string                    // 解析为绝对路径
    base: string                    // 标准化为带尾斜杠的路径
    publicDir: string               // 解析为绝对路径
    cacheDir: string                // 解析为绝对路径
    command: 'build' | 'serve'
    mode: string
    isBundled: boolean              // build 或 bundledDev 模式
    isWorker: boolean
    isProduction: boolean
    envDir: string | false
    env: Record<string, any>        // 包含 BASE_URL, MODE, DEV, PROD
    resolve: Required<ResolveOptions> & { alias: Alias[] }
    plugins: readonly Plugin[]      // 完整的插件列表(只读)
    css: ResolvedCSSOptions
    json: Required<JsonOptions>
    server: ResolvedServerOptions
    build: ResolvedBuildOptions
    environments: Record<string, ResolvedEnvironmentOptions>
    assetsInclude: (file: string) => boolean  // 变为函数
    logger: Logger
    createResolver: (options?) => ResolveFn
    webSocketToken: string
    fsDenyGlob: AnymatchFn          // 文件系统访问拒绝匹配器
    safeModulePaths: Set<string>    // 安全模块路径缓存
    // ... 省略 esbuild、oxc、dev、builder、preview、ssr、devtools、worker 等字段
  } & PluginHookUtils
> {}

与 UserConfig 相比,ResolvedConfig 有几个关键转变:

graph LR
    subgraph "UserConfig (用户输入)"
        U1["root?: string<br/>(可选,可能相对)"]
        U2["plugins?: PluginOption[]<br/>(可选,可嵌套)"]
        U3["assetsInclude?: string | RegExp | ...<br/>(可选,多种类型)"]
        U4["resolve?: ResolveOptions<br/>(可选,部分字段)"]
        U5["environments?: Record<...><br/>(可选)"]
    end
    subgraph "ResolvedConfig (系统使用)"
        R1["root: string<br/>(必填,绝对路径)"]
        R2["plugins: readonly Plugin[]<br/>(必填,扁平化,只读)"]
        R3["assetsInclude: (file) => boolean<br/>(必填,函数形式)"]
        R4["resolve: Required<ResolveOptions><br/>(必填,所有字段)"]
        R5["environments: Record<..., Resolved><br/>(必填,所有环境已解析)"]
    end
    U1 -->|"path.resolve"| R1
    U2 -->|"flatten + sort"| R2
    U3 -->|"createFilter"| R3
    U4 -->|"mergeWithDefaults"| R4
    U5 -->|"resolveEnvironmentOptions"| R5

核心转变包括:

  1. 路径标准化:所有路径从可选的相对路径变为必填的绝对路径
  2. 类型收窄:联合类型被解析为具体类型(如 assetsInclude 从模式变为函数)
  3. 必填化:被重新声明的字段去掉了 ? 可选标记,由默认值填充(未重新声明的字段如 logLevel、envPrefix 仍沿用 UserConfig 的可选声明)
  4. 只读化:整个接口被 Readonly<> 包装,防止插件意外修改配置
  5. 扩展属性:添加了 PluginHookUtils(getSortedPlugins、getSortedPluginHooks),方便高效查找插件

3.7 配置文件的加载与解析

配置文件是用户与 Vite 交互的主要界面。尽管 Vite 可以通过 API 接收内联配置,但绝大多数用户会通过 vite.config.ts(或 .js、.mjs 等变体)来定义配置。配置文件的加载是一个看似简单但实际充满挑战的任务:文件可能是 TypeScript 编写的(需要转译),可能使用 ESM 或 CJS 格式,可能导入其他本地模块或第三方包,甚至可能是异步的。loadConfigFromFile 函数(第 2213 行)负责处理这些复杂情况,支持三种加载策略和六种文件格式。

3.7.1 配置文件发现

// 文件: packages/vite/src/node/config.ts

export async function loadConfigFromFile(
  configEnv: ConfigEnv,
  configFile?: string,
  configRoot: string = process.cwd(),
  logLevel?: LogLevel,
  customLogger?: Logger,
  configLoader: 'bundle' | 'runner' | 'native' = 'bundle',
): Promise<{
  path: string
  config: UserConfig
  dependencies: string[]
} | null> {
  let resolvedPath: string | undefined

  if (configFile) {
    // 显式指定的配置文件路径,直接解析
    resolvedPath = path.resolve(configFile)
  } else {
    // 按优先级搜索默认配置文件
    for (const filename of DEFAULT_CONFIG_FILES) {
      const filePath = path.resolve(configRoot, filename)
      if (!fs.existsSync(filePath)) continue
      resolvedPath = filePath
      break
    }
  }

  if (!resolvedPath) {
    debug?.('no config file found.')
    return null
  }
  // ...
}

默认配置文件的搜索顺序定义在常量中:

// 文件: packages/vite/src/node/constants.ts

export const DEFAULT_CONFIG_FILES: string[] = [
  'vite.config.js',
  'vite.config.mjs',
  'vite.config.ts',
  'vite.config.cjs',
  'vite.config.mts',
  'vite.config.cts',
]

.js 排在第一位是因为它是最通用的格式——即使在 TypeScript 项目中,vite.config.js 也能正常工作。.ts 排在第三位而非第一位是因为 Vite 不会假设项目一定使用 TypeScript。显式 ESM 的 .mjs 紧随 .js 排第二,CommonJS 的 .cjs 排第四;TypeScript 的两个显式变体 .mts、.cts 排在最后两位。.cjs / .cts 主要用于 "type": "module" 的项目中需要使用 CJS 格式配置文件的场景。

搜索过程在找到第一个存在的文件后立即停止——这意味着如果项目中同时存在 vite.config.js 和 vite.config.ts,前者会被使用。这种"先到先得"的策略避免了歧义,但用户需要注意不要在项目中遗留废弃的配置文件。

3.7.2 三种加载策略

flowchart TB
    Start["loadConfigFromFile"] --> Resolve["解析配置文件路径"]
    Resolve --> Switch{"configLoader 参数"}
    Switch -->|"'bundle' (默认)"| Bundle["bundleAndLoadConfigFile"]
    Switch -->|"'runner'"| Runner["runnerImportConfigFile"]
    Switch -->|"'native'"| Native["nativeImportConfigFile"]
    subgraph "bundle 策略"
        Bundle --> Rolldown["使用 Rolldown 打包配置文件"]
        Rolldown --> InjectVars["注入 __dirname, __filename,<br/>import.meta.url 等变量"]
        Rolldown --> Externalize["外部化 node_modules 依赖"]
        Rolldown --> Generate["生成单文件产物"]
        Generate --> LoadESM["ESM: 写入临时文件后 import"]
        Generate --> LoadCJS["CJS: 使用 _compile 加载"]
    end
    subgraph "runner 策略"
        Runner --> ViteRunner["使用 Vite ModuleRunner<br/>直接执行配置文件"]
    end
    subgraph "native 策略"
        Native --> NodeImport["使用 Node.js 原生<br/>import() 加载"]
    end
    LoadESM --> ParseExport
    LoadCJS --> ParseExport
    ViteRunner --> ParseExport
    NodeImport --> ParseExport
    ParseExport["解析导出的配置<br/>(支持函数和对象形式)"] --> Return["返回 { path, config, dependencies }"]
    style Bundle fill:#ff9800,color:white
    style Runner fill:#2196f3,color:white
    style Native fill:#4caf50,color:white

Bundle 策略(默认)是最复杂但也最可靠的方案。它使用 Rolldown 打包配置文件,处理 TypeScript 转译、路径解析和依赖外部化:

// 文件: packages/vite/src/node/config.ts(简化:省略了 ESM/CJS 解析分支、非 Node 内置模块处理、
// import.meta.resolve 注入与 hashbang 处理)

async function bundleConfigFile(
  fileName: string,
  isESM: boolean,
): Promise<{ code: string; dependencies: string[] }> {
  const root = path.dirname(fileName)
  const dirnameVarName = '__vite_injected_original_dirname'
  const filenameVarName = '__vite_injected_original_filename'
  const importMetaUrlVarName = '__vite_injected_original_import_meta_url'
  const importMetaResolveVarName =
    '__vite_injected_original_import_meta_resolve'

  const bundle = await rolldown({
    input: fileName,
    platform: 'node',
    resolve: { mainFields: ['main'] },
    transform: {
      define: {
        __dirname: dirnameVarName,
        __filename: filenameVarName,
        'import.meta.url': importMetaUrlVarName,
        'import.meta.dirname': dirnameVarName,
        'import.meta.filename': filenameVarName,
        'import.meta.resolve': importMetaResolveVarName,
        'import.meta.main': 'false',
      },
    },
    treeshake: false,     // 禁用 tree-shaking,保留所有模块用于依赖追踪
    tsconfig: false,       // 不使用项目的 tsconfig,避免干扰
    plugins: [
      {
        name: 'externalize-deps',
        resolveId: {
          filter: { id: /^[^.#].*/ },
          async handler(id, importer, { kind }) {
            // 外部化所有 node_modules 依赖
            // 保留项目内文件以追踪依赖关系
            if (!importer || path.isAbsolute(id) || isNodeBuiltin(id)) return
            const idFsPath = nodeResolveWithVite(id, importer, { root })
            if (idFsPath?.endsWith('.json')) return idFsPath  // JSON 不外部化
            return { id: idFsPath, external: true }
          },
        },
      },
      {
        name: 'inject-file-scope-variables',
        transform: {
          filter: { id: /\.[cm]?[jt]s$/ },
          handler(code, id) {
            // 为每个文件注入正确的 __dirname 等变量
            let injectValues =
              `const ${dirnameVarName} = ${JSON.stringify(path.dirname(id))};` +
              `const ${filenameVarName} = ${JSON.stringify(id)};` +
              `const ${importMetaUrlVarName} = ${JSON.stringify(
                pathToFileURL(id).href,
              )};`
            return { code: injectValues + code, map: null }
          },
        },
      },
    ],
  })

  const result = await bundle.generate({
    format: isESM ? 'esm' : 'cjs',
    sourcemap: 'inline',
    codeSplitting: false,  // 确保生成单文件
  })
  await bundle.close()

  const entryChunk = result.output.find(
    (chunk): chunk is OutputChunk => chunk.type === 'chunk' && chunk.isEntry,
  )!
  const bundleChunks = Object.fromEntries(
    result.output.flatMap((c) => (c.type === 'chunk' ? [[c.fileName, c]] : [])),
  )
  // 递归收集入口 chunk 及其 imports / dynamicImports 中的全部 moduleIds
  const allModules = new Set<string>()
  collectAllModules(bundleChunks, entryChunk.fileName, allModules)

  return {
    code: entryChunk.code,
    // 排除 `\x00rolldown/runtime.js`
    dependencies: [...allModules].filter((m) => !m.startsWith('\0')),
  }
}

bundle 策略的关键设计决策:

  1. 禁用 tree-shaking:因为需要追踪配置文件的所有依赖(dependencies),用于文件监听
  2. 禁用 tsconfig:配置文件的 TypeScript 转译不应受项目 tsconfig 影响,避免意外行为
  3. 变量注入:打包后的代码中 __dirname 等变量的值会丢失,因此需要在转换时注入正确的值
  4. JSON 不外部化:Rolldown 不支持 import attributes,JSON 文件必须内联打包
  5. 单文件产出:通过 codeSplitting: false 确保生成单个 chunk,简化加载逻辑

Runner 策略使用 Vite 自身的 ModuleRunner 加载配置,适用于需要 Vite 转换能力的场景:

async function runnerImportConfigFile(resolvedPath: string) {
  const { module, dependencies } = await runnerImport<{
    default: UserConfigExport
  }>(resolvedPath)
  return {
    configExport: module.default,
    dependencies,
  }
}

Native 策略最简单,直接使用 Node.js 原生的 import() 加载,但不追踪依赖:

async function nativeImportConfigFile(resolvedPath: string) {
  const module = await import(
    pathToFileURL(resolvedPath).href + '?t=' + Date.now()
  )
  return {
    configExport: module.default,
    dependencies: [],  // 无法追踪依赖
  }
}

URL 中追加 ?t= 时间戳是为了绕过 Node.js 的 ESM 模块缓存——否则同一 URL 只会被加载一次,即使文件内容已经改变(例如服务器重启时),import() 也会返回缓存的旧模块。这是 Node.js ESM 实现的一个已知限制,时间戳后缀是一个广泛使用的 workaround。

三种策略的适用场景各有不同。Bundle 策略适用于大多数项目,它是开箱即用的默认选择。Runner 策略适用于想用 Vite 自身的模块转换管线(而非打包)来执行配置文件的场景——注意 runnerImport 是以 configFile: false 解析出一份最小配置来跑的(packages/vite/src/node/ssr/runnerImport.ts:21-44),用户插件并不参与。Native 策略最轻量,适用于配置文件是纯 JavaScript 且不需要转译的简单场景,但它无法追踪配置依赖——配置文件本身的改动仍会触发重启(hmr.ts 中直接比较 file === config.configFile),可它 import 的本地模块改动就不会了。

3.7.3 配置导出的解析

无论使用哪种加载策略,最终的配置导出都支持多种形式:

const config = await (typeof configExport === 'function'
  ? configExport(configEnv)  // 函数形式:传入 ConfigEnv
  : configExport)            // 对象或 Promise 形式

if (!isObject(config)) {
  throw new Error(`config must export or return an object.`)
}

3.8 环境变量加载

环境变量是现代 Web 应用开发中不可或缺的配置机制。它允许同一套代码在不同环境(开发、测试、预发布、生产)下表现不同的行为,而无需修改源码。Vite 的环境变量系统遵循 dotenv 的约定,通过 .env 文件管理环境变量,并提供了精细的安全控制,防止敏感信息泄露到客户端代码中。

环境变量的加载由 packages/vite/src/node/env.ts 实现,这是一个相对独立的模块,只有 115 行代码,但其中蕴含了多个值得深入理解的设计决策。

3.8.1 .env 文件的加载顺序

// 文件: packages/vite/src/node/env.ts

export function getEnvFilesForMode(
  mode: string,
  envDir: string | false,
): string[] {
  if (envDir !== false) {
    return [
      `.env`,                  // 始终加载
      `.env.local`,            // 始终加载,被 git 忽略
      `.env.${mode}`,          // 对应当前 mode
      `.env.${mode}.local`,    // 对应当前 mode,被 git 忽略
    ].map((file) => normalizePath(path.join(envDir, file)))
  }
  return []
}
graph TB
    subgraph ".env 文件加载优先级 (后加载覆盖先加载)"
        E1[".env<br/>通用默认值"] --> E2[".env.local<br/>通用本地覆盖 (git 忽略)"]
        E2 --> E3[".env.development<br/>开发模式默认值"]
        E3 --> E4[".env.development.local<br/>开发模式本地覆盖 (git 忽略)"]
    end
    subgraph "环境变量暴露规则"
        ALL["所有 .env 变量"] --> Filter{"以 VITE_ 开头?"}
        Filter -->|"是"| Client["暴露到客户端<br/>import.meta.env.VITE_*"]
        Filter -->|"否"| ServerOnly["不暴露给客户端<br/>(也不写入 process.env)"]
    end
    E4 --> ALL
    style Client fill:#4caf50,color:white
    style ServerOnly fill:#f44336,color:white

3.8.2 loadEnv 的完整实现

// 文件: packages/vite/src/node/env.ts

export function loadEnv(
  mode: string,
  envDir: string | false,
  prefixes: string | string[] = 'VITE_',
): Record<string, string> {
  if (mode === 'local') {
    throw new Error(
      `"local" cannot be used as a mode name because it conflicts with ` +
      `the .local postfix for .env files.`,
    )
  }
  prefixes = arraify(prefixes)
  const env: Record<string, string> = {}
  const envFiles = getEnvFilesForMode(mode, envDir)

  // 使用 Node.js 内置的 parseEnv 解析 .env 文件
  const parsed = Object.fromEntries(
    envFiles.flatMap((filePath) => {
      const stat = tryStatSync(filePath)
      if (!stat || (!stat.isFile() && !stat.isFIFO())) return []
      const parsedEnv = parseEnv(fs.readFileSync(filePath, 'utf-8'))
      return Object.entries(parsedEnv as Record<string, string>)
    }),
  )

  // NODE_ENV 特殊处理
  if (parsed.NODE_ENV && process.env.VITE_USER_NODE_ENV === undefined) {
    process.env.VITE_USER_NODE_ENV = parsed.NODE_ENV
  }

  // 支持 BROWSER 和 BROWSER_ARGS
  if (parsed.BROWSER && process.env.BROWSER === undefined) {
    process.env.BROWSER = parsed.BROWSER
  }
  if (parsed.BROWSER_ARGS && process.env.BROWSER_ARGS === undefined) {
    process.env.BROWSER_ARGS = parsed.BROWSER_ARGS
  }

  // 展开变量引用(如 $VAR 或 ${VAR})
  const processEnv = { ...process.env } as DotenvPopulateInput
  expand({ parsed, processEnv })

  // 仅暴露以指定前缀开头的变量到客户端
  for (const [key, value] of Object.entries(parsed)) {
    if (prefixes.some((prefix) => key.startsWith(prefix))) {
      env[key] = value
    }
  }

  // 进程环境变量优先级高于 .env 文件
  for (const key in process.env) {
    if (prefixes.some((prefix) => key.startsWith(prefix))) {
      env[key] = process.env[key] as string
    }
  }

  return env
}

这段代码中蕴含了多个精妙的设计决策:

  1. FIFO 支持:除了普通文件,还支持命名管道(FIFO),这使得 1Password 等密码管理器可以通过管道注入环境变量,而无需将敏感信息明文写入 .env 文件。

  2. 使用 Node.js 内置的 parseEnv:Vite 8.0 使用了 Node.js 内置的 parseEnv 函数(来自 node:util)替代了第三方的 dotenv 库来解析 .env 文件格式,减少了外部依赖。

  3. 变量展开:使用 dotenv-expand 支持变量之间的引用(如 API_URL=$HOST:$PORT),但注意它使用 process.env 的副本而非直接操作全局环境。源码注释给出的理由是不让 dotenv-expand 把展开后的值写回全局 process.env(packages/vite/src/node/env.ts:72-75)——如果直接修改 process.env,展开后的变量值会影响到所有使用 process.env 的代码,可能导致意外的副作用。

  4. 进程变量优先:通过命令行 VITE_API_KEY=xxx vite 设置的环境变量优先于 .env 文件中的值。这符合十二要素应用(Twelve-Factor App)的配置管理原则——运行时注入的配置应该覆盖文件中的配置,因为前者通常代表更具体的部署环境意图。

  5. "local" 模式名禁止:如果允许 mode 为 local,那么 .env.local 这个文件会变得歧义——它究竟是通用的本地覆盖文件,还是 local 模式的配置文件?通过禁止这个模式名,Vite 从根本上消除了这种歧义。

  6. NODE_ENV 的特殊处理:.env 文件中的 NODE_ENV 会先被保存到 process.env.VITE_USER_NODE_ENV;随后 resolveConfig 在进程本身未设 NODE_ENV 时,只接受 development 并应用(packages/vite/src/node/config.ts:1659-1671)。设成 production 等其他值,Vite 只发出警告而不应用,因为在开发模式下设置 NODE_ENV=production 可能破坏框架(如 Vue)的 HMR 功能。

3.8.3 envPrefix 安全校验

// 文件: packages/vite/src/node/env.ts

export function resolveEnvPrefix({
  envPrefix = 'VITE_',
}: UserConfig): string[] {
  envPrefix = arraify(envPrefix)
  if (envPrefix.includes('')) {
    throw new Error(
      `envPrefix option contains value '', which could lead unexpected ` +
      `exposure of sensitive information.`,
    )
  }
  if (envPrefix.some((prefix) => /\s/.test(prefix))) {
    console.warn(
      colors.yellow(
        `[vite] Warning: envPrefix option contains values with whitespace, which does not work in practice.`,
      ),
    )
  }
  return envPrefix
}

空字符串前缀被严格禁止——如果允许,所有环境变量(包括 DATABASE_PASSWORD、AWS_SECRET_KEY 等敏感信息)都会被暴露到客户端代码中,任何访问网站的用户都能在浏览器开发者工具中看到这些值。这将构成严重的安全漏洞。因此,Vite 选择在配置验证阶段就以抛出异常的方式阻止这种危险配置,而不是仅仅发出警告。

包含空白字符的前缀虽然不会导致安全问题,但在实践中无法正常工作(环境变量名通常不包含空白字符),因此 Vite 对此发出警告以帮助用户发现配置错误。

3.9 配置合并的层级关系

配置合并是配置系统中最容易引起困惑的部分。当同一个选项可能在多个层级(命令行、配置文件、插件钩子、环境默认、全局默认)被设置时,最终使用哪个值?Vite 通过清晰的优先级链解决了这个问题。

Vite 的配置合并遵循清晰的层级关系,高优先级覆盖低优先级:

flowchart TB
    subgraph "配置合并优先级 (从高到低)"
        L1["第1层: 插件 config 钩子返回值<br/>(按插件顺序依次作为 overrides 合并)"]
        L2["第2层: 命令行参数 / API 内联配置<br/>(InlineConfig)"]
        L3["第3层: 配置文件<br/>(vite.config.ts)"]
        L4["第4层: 由顶层配置派生的环境默认值<br/>(environments.* 里没显式写的字段取自顶层;<br/>显式写出的字段属于上面几层, 会覆盖它)"]
        L5["第5层: configDefaults 默认值<br/>(Object.freeze 冻结)"]
    end
    L1 --> L2 --> L3 --> L4 --> L5
    subgraph "合并规则"
        R1["对象: 深度合并 (深层属性逐一合并)"]
        R2["数组: 追加合并 (mergeConfig 拼接两个数组;<br/>mergeWithDefaults 则整体替换)"]
        R3["原始值: 高优先级覆盖低优先级"]
    end
    style L1 fill:#d32f2f,color:white
    style L5 fill:#757575,color:white

配置合并使用的 mergeConfig 函数实现了深度合并语义:

// 合并顺序示例
// 1. 文件配置作为基础
config = mergeConfig(loadResult.config, inlineConfig)
// 2. 插件依次修改
config = mergeConfig(config, pluginReturnedConfig)
// 3. 环境默认值填充
config.environments[name] = mergeConfig(defaultOptions, config.environments[name])
// 4. 各子模块使用 mergeWithDefaults 填充剩余默认值
json = mergeWithDefaults(configDefaults.json, config.json ?? {})

3.10 Oxc 与 esbuild 的配置迁移

Vite 8.0 完成了一次重要的技术栈迁移:将默认的 JavaScript/TypeScript 转译器从 esbuild 替换为 Oxc。esbuild 是用 Go 编写的极速编译器,在 Vite 的早期版本中发挥了关键作用。然而,随着 Vite 团队推进与 Rolldown(Rust 编写的打包器)的深度整合,使用同样基于 Rust 的 Oxc 转译器成为自然的选择——它不仅提供了更好的与 Rolldown 的互操作性,还能利用 Rust 生态的共享基础设施(如 AST 解析器)。

为了确保这次迁移对用户的影响最小化,配置系统中包含了完善的兼容处理:

// 文件: packages/vite/src/node/config.ts (约第 1840 行,警告文案有删节)

let oxc: OxcOptions | false | undefined = config.oxc
if (config.esbuild) {
  if (config.oxc) {
    // 两者都设置时,oxc 优先
    logger.warn(
      `Both esbuild and oxc options were set. ` +
      `oxc options will be used and esbuild options will be ignored.`,
    )
  } else {
    // 自动将 esbuild 配置转换为 oxc 配置
    oxc = convertEsbuildConfigToOxcConfig(config.esbuild, logger)
  }
} else if (config.esbuild === false && config.oxc !== false) {
  logger.warn(
    `esbuild option is set to false, but oxc option was not set to false. ` +
    `esbuild: false does not have effect any more. ` +
    `Please set oxc: false instead.`,
  )
}

这段逻辑展示了 Vite 团队处理重大技术栈迁移的策略,值得在任何涉及 API 弃用的项目中参考:

  1. 优先使用新配置:当 oxc 选项存在时,它总是优先于 esbuild
  2. 自动转换旧配置到新格式:通过 convertEsbuildConfigToOxcConfig 函数,将 esbuild 的配置语义映射到 Oxc 的等价选项,最大限度地减少用户的迁移工作量
  3. 对冲突和弃用给出清晰警告:当两者同时设置时发出警告,当旧选项被设为 false 但新选项未显式禁用时发出解释性警告
  4. 不会默默忽略用户的配置意图:即使旧选项已弃用,如果用户明确设置了它,Vite 仍然会尝试尊重这个意图(通过自动转换),而不是直接丢弃

这种渐进式迁移策略确保了即使在主版本升级中,绝大多数用户也无需修改配置就能正常工作。只有当用户使用了 esbuild 特有且无法自动转换的高级选项时,才需要手动迁移到 Oxc 的配置格式。

3.11 WebSocket 安全令牌

安全性在开发工具中常常被忽视,但 Vite 开发服务器实际上面临着真实的安全威胁。一个典型的攻击场景是:用户在浏览器中打开了一个恶意网页,该网页尝试通过 WebSocket 连接到用户本地运行的 Vite 开发服务器(通常监听 localhost:5173),从而利用 Vite 的文件系统访问能力读取敏感文件。

为了防范这类攻击,ResolvedConfig 中包含一个随机生成的 WebSocket 认证令牌:

// 72 位随机数 (12 个 base64 字符)
// 至少 64 位是 OWASP 推荐的最低标准
webSocketToken: Buffer.from(
  crypto.getRandomValues(new Uint8Array(9)),
).toString('base64url'),

这个令牌用于 HMR WebSocket 连接的认证,防止恶意网页通过 WebSocket 与开发服务器通信。每次服务器启动都会生成新的令牌,由 clientInjectionsPlugin 注入到 HMR 客户端代码中。当浏览器中的 HMR 客户端尝试建立 WebSocket 连接时,必须在查询参数中提供正确的 token 才能被接受(ws.ts 只对带 Origin 头的请求校验令牌——浏览器发起的 WebSocket 一定带 Origin;非浏览器请求为了向后兼容不校验,legacy.skipWebSocketTokenCheck 可整体关闭校验)。由于恶意网页无法获取注入到 Vite 项目页面中的令牌值(受同源策略保护),因此无法建立连接。

72 位的随机长度遵循了 OWASP(开放 Web 应用安全项目)关于会话令牌至少 64 位的建议。使用 crypto.getRandomValues 而非 Math.random 确保了令牌的密码学安全性。base64url 编码使得令牌可以安全地嵌入到 URL 和 JavaScript 代码中而无需额外转义。

3.12 设计决策分析

理解一个系统的设计决策,往往比理解其实现细节更有价值。设计决策揭示了作者面临的约束和取舍,理解这些取舍能帮助我们在自己的项目中做出更好的架构选择。

3.12.1 为什么 config.ts 是单文件

尽管 2703 行的单文件看起来"过大",但将配置系统保持在一个文件中有其合理性:

  1. 内聚性:UserConfig、ResolvedConfig、resolveConfig、loadConfigFromFile 和默认值定义紧密关联,放在同一文件中便于理解整体流程
  2. 导入集中:配置类型被 Vite 几乎所有模块引用,集中在一处便于导入(但这并不能消除循环导入:config.ts 自己也从 ./build、./server 等模块导入,而这些模块又从 ./config 取类型)
  3. 类型导出:TypeScript 类型在同一文件中更容易管理,用户可以从一个位置导入所有配置相关类型

3.12.2 为什么默认值使用 Object.freeze

const configDefaults = Object.freeze({ /* ... */ })

冻结默认值对象防止了一个微妙但严重的 bug:如果多个 resolveConfig 调用共享同一个可变默认值对象,一次调用中的修改可能"泄漏"到后续调用中。在 Vite 的 builder 模式下,同一进程可能为不同环境多次调用 resolveConfig,隔离因此很重要。不过 Object.freeze 是浅冻结,只挡住顶层属性的改写;嵌套对象(如 configDefaults.resolve)的隔离实际靠 mergeWithDefaults 在合并前先 deepClone(defaults)(packages/vite/src/node/utils.ts:1232-1234)。

3.12.3 Bundle 策略为何是默认加载器

选择 bundle 作为默认的配置文件加载策略,反映了实用主义的工程取舍:

  • TypeScript 支持:无需用户安装额外的 TypeScript 运行时
  • 依赖追踪:打包过程天然收集了配置文件的所有依赖,使 Vite 能在这些依赖变化时自动重启
  • 一致性:无论用户的 Node.js 版本如何,配置文件都以一致的方式被处理
  • 代价:启动时有一定的打包开销,但配置文件通常很小,这个开销相对有限

3.12.4 向后兼容层的设计哲学

Vite 6.0 引入了 Environment API 这一重大架构变革,但无法要求所有用户和插件一夜之间完成迁移。因此,resolveConfig 中有大量向后兼容的合并代码,它们的职责是在新老 API 之间建立双向桥梁。这些代码虽然增加了实现的复杂度,但确保了生态系统的平滑过渡,避免了"大爆炸"式升级带来的混乱。

让我们看几个具体的兼容层示例:

// packages/vite/src/node/config.ts:1508-1512 与 :1586-1587 两段
// 将旧的 config.ssr 选项合并到新的 environments.ssr 配置中
if (configEnvironmentsSsr) {
  configEnvironmentsSsr.optimizeDeps = mergeConfig(
    deprecatedSsrOptimizeDepsConfig,
    configEnvironmentsSsr.optimizeDeps ?? {},
  )
  // ... :1514-1528 同样把 ssr.resolve(含 noExternal)并进 configEnvironmentsSsr.resolve
}
// ... :1531-1535 还把旧的 build.ssrEmitAssets 映射到 environments.ssr.build.emitAssets

// 将 environments.client.resolve 回写到顶级 config.resolve
config.resolve.conditions = config.environments.client.resolve?.conditions
config.resolve.mainFields = config.environments.client.resolve?.mainFields

这些代码确保了两个方向的兼容性:

  • 旧配置写法 -> 新系统:用户使用旧的 ssr 顶级选项,自动映射到 environments.ssr
  • 新系统 -> 旧读取方式:插件仍然可以从 config.resolve 读取客户端配置

3.13 配置系统与其他子系统的交互

配置系统并非孤立存在,它是整个 Vite 系统的基石。理解配置如何影响其他子系统,能帮助我们建立更完整的心智模型。

与开发服务器的交互:resolvedConfig.server 决定了 HTTP 服务器的端口、主机、HTTPS 配置、代理规则、CORS 策略等。resolvedConfig.environments 中每个环境的 dev.createEnvironment 函数决定了 DevEnvironment 实例的创建方式。文件监听器使用 resolvedConfig.server.watch 配置(传递给 Chokidar),resolvedConfig.configFileDependencies 被加入监听列表以支持配置变更后的自动重启。

与插件系统的交互:resolvedConfig.plugins 是最终的插件列表,已经过扁平化和排序。每个环境还有独立的 resolvedConfig.environments[name].plugins,它是对全局插件列表进行环境特定过滤后的结果。插件在 config 钩子中修改配置,在 configResolved 钩子中读取最终配置并初始化自身状态。

与构建引擎的交互:resolvedConfig.build 中的 rolldownOptions 直接传递给 Rolldown 打包器,target 决定了代码转译的目标版本,outDir 指定输出目录,cssCodeSplit 控制 CSS 是否单独提取。构建模式下,resolvedConfig.isBundled 为 true,这影响了插件管道的组成(加载构建专属插件,移除开发专属插件)。

与模块解析的交互:内置的 alias 插件使用 resolvedConfig.resolve.alias 进行路径别名解析;resolvedConfig.server.fs.deny(编译为 fsDenyGlob)控制开发服务器的文件系统访问权限。resolvedConfig.assetsInclude 函数决定了哪些文件被当作静态资源而非可转换模块。

与依赖优化器的交互:resolvedConfig.optimizeDeps(向后兼容层)和 resolvedConfig.environments.client.optimizeDeps 配置了依赖预构建的行为,包括需要预构建的包列表(include)、排除列表(exclude)、自定义 Rolldown 选项等。

3.14 本章小结

本章深入剖析了 Vite 配置系统的完整架构——从 config.ts 这个 2703 行的核心文件出发,我们追踪了从用户配置到最终解析配置的完整旅程。

核心要点回顾:

  1. UserConfig 到 ResolvedConfig 的转换是一个多步骤的管道:加载配置文件 -> 合并内联配置 -> 执行插件 config 钩子 -> 初始化环境配置 -> 解析子选项 -> 加载环境变量 -> 组装最终配置 -> 解析插件 -> 调用 configResolved 钩子。

  2. 配置文件加载支持三种策略(bundle/runner/native),默认的 bundle 策略使用 Rolldown 打包配置文件,自动处理 TypeScript 转译和依赖追踪。

  3. defineConfig() 是一个纯类型辅助函数,通过多重载签名为用户提供完整的 TypeScript 类型推断支持。

  4. 环境变量加载遵循明确的优先级链(进程变量 > mode-local > mode > local > default),通过 envPrefix 安全地控制客户端暴露范围。

  5. 配置合并遵循清晰的层级关系(插件 config 返回值 > 命令行/内联 > 配置文件 > 环境默认 > configDefaults),使用深度合并语义处理嵌套对象。注意插件排在最前面:packages/vite/src/node/config.ts:1408 先把内联配置作为 overrides 合到文件配置上,packages/vite/src/node/config.ts:1441 才跑 runConfigHook,而它内部是 conf = mergeConfig(conf, res)(packages/vite/src/node/config.ts:2629)——插件返回值处在 overrides 位,标量字段会盖掉前两层。唯一的例外是 mode:它在 packages/vite/src/node/config.ts:1415 就已按 inlineConfig.mode || config.mode || mode 定好,跑在 config 钩子之前。

  6. Environment API 引入了两层配置结构,顶级配置作为所有环境的默认值(optimizeDeps 例外,只作为 client 的默认值),每个环境可以独立覆盖 resolve、build、dev、optimizeDeps 等选项。

理解了配置系统,就掌握了 Vite 运行的"基因密码"。从用户在 vite.config.ts 中写下第一行配置,到 resolveConfig 输出完整的 ResolvedConfig,这个过程涉及文件发现、TypeScript 转译、配置函数执行、插件钩子调用、多层合并、环境变量注入、子选项标准化等十余个步骤。每一步都经过精心设计,既保证了零配置的开箱即用体验,又为高级用户提供了深度定制的可能。

在下一章中,我们将深入插件系统——看看 ResolvedConfig 中的 plugins 数组是如何在开发服务器和构建引擎中发挥作用的,以及插件容器如何模拟 Rollup 的运行时环境使同一套插件在两种模式下都能工作。