Vite 设计与实现

第1章 为什么需要理解 Vite

作者 杨艺韬 · 7,449 字

第1章 为什么需要理解 Vite

本章要点

  • 理解前端构建工具从 Webpack 到 Vite 的演进动力
  • 掌握 Vite 的两大核心创新:原生 ESM 开发服务器与 Rolldown 构建引擎
  • 明确深入源码的价值:从"会用"到"理解为什么这样设计"
  • 建立全书的阅读路线图

1.1 前端构建工具的三次变革

前端构建工具的演进史,本质上是一部开发体验与生产性能的博弈史

第一代:打包一切(Webpack 时代)

Webpack 的核心理念是"万物皆模块"——JS、CSS、图片、字体,所有资源都通过 loader 转换为 JavaScript 模块,最终打包成 bundle。这在 2015 年是革命性的,但随着项目规模增长,问题浮现了:

第二代:编译加速(esbuild/swc 时代)

esbuild(Go)和 swc(Rust)用系统语言重写了 JavaScript 编译器,将转换速度提升了 10-100 倍。但它们解决的是编译速度问题,而不是架构问题——开发服务器仍然需要先打包再服务。

第三代:按需服务(Vite 时代)

Vite 的突破不在于更快的编译器,而在于架构创新

graph LR
    A[传统方案] --> B[启动时打包所有模块]
    B --> C[服务打包后的 bundle]

    D[Vite 方案] --> E[启动时只启动服务器]
    E --> F[请求时按需转换模块]

开发阶段,Vite 利用浏览器原生 ESM 支持,不打包、不构建,浏览器请求哪个模块就实时转换哪个模块。这让冷启动从"分钟级"降到"毫秒级"。

性能对比:具体数字

以一个 5000 模块的中型 React 项目为例:

指标 Webpack 5 Vite (ESM) 提升
冷启动 30-60s 300-800ms 50-100×
HMR 更新 2-5s 20-50ms 100×
生产构建 90-120s 15-30s (Rolldown) 3-6×
内存占用 1-2 GB 200-400 MB 3-5×

冷启动的差距为什么这么大?因为 Webpack 需要在启动时构建完整的依赖图 + 打包所有模块,而 Vite 只需要启动一个 HTTP 服务器——模块的转换推迟到浏览器请求时才按需执行。这是架构层面的胜利,不是"更快的编译器"能解决的。

生产阶段,Vite 使用 Rolldown(Rust 编写的 Rollup 兼容打包器)进行优化构建,兼顾性能与产物质量。

1.2 Vite 的核心设计哲学

开发与生产的分离

graph TB
    subgraph "开发阶段 (vite dev)"
        A1[原生 ESM] --> A2[按需转换]
        A2 --> A3[毫秒级 HMR]
    end

    subgraph "生产阶段 (vite build)"
        B1[Rolldown 打包] --> B2[代码分割]
        B2 --> B3[Tree Shaking]
        B3 --> B4[压缩优化]
    end

这是 Vite 最根本的架构决策:开发和生产使用完全不同的策略。开发追求速度(原生 ESM + 按需转换),生产追求质量(Rolldown + 优化)。

插件优先

Vite 的核心非常小——大部分功能(CSS 处理、TypeScript 编译、HTML 转换、资源处理)都通过内置插件实现。用户插件和内置插件使用完全相同的 Hook 接口,没有特权 API。

约定优于配置

零配置即可处理 TypeScript、JSX、CSS Modules、静态资源。只有当你需要定制时才需要配置。

1.3 为什么要读 Vite 源码

超越文档的理解

文档告诉你 import.meta.hot.accept() 可以注册 HMR 回调,但不会告诉你:

插件开发的底层知识

如果你要开发 Vite 插件,理解内置插件的实现会帮你:

可迁移的架构模式

Vite 源码中有大量可迁移到其他项目的设计模式:

1.4 本书的组织方式

graph TD
    A[第1-2章: 为什么 & 架构总览] --> B[第3-4章: 配置 & 插件系统]
    B --> C[第5-8章: 开发服务器核心]
    C --> D[第9-12章: 转换管线]
    D --> E[第13-14章: 构建与优化]
    E --> F[第15-17章: 高级特性]
    F --> G[第18章: 设计模式总结]
  1. 架构层(第1-2章):建立全景认知
  2. 基础设施层(第3-4章):配置系统与插件机制
  3. 开发服务器层(第5-8章):服务器、模块图、HMR、预构建
  4. 转换管线层(第9-12章):JS/CSS/HTML/资源的处理
  5. 构建层(第13-14章):Rolldown 集成与产物优化
  6. 高级特性层(第15-17章):SSR、Environment API、Worker
  7. 总结层(第18章):可迁移的设计模式

每一章都会大量使用 Mermaid 图表来可视化数据流、状态机和架构关系,帮助你建立清晰的心智模型。

1.5 Vite 源码的规模感

先用几个数字感受 Vite 的工程规模:

vite-latest/packages/vite/src/node/    约 35,000 行 TypeScript
  config.ts                            2,704 行   配置解析和合并
  server/                              约 8,000 行  dev server / HMR / moduleGraph
  optimizer/                           约 4,000 行  依赖预构建
  plugins/                             约 10,000 行 内置插件(~40 个)
  ssr/                                 约 2,500 行  SSR 和 Module Runner
  build.ts                             ~1,200 行   生产构建集成 Rolldown

这只是 src/node/ 主目录——加上 client 代码(浏览器端 HMR 客户端、~1500 行)、module-runner(运行时代码执行器、~2500 行)、shared 工具——总共约 45,000 行 TypeScript

对比几个数字:

Vite 45k 行 TS 覆盖了一个完整的构建工具生命周期——从 dev server 到 production build、从 CSS 到 TS 到 JSX、从 HMR 到 SSR。

1.6 defineConfig 的 6 个重载——TypeScript 类型系统的极致

打开 src/node/config.ts:175-181——你会看到 defineConfig6 个函数重载

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

6 个重载对应 6 种 config 写法

  1. 同步对象 defineConfig({ plugins: [react()] })
  2. Promise 对象 defineConfig(fetchRemoteConfig())
  3. 同步函数 defineConfig(env => ({ ... }))——按 env 返回对象
  4. 异步函数 defineConfig(async env => ({ ... }))——按 env 异步返回
  5. 函数联合类型 defineConfig(env => env.mode === 'dev' ? ... : ...)
  6. 兜底 defineConfig(config)——接收任意 UserConfigExport

为什么要 6 个重载?——因为 TypeScript 类型推导

const c1 = defineConfig({ plugins: [] })     // c1 推导为 UserConfig
const c2 = defineConfig(env => ({ ... }))    // c2 推导为 UserConfigFnObject

IDE 里你写 c1. 时自动补全出现的是 UserConfig 的字段、写 c2. 时出现的是函数的 call signature——精确到具体类型

合并成一个 defineConfig(config: UserConfigExport): UserConfigExport 能工作,但补全会退化——c1. 会补全所有可能类型的交集、不精确。6 个重载是为了精确的 IDE 类型补全而付出的 API 成本。

1.7 Vite 的四大核心模块——一张全景图

下图把 Vite 的 45k 行代码压缩成四大核心模块

┌─────────────────────────────────────────────────────┐
│  Vite 核心架构                                      │
│                                                     │
│  ┌───────────────┐    ┌───────────────────┐         │
│  │ 1. Config     │───▶│ 2. Plugin System  │         │
│  │ (config.ts    │    │ (plugin.ts        │         │
│  │  2704 行)     │    │  plugins/*)       │         │
│  └───────────────┘    └─────────┬─────────┘         │
│                                 │                   │
│                                 ▼                   │
│                     ┌───────────────────────┐       │
│                     │ 3. Environment        │       │
│                     │ (DevEnvironment /     │       │
│                     │  BuildEnvironment)    │       │
│                     └─────┬──────────┬──────┘       │
│                           │          │              │
│               ┌───────────┘          └──────────┐   │
│               ▼                                 ▼   │
│     ┌─────────────────┐               ┌───────────┐ │
│     │ 4a. Dev Server  │               │ 4b. Build │ │
│     │ (server/*       │               │ (build.ts │ │
│     │  HMR/ModuleG    │               │  Rolldown)│ │
│     │  8000 行)       │               │           │ │
│     └─────────────────┘               └───────────┘ │
└─────────────────────────────────────────────────────┘

四大模块的职责分工

① Configconfig.ts)——配置解析——读用户的 vite.config.ts、合并默认值、环境变量替换、插件加载。2704 行代码里一半是合并逻辑——处理各种边界 case。

② Plugin Systemplugin.ts + plugins/)——插件协议——Rollup 兼容的 hook + Vite 专属 hook(如 configureServerhandleHotUpdate)。40 个内置插件和用户插件走同一套机制——没有特权。

③ Environmentenvironment.ts + baseEnvironment.ts)——环境隔离——client / ssr / worker 是不同的 Environment、各有自己的 plugin container / module graph。Vite 6 的关键架构演进

④a Dev Serverserver/)——HMR / Module Graph / HTTP server——按需转换、推送更新。

④b Buildbuild.ts)——生产构建——集成 Rolldown、输出 bundle。

四大模块 + 40 内置插件 = 用户看到的 "Vite"。本书按这个架构组织。

1.8 为什么 Vite 6 引入 Environment API

Vite 5 之前、client / ssr 的代码路径是分叉的——两套独立的插件容器、两套模块图、两套转换管线——代码重复、维护困难

Vite 6 引入 Environment API——把 client/ssr/worker 抽象成 "Environment"——三者共享同一套 plugin/moduleGraph/pipeline 接口、只在配置上有差异

这个改动的代码影响

收益

代价是 API surface 扩大、迁移期 v5 插件要做少量改动。Environment 抽象的引入是为了消除 client/ssr 分叉路径的重复与不一致,是"多条分叉路径统一为抽象"的典型收敛。

1.9 Vite 为什么选 Rolldown 而不是继续用 Rollup / 或切到 esbuild

Vite 6 把生产构建从 Rollup 迁到 Rolldown——这个选择值得细说:

为什么不继续用 Rollup?

为什么不用 esbuild(Go 写的)?

Rolldown 是 Rust 写的 Rollup 兼容 bundler,由 VoidZero(Evan You 创立)主导维护,兼顾性能(Rust 比 JS 快 5-10×)与生态兼容(Rollup 插件几乎无改动可用)。代价是当前版本某些边缘场景未覆盖,且 Rust 维护者数量少于 JS。

1.11 读 Vite 源码前应该知道的前置知识

TypeScript 基础

Node.js 基础

构建工具基础

HMR 概念

1.12 一窥 Plugin 接口——Vite 扩展了 Rollup 什么

打开 src/node/plugin.ts:101Plugin 接口定义——它 extends RolldownPlugin——Vite 的 Plugin 是 Rollup/Rolldown Plugin 的超集Vite 额外扩展的 hook

hotUpdate——HMR 自定义处理:

hotUpdate?: (
  this: MinimalPluginContext & { environment: DevEnvironment },
  options: HotUpdateOptions,
) => Array<EnvironmentModuleNode> | void

返回值三种选择

三种返回对应三种插件用例——这种精细的 hook 协议让插件作者能精确控制 HMR 行为。Vue、React、Svelte 的官方 Vite 插件都 heavily 用这个 hook 实现 framework-specific HMR。

resolveId / load / transform 的 filter 参数——Vite 6 新增的性能优化:

transform?: ObjectHook<
  (...) => TransformResult,
  {
    filter?: {
      id?: StringFilter
      code?: StringFilter
      moduleType?: ModuleTypeFilter
    }
  }
>

filter.id——让插件声明 "我只处理 .vue 文件"——Vite 在调用 hook 前就判断、不匹配的文件根本不调 transform——省 90%+ 的 hook 调用开销

Rollup 原生没有 filter——插件自己在 transform 开头写 if (!id.endsWith('.vue')) return null——每次都跑 function 只是为了早返回。Vite 的 filter 把判断前移到调用前——显著优化。

sharedDuringBuild / perEnvironmentStartEndDuringDev / perEnvironmentWatchChangeDuringDev——Environment API 相关 opt-in flags

让插件作者精确控制

默认行为是保守的(不共享、只调一次)。高级插件可以 opt in 拿到更精细的控制——用几个布尔 flag 而非独立 API 表达这种高级场景。

1.13 Vite 的 40+ 内置插件——一张清单

src/node/plugins/ 下的内置插件:

alias.ts           - 路径别名解析
asset.ts           - 静态资源(图片、字体)
assetImportMetaUrl.ts - new URL(xx, import.meta.url)
clientInjections.ts - 客户端运行时注入
css.ts             - CSS 处理(~2500 行)
dataUri.ts         - data: URI
dynamicImportVars.ts - 动态 import 变量支持
esbuild.ts         - esbuild 转换器
html.ts            - HTML 入口处理
importAnalysis.ts  - import 语句分析 + HMR 注入
importAnalysisBuild.ts - build 时的 import 分析
json.ts            - JSON 模块
loadFallback.ts    - fs.readFile fallback
manifest.ts        - build manifest 生成
metadata.ts        - URL 参数 metadata
modulePreloadPolyfill.ts - <link rel="modulepreload"> polyfill
oxc.ts             - Oxc 转换器
preload.ts         - 预加载
publicDir.ts       - public/ 静态目录
reporter.ts        - 构建进度报告
resolve.ts         - 模块解析(1200+ 行)
ssrRequireHook.ts  - SSR 的 require 钩子
terser.ts          - Terser 压缩
wasm.ts            - WebAssembly
webWorker.ts       - Web Worker
worker.ts          - Worker 入口

~27 个核心插件 + optimizedDeps 相关 + css/pre-processor 子插件 = 40+

每个插件的平均规模 100-2500 行——css.ts 最大 2500 行(因为要处理 PostCSS、CSS Modules、sass/less/stylus 预处理器、HMR)——dataUri.ts 最小 ~50 行。

这 40 个插件通过 Plugin 接口互相协作——没有插件知道其他插件的内部实现、只通过 hook 协议交互。这种完全插件化的架构让 Vite 核心小、扩展性强——未来要支持新语法、新资源类型、新框架都是加插件、不改核心。

1.14 一个例子:从 import 'foo.css' 到渲染的全链路

用一个具体例子展示这些插件的协作——一个 JS 文件里的 import 'foo.css' 发生了什么:

  1. import 'foo.css' 触发 resolveId——resolve.ts 插件解析 foo.css 的绝对路径
  2. load 阶段——loadFallback.ts 读文件内容(也可能是 asset.ts 如果 .css 被当 asset)
  3. transform 阶段——css.ts 插件接手:
    • 如果是 .module.css → CSS Modules 处理、生成 hash-prefixed 类名
    • 如果是 .scss → sass 预处理器编译
    • 标准 CSS → PostCSS 处理(autoprefixer 等)
  4. import analysis——importAnalysis.tsimport 'foo.css' 转换成 import '/src/foo.css?import' 的 HMR-enabled URL
  5. 浏览器请求 /src/foo.css?import——Vite dev server 从 moduleGraph 取已 transform 的内容、返回 JS module
  6. 返回的 JS 调 __vite__updateStyle(...)——把 CSS 注入到 <style> 标签、实现 HMR-friendly 样式

6 步涉及至少 4 个插件,用户代码只写了 import 'foo.css'。生产 build 时,同一个 import 'foo.css' 走不同路径——CSS 被提取成单独的 CSS 文件、通过 <link> 引入、浏览器并行加载——呼应 §1.2 的"开发与生产分离"哲学。

1.15 filter hook 的性能估算

对一个中型项目(1500 JS 文件 + 100 CSS + 200 资源)粗略估算:

把过滤前移到调用前,是架构级别的性能收益,不是微优化。

1.16 Vite 的"reactivity"——HMR 是怎么把变化传到浏览器的

从你改一行代码到浏览器局部更新——Vite 的 HMR 走的路径:

  1. 文件系统 watch——chokidar 监听 src/ 目录
  2. 文件变更事件——chokidar emit change event
  3. Vite server 接收——调用 invalidateModule 让 moduleGraph 把这个 module 标记失效
  4. 调用插件 hotUpdate hook——Vue / React 插件可能缩小更新范围
  5. 计算受影响模块——从变更模块向上找 accept boundary(§17 章详讲)
  6. 通过 WebSocket 发 HMR payload——{ type: 'update', updates: [...] }
  7. 浏览器客户端接收——@vite/client 运行时
  8. 客户端调 import.meta.hot.accept callback——或者做 full reload
  9. 用户看到更新——整条链路在 20-50ms 内完成

每一步都是一个插件/模块的职责,约 10 个文件协作让 HMR 工作。本书第 6-7 章会把这条链路每一步的代码都拆开讲。Vite 把"变更如何传播"抽象成通用机制,所有文件类型通过 plugin 自定义——通用 + 可扩展。

1.17 Vite 在真实项目里的采用数据

用一些客观数据感受 Vite 2026 年的生态地位:

Framework 层面

多数主流前端 framework 已经切到 Vite,迁移主要发生在 2024-2026 年。

性能基准(公开 benchmark):

Vite 同时服务 dev 和 prod 两套场景,性能在同类 Rust-based 工具里属 top 3。

Community

1.18 Vite、Rolldown、Oxc——一个生态

Vite 和几个姊妹工具形成一个 Rust-based 前端工具链生态:

这四个工具共同的特点是 Rust 写的高性能核心 + TS 写的用户友好 API。VoidZero 的长期规划是逐步把 Vite 的所有 JS-based 工具(esbuild、Rollup、Babel 等)替换成 Rust 版本,追求性能追赶 Turbopack + 生态兼容 webpack/rollup 的双重优势。

1.19 src/node/constants.ts 的魔法数字

Vite 的核心行为很多由魔法常量控制——集中在 src/node/constants.ts

// 节选
export const DEFAULT_DEV_PORT = 5173         // vite dev 默认端口
export const DEFAULT_PREVIEW_PORT = 4173     // vite preview 默认端口
export const DEFAULT_ASSETS_INLINE_LIMIT = 4096  // 小于 4KB 的资源内联为 base64
export const METADATA_FILENAME = '_metadata.json' // 缓存元数据文件
export const DEP_VERSION_RE = /[?&]v=[\w.-]+/       // 缓存版本查询参数
export const ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET = [
  'chrome107', 'edge107', 'firefox104', 'safari16',
]  // 默认浏览器兼容目标

每个常量背后都有具体决策

DEFAULT_DEV_PORT = 5173——避免与 3000 / 3001 / 4000 / 5000 / 8000 / 8080 等常见端口冲突,5173 几乎没有其他工具占用。

DEFAULT_ASSETS_INLINE_LIMIT = 4096——小于 4KB 的资源被内联为 base64 data URI——避免一次 HTTP 请求。这个数字是经验值——太大会让 bundle 膨胀、太小会有太多小请求。4KB 是多年社区共识的均衡点。

ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET——默认编译目标,对应 Baseline widely-available(Web 标准组织定义),约 30 个月以上的浏览器。这确保兼容性的同时允许使用相对新的特性。

1.20 src/node/cli.ts 的入口剖析

Vite 的 CLI 入口是 cli.ts——480 行代码——是用户和 Vite 的第一接触点

// 概念性结构
const cli = cac('vite')

cli
  .command('[root]', 'start dev server')
  .option('--host', 'expose to network')
  .option('--port <port>', 'specify port')
  // ... 几十个选项
  .action(async (root, options) => {
    // 1. 创建 server
    const { createServer } = await import('./server')
    const server = await createServer({ root, ...options })

    // 2. listen
    await server.listen()

    // 3. 输出 URLs
    server.printUrls()
  })

cli.command('build', '...')
cli.command('preview', '...')
cli.command('optimize', '...')

cli.parse()

cac(高性能 CLI argument parser)——比 commander / yargs 都轻量、启动快。

await import('./server') 延迟加载——只有 dev 命令需要 server 模块vite build 不加载 server——每个命令只加载自己需要的代码——CLI 启动时间 ~50ms

这种"懒加载子命令依赖"的模式在 git / cargo / npm 等工具里都用——主 CLI 极小、具体功能按需加载,让频繁但不全执行的命令响应迅速。cli.ts 的 480 行是 Vite 的用户界面,背后是 45k 行的实现——小门面、大内核。

1.21 config.ts 的结构——2704 行讲了什么

config.ts 的 2704 行——Vite 单文件最大——按 section 展开:

2704 行里最密集的是 resolveConfig——约 400 行单函数——merge 用户配置 + 默认值 + 插件返回的配置修改一层层 fallback + validation + normalization——是 Vite 能"零配置但高度可定制" 的引擎。

loadConfigFromFile 也很复杂——因为 vite.config.ts 需要先被 esbuild/Rolldown bundle(把 TS 和 ESM imports 解析掉),再 eval 拿到 config object。config loading 本身也需要一个 mini bundler。

config.ts 的最佳姿势:先从 defineConfig 开始、追踪到 resolveConfig、再看 loadConfigFromFile——按用户 API → 内部合并 → 文件加载的顺序读,比线性读 2704 行有效。

1.22 为什么 Vite 不是"简化版的 Webpack"

一个常见误解是"Vite 就是轻量化的 Webpack、功能少了所以快"。事实上 Vite 的快不是因为功能少,而是架构根本不同:

Webpack 的工作模型

启动时:
  构建完整依赖图 → 打包所有模块 → 准备好给浏览器的 bundle
运行时:
  浏览器请求 bundle.js → server 返回 bundle.js

Vite 的工作模型

启动时:
  启动 HTTP server、做最小初始化(几十 ms)
  依赖预构建(为 CJS → ESM 转换)
运行时:
  浏览器请求 /src/App.vue → server 实时 transform → 返回 ESM module
  浏览器发现 App.vue 里 import Button → 请求 /src/Button.vue → 继续 transform
  ...按需、并行、延迟发生

两个模型的差异不是"Vite 少做事",而是"Vite 把工作推迟到请求时"。总工作量可能差不多,但用户感知的"等待"完全不同——冷启动从"要等编译完"变成"马上能看到第一个页面"。

HMR 也是同理——Webpack 修改一个文件要重新构建整个 chunk graph,Vite 只失效一个 module(及其 HMR-dependent 下游)。这是整体范式不同,不是单点优化。

1.23 Vite 的学习曲线

初学(读第 1-3 章)

进阶(读第 4-8 章)

高级(读第 9-14 章)

专家(读第 15-18 章)

1.24 Vite vs Webpack 的迁移决策——判断框架

如果项目还在用 Webpack、正在考虑迁 Vite:

应该迁 Vite

可能不该迁 Vite

迁移成本估算

迁移后的收益

1.25 Vite 的"非 goals"——它不会做什么

理解工具的边界和理解它的能力同样重要。Vite 明确不做的事:

① 不做 monorepo 编排——那是 Nx、Turborepo、Lerna 的职责——Vite 只负责单个项目的 build

② 不做依赖管理——不会替代 npm/yarn/pnpm——Vite 读 node_modules、不管如何安装

③ 不做代码格式化 / linting——Prettier / ESLint 是独立工具——vite 只 bundle

④ 不做测试运行器——Vitest 是姊妹项目、但独立——vite build/dev 和测试无关

⑤ 不做完整的 framework——没有 routing、没有数据层、没有状态管理——Vite 是构建工具、不是 framework

⑥ 不做微前端 orchestration——没有 module federation 级别的动态加载,可以通过 plugin 实现,但 vite 核心不管

典型组合:

vite 是组件、不是整机——组合使用其他工具才是完整工作流。

1.26 读一个具体的插件——clientInjections.ts 作为范例

src/node/plugins/clientInjections.ts 是 Vite 里最短的内置插件之一(约 100 行)。它的职责是把一些运行时常量注入到 @vite/client@vite/env 这两个浏览器端脚本里:

// 概念性、近似真实代码
export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
  let injectConfigValues: (code: string) => string

  return {
    name: 'vite:client-inject',
    async buildStart() {
      // 准备替换函数
      injectConfigValues = (code) =>
        code
          .replace(`__MODE__`, JSON.stringify(config.mode))
          .replace(`__DEFINES__`, serializeDefine(config.define || {}))
          .replace(`__HMR_PROTOCOL__`, ...)
          .replace(`__HMR_HOSTNAME__`, ...)
          .replace(`__HMR_PORT__`, ...)
          // 等等
    },
    transform(code, id) {
      if (id.endsWith('client.mjs') || id.endsWith('env.mjs')) {
        return injectConfigValues(code)
      }
    },
  }
}

这个插件展示了 Vite plugin 的典型结构

替换逻辑把占位符 __MODE__ / __HMR_PROTOCOL__ 替换成实际配置值——client.mjs 被编译成 static asset,但要在 runtime 知道配置。这种"编译期注入 runtime 常量"是 Vite 的常见模式。不把这些常量放在全局对象里,一方面因为全局对象访问慢,另一方面每个 Vite 实例可能有不同配置(monorepo 场景)——编译到 client 代码里最纯粹。

1.27 配套阅读计划——3 周 30 小时覆盖核心

Day 1(~2 小时):读完本章和第 2 章,扫一遍 src/node/ 目录。

Day 2-3(~4 小时):读 cli.ts(约 480 行)、config.tsdefineConfigresolveConfig(跳过细节)。

Day 4-7(~8 小时):读 server/index.tsserver/moduleGraph.ts,读 1-2 个简单插件(clientInjections.tsjson.ts)。

Week 2(~10 小时):读 optimizer/plugins/importAnalysis.tsplugins/css.ts 前 500 行。

Week 3(~10 小时):读 build.ts + Rolldown 集成、ssr/ 和 Environment API、1-2 个大型插件(css.tsresolve.ts)完整。

1.28 一个常被忽略的特性——server.fs 配置

Vite 有一个很实用但很少被讨论的配置——server.fs

export default defineConfig({
  server: {
    fs: {
      strict: true,        // 严格限制文件系统访问
      allow: ['..'],       // 允许的路径
      deny: ['.env', '.env.*', '*.{crt,pem}'],  // 拒绝的文件
    }
  }
})

这个配置控制 dev server 能从磁盘读取哪些文件。dev server 默认能 serve 任意可访问的文件,如果不限制,用户通过精心构造的 URL 可能读 /etc/passwd/.env 等敏感文件。Vite 6+ 的默认配置(strict: true + 自动 deny .env),开箱即用就有合理的默认 safety。具体实现在 server/middlewares/static.ts——每次 file request 都走这套策略检查,不符合 allow/deny 的直接返回 403。

1.29 import.meta.envloadEnv 的合作

用户在 .env / .env.development / .env.production 里写

VITE_API_URL=https://api.example.com
VITE_APP_VERSION=1.2.3
SECRET_KEY=do-not-leak   # 不以 VITE_ 开头、不会暴露给客户端

代码里用

const apiUrl = import.meta.env.VITE_API_URL

Vite 做了什么?

Step 1:loadEnv 函数src/node/env.ts)——启动时读所有 .env* 文件、合并(按优先级:specific mode 覆盖 generic)。

Step 2:过滤——只把以 VITE_ 开头的变量放进 import.meta.env——SECRET_KEY 被过滤掉——防止敏感信息意外暴露到客户端 bundle

Step 3:注入——clientInjections.ts 插件(§1.26 讲过)把 import.meta.env 替换成实际的对象 literal,编译到 client bundle 里。

前端 bundle 会发给所有用户,如果把所有 env var 都注入,数据库密码、API key 等机密会泄露。前缀 VITE_ 是显式 opt-in——开发者必须选择"这个变量我想暴露给前端"。前缀约定实现的隔离比文档约定健壮——违反约定的代码编译期就不能访问敏感变量。

可以改前缀

export default defineConfig({
  envPrefix: ['VITE_', 'PUBLIC_'],  // 允许两个前缀
})

这允许迁移自 webpack(REACT_APP_ 前缀)或 Nuxt(NUXT_PUBLIC_)的项目复用已有变量命名。

1.30 MODEBASE_URL——还有两个内置 env

除了用户自定义的 VITE_* 变量、Vite 内置两个 env var

这些内置变量让 client 代码能做条件逻辑

if (import.meta.env.DEV) {
  console.log('dev-only debug info')
}

fetch(`${import.meta.env.BASE_URL}api/users`)

Vite 允许任意字符串作为 mode(不只是 dev/prod)——staginge2epreview 都行。用户可以 vite --mode staging 启动,mode 的语义比 NODE_ENV 更广。

PRODDEVMODE === 'production'MODE === 'development' 的 shortcut,Dead Code Elimination 友好:

if (import.meta.env.DEV) {
  // 生产 build 时、这段代码被识别为 unreachable、整个块被 tree-shake 掉
}

这就是"内置常量 + 编译时替换 + tree-shaking"的 dev-only code 模式——比运行时检查(if (process.env.NODE_ENV === 'development'))更彻底:调试代码完全不进入生产 bundle,零字节。React 和 Vue 都 heavily 用这个模式。

1.31 为什么 envPrefix 不能是空字符串

envPrefix 看起来只是一个命名约定,源码里却把它当成安全边界处理。../vite-latest/packages/vite/src/node/config.ts:459-462 在配置类型上明确写着:以 envPrefix 开头的变量会暴露给客户端源码,默认值是 VITE_。真正执行过滤的是 loadEnv:它先读 .env* 文件,再只把匹配前缀的键写入返回对象(../vite-latest/packages/vite/src/node/env.ts:77-82);随后又让真实 process.env 中同前缀的变量覆盖文件值(../vite-latest/packages/vite/src/node/env.ts:84-90)。

这里有两个工程含义。

第一,.env 文件不是"都能进浏览器"。只有显式带前缀的变量才会进入 import.meta.env,这让开发者在命名时做一次安全确认。第二,真实环境变量优先级更高,CI/CD 可以在不改仓库文件的情况下覆盖构建参数。

源码还防了一个危险配置:resolveEnvPrefix 如果发现前缀数组里有空字符串,会直接抛错,因为空前缀等价于"所有变量都可以暴露"(../vite-latest/packages/vite/src/node/env.ts:97-105)。这比文档提醒更可靠;一旦有人想用 envPrefix: '' 图省事,构建阶段就会失败。

flowchart LR
    A[".env / process.env"] --> B["loadEnv(mode, envDir, prefixes)"]
    B --> C{"key startsWith prefix?"}
    C -->|"是"| D["进入 import.meta.env"]
    C -->|"否"| E["留在服务器进程"]
    F["envPrefix: ''"] --> G["resolveEnvPrefix 抛错"]

所以 Vite 的 env 设计不是"读文件 + 字符串替换"这么简单,而是把公开变量和私密变量切成两条路径:公开变量经由前缀进入客户端,私密变量留在 Node 进程。这个边界是前端工程化里最容易被低估的安全设计之一。