Vite 设计与实现

第1章 为什么需要理解 Vite

作者 杨艺韬 · 8,509 字 · 发布于 · 更新于

本章要点

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

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

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

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

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

  • 冷启动慢:一个中型项目(5000 个模块)的 webpack-dev-server 启动需要 30-60 秒
  • 热更新慢:修改一个文件后,需要重新构建整个 chunk 图
  • 配置复杂:webpack.config.js 动辄数百行,loader/plugin 的组合爆炸

第二代:编译加速(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×

表里的数字是按常见中型项目经验给出的量级估算(Vite 侧按 Vite 8 + Rolldown 设想),不是可复现的实测,换机器、换插件集能差出成倍。它只用来说明差距的数量级,不要拿它和任何具体 benchmark 横向相减。

冷启动的差距为什么这么大?因为 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 回调,但不会告诉你:

  • HMR 更新如何从文件变更传播到浏览器
  • 模块图的"软失效"和"硬失效"有什么区别
  • 循环依赖在 HMR 中如何处理

插件开发的底层知识

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

  • 知道每个 Hook 的最佳使用时机
  • 避免与内置插件冲突
  • 利用内部 API 实现更强大的功能

可迁移的架构模式

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 的工程规模:

packages/vite/src/node/    约 40,000 行 TypeScript(不含 __tests__)
  config.ts                            2,703 行   配置解析和合并
  server/                              约 9,700 行  dev server / HMR / moduleGraph
  optimizer/                           约 4,000 行  依赖预构建
  plugins/                             约 14,000 行 内置插件(27 个源文件、30 多个插件)
  ssr/                                 约 1,600 行  SSR 转换与 ssrLoadModule 兼容层
  build.ts                             ~1,900 行   生产构建集成 Rolldown

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

对比几个数字:

  • Webpack core——约 20 万行 JS(webpack@5.109.2 的 lib/ 目录;功能更多、但单位功能代码量也高)
  • Rollup core——约 1 万行 TS;接棒它的 rolldown 则是约 9 万行 Rust(rolldown@1.0.0-rc.12 的 crates/,不含测试)
  • esbuild——约 10 万行 Go(完全不同的语言)

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

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

打开 packages/vite/src/node/config.ts:175-180——你会看到 defineConfig 有 6 个函数重载:

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 === 'development' ? { ... } : loadRemoteConfig())——返回 UserConfig | Promise<UserConfig>
  6. 兜底 defineConfig(config)——接收任意 UserConfigExport

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

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

IDE 里你写 c1. 时自动补全出现的是 UserConfig 的字段、写 c2( 时提示的是 UserConfigFnObject 的调用签名——精确到具体类型。

合并成一个 defineConfig(config: UserConfigExport): UserConfigExport 能工作,但补全会退化——返回值成了五种类型的联合,c1. 只能补全各联合成员共有的成员、不精确。6 个重载是为了精确的 IDE 类型补全而付出的 API 成本。

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

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

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

四大模块的职责分工:

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

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

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

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

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

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

1.8 为什么 Vite 6 引入 Environment API

Vite 5 及之前、client / ssr 共用一个插件容器和一张模块图,靠 ssr: boolean 参数在各处分叉——transformRequest(url, { ssr })、pluginContainer.transform(code, id, { ssr })、ModuleNode.ssrModule——分支散落、维护困难。

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

这个改动的代码影响:

  • src/node/server/——原先的 single ModuleGraph 变成 EnvironmentModuleGraph(每个 Environment 一个)
  • packages/vite/src/node/environment.ts + baseEnvironment.ts——新增、定义 Environment 抽象
  • Plugin 接口新增 applyToEnvironment 钩子、允许插件只对特定 environment 生效(preAlias.ts、terser.ts、esbuild.ts 等内置插件已经用上)

收益:

  • 新增 environment(比如 edge runtime、RSC)只要定义新 Environment、不改核心代码
  • 用户插件能精确控制"我只在 ssr 跑"——精细度大幅提升
  • Framework 作者(Nuxt / SvelteKit / Remix)能更自由地组合 environments

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

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

Vite 8 把生产构建从 Rollup 迁到 Rolldown(Vite 7 期间以 rolldown-vite 过渡包试水)——这个选择值得细说:

为什么不继续用 Rollup?

  • Rollup 用 JS 写、性能卡在 single-thread JS 执行
  • 大型项目 build 动辄十几秒乃至更久——dev 启动已是毫秒级、build 时间显得不成比例

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

  • API 不完全兼容 Rollup——Vite 用户和插件生态依赖 Rollup API(resolveId、load、transform、generateBundle 等 hook)
  • esbuild 的 plugin 系统能力弱于 Rollup——某些 Vite 插件难以迁移

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

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

TypeScript 基础:

  • 懂 interface vs type、泛型、条件类型
  • 懂 function overload(§1.6 就用到)
  • 懂 Partial<T> / Required<T> / Omit<T> 等 utility types

Node.js 基础:

  • 懂 fs.promises / path / url 模块
  • 懂 HTTP server 和 stream
  • 懂 esbuild-plugin / rollup-plugin 基础(Vite 兼容 rollup-plugin)

构建工具基础:

  • 知道 Webpack / Rollup / esbuild 大致做什么
  • 懂 Tree shaking、code splitting、chunk、bundle 的定义
  • 知道 CommonJS vs ESM 的区别

HMR 概念:

  • 知道 HMR 是 Hot Module Replacement、和 live reload 不同
  • 看过 if (import.meta.hot) 的代码

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

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

① hotUpdate——HMR 自定义处理:

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

返回值三种选择:

  • 返回 modules 数组——缩小 HMR 范围(比如 Vue SFC 只更新 template 而非整个组件)
  • 返回空数组 + 手动调 environment.hot.send()——完全自定义 HMR 消息
  • 什么都不返回——走默认 HMR 逻辑

三种返回对应三种插件用例——这种精细的 hook 协议让插件作者能精确控制 HMR 行为。框架插件可以借它实现 framework-specific 的 HMR。

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

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

filter.id——让插件声明 "我只处理 .vue 文件"——Vite 在调用 hook 前就判断、不匹配的文件根本不调 transform——省下的正是这部分 hook 调用开销(具体比例取决于插件集合,§1.14 再展开)。

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

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

让插件作者精确控制:

  • 插件在 multi-env build 时复用一个实例还是每个 env 一个
  • buildStart / buildEnd 是每个 env 调一次还是只调一次
  • watchChange 同上

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

1.12 Vite 的 30 多个内置插件——一张清单

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

asset.ts           - 静态资源(图片、字体)
assetImportMetaUrl.ts - new URL(xx, import.meta.url)
clientInjections.ts - 客户端运行时常量注入
css.ts             - CSS 处理(3552 行,含 cssPlugin / cssPostPlugin / cssAnalysisPlugin)
define.ts          - 全局常量替换
dynamicImportVars.ts - 动态 import 变量支持
esbuild.ts         - esbuild 兼容层(build.minify: 'esbuild' 等)
esbuildBannerFooterCompatPlugin.ts - esbuild banner/footer 选项兼容
forwardConsole.ts  - 浏览器 console 转发到服务端
html.ts            - HTML 入口处理
importAnalysis.ts  - import 语句分析 + HMR 注入
importAnalysisBuild.ts - build 时的 import 分析
importMetaGlob.ts  - import.meta.glob
json.ts            - JSON 相关类型与 isJSONRequest(19 行;JSON 插件本身是 Rolldown 原生的 viteJsonPlugin)
license.ts         - 构建产物的许可证汇总
manifest.ts        - build manifest 生成
modulePreloadPolyfill.ts - <link rel="modulepreload"> polyfill
optimizedDeps.ts   - 预构建依赖加载
oxc.ts             - Oxc 转换器
preAlias.ts        - 预构建依赖的别名预处理
prepareOutDir.ts   - 清理/准备输出目录
reporter.ts        - 构建进度报告
resolve.ts         - 模块解析(1242 行)
terser.ts          - Terser 压缩
wasm.ts            - WebAssembly
worker.ts          - Web Worker
workerImportMetaUrl.ts - new Worker(new URL(...))

27 个文件(不含 index.ts 与 pluginFilter.ts;其中 json.ts 已不含插件,见 packages/vite/src/node/plugins/index.ts:101),部分文件导出多个插件(css.ts 就有三个),加上 build.ts、ssr/、optimizer/ 等处的插件,总共 30 多个内置插件。

每个插件文件的规模从二十几行到三千多行——css.ts 最大 3552 行(因为要处理 PostCSS、CSS Modules、sass/less/stylus 预处理器、HMR)——reporter.ts 最小 26 行(它只是包一层 Rolldown 原生的 reporter 插件)。

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

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

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

  1. import 'foo.css' 触发 resolveId——resolve.ts 插件解析 foo.css 的绝对路径
  2. load 阶段——没有插件接手时,server/transformRequest.ts 的 loadAndTransform 直接用 fsp.readFile 兜底读文件
  3. transform 阶段——css.ts 插件接手:
    • 如果是 .module.css → CSS Modules 处理、生成 hash-prefixed 类名
    • 如果是 .scss → sass 预处理器编译
    • 标准 CSS → PostCSS 处理(autoprefixer 等)
  4. import analysis——packages/vite/src/node/plugins/importAnalysis.ts 把 import 'foo.css' 重写成 /src/foo.css(不加 ?import:isExplicitImportRequired 只对「既不是 JS 也不是 CSS」的请求加这个查询参数,见 packages/vite/src/node/plugins/importAnalysis.ts:103-105 与 packages/vite/src/node/plugins/importAnalysis.ts:418-419;该模块发生过 HMR 之后才会追加 ?t= 时间戳)
  5. 浏览器请求 /src/foo.css——Vite dev server 从 moduleGraph 取已 transform 的内容、返回 JS module(这份内容来自第 4 步之后的预转换:import analysis 会对静态导入调 warmupRequest 提前走完第 1–3 步,由 dev.preTransformRequests 控制、client 环境默认开启;关掉时就在此刻当场转换)
  6. 返回的 JS 调 __vite__updateStyle(...)——把 CSS 注入到 <style> 标签、实现 HMR-friendly 样式

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

1.14 filter hook 的性能估算

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

  • 无 filter(Vite 6.3 之前):每个文件都要把所有注册了 transform 钩子的插件依次调一遍(插件容器只遍历有该钩子的插件——packages/vite/src/node/server/pluginContainer.ts:551 的 this.getSortedPlugins('transform'),而 getSortedPluginsByHook 会跳过没有该钩子的插件,见 packages/vite/src/node/plugins/index.ts:170-171;所以这个数远小于 plugins/ 目录下的 27 个文件),函数调用开销随文件数线性累积
  • 有 filter(Vite 6.3+):绝大多数插件在进入 handler 之前就被 id / code 过滤掉,只有真正匹配的那几个被调用

把过滤前移到调用前,是架构级别的性能收益,不是微优化。(这里不给具体毫秒数——真实开销取决于插件集合与文件规模,本仓不抄无法复现的基准。)

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

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

  1. 文件系统 watch——chokidar 监听项目根目录(外加配置文件依赖、.env 文件与 public 目录)
  2. 文件变更事件——chokidar emit change event
  3. Vite server 接收——对每个 environment 调用 moduleGraph.onFileChange(file),内部经 invalidateModule 把这个 module 标记失效
  4. 调用插件 hotUpdate hook——Vue / React 插件可能缩小更新范围
  5. 计算受影响模块——从变更模块向上找 accept boundary(第 7 章详讲)
  6. 通过 WebSocket 发 HMR payload——{ type: 'update', updates: [...] }
  7. 浏览器客户端接收——@vite/client 运行时
  8. 客户端调 import.meta.hot.accept callback——或者做 full reload
  9. 用户看到更新——整条链路通常在毫秒级完成(经验判断,随模块规模与插件集变化)

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

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

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

Framework 层面:

  • Nuxt 3——默认使用 Vite(之前是 Webpack)
  • SvelteKit——基于 Vite
  • SolidStart——基于 Vite
  • Remix v2——支持 Vite(取代原先基于 esbuild 的 Remix 编译器)
  • Qwik——基于 Vite
  • Astro——基于 Vite
  • Vue 3 官方脚手架(create-vue)——Vite
  • React 官方文档——create-react-app 退役后,「从零搭建 React 应用」一节把 Vite 列为可选构建工具之一(与 Parcel、Rsbuild 并列;文档首推的是框架)

多数主流前端 framework 已经基于或支持 Vite。

性能定位:Vite 8 的生产构建换成 Rust 写的 Rolldown 之后,与 Turbopack、Rspack 这类 Rust 构建工具处在同一梯队,明显快于 JS 实现的 Webpack 5;具体倍数取决于项目、插件集与机器,这里不引用无法复现的基准数字。

Community:

  • npm weekly downloads——约 6000 万(2026 Q1 均值:npm 官方 downloads API 给出 2026-01-01 至 03-31 共约 8.0 亿次下载)
  • GitHub stars——80k+
  • Ecosystem 插件——500+ vite-plugin-*
  • MIT licensed,由 VoidZero(Evan You 创立)全职维护

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

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

  • Vite——构建工具(TS 写的高层协议 + 调度)
  • Rolldown——打包器(Rust 写的 Rollup 兼容 bundler、§1.9 讲过)
  • Oxc——下一代 JS/TS 解析器和 linter(Rust、目标替代 Babel/ESLint)
  • rolldown-vite——Vite 7 时期的过渡包,提前把 Vite 的构建引擎换成 Rolldown 供社区试用,已并入 Vite 8;它孵化的全量打包开发模式(experimental.bundledDev)在 8.0 里仍是实验特性

分工上,Rolldown 和 Oxc 是 Rust 写的高性能核心、再通过 JS/TS API 暴露给用户,Vite 本体仍是 TS 写的调度层。VoidZero 的长期规划是逐步把 Vite 依赖的 esbuild(Go)、Rollup、Babel 等工具替换成 Rust 实现,兼顾原生性能与 Rollup 插件生态的兼容。

1.18 packages/vite/src/node/constants.ts 的魔法数字

Vite 的核心行为很多由魔法常量控制——集中在 packages/vite/src/node/constants.ts(逐个对应:packages/vite/src/node/constants.ts:207 / :209 / :211 / :220 / :193 / :90-96;最后那个数组的上方注释 packages/vite/src/node/constants.ts:86-88 就写着「bumped on each major release」与「generated by pnpm generate-target」):

// 节选
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.-]+)\b/  // 缓存版本查询参数
export const ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET = [
  'chrome111', 'edge111', 'firefox114', 'safari16.4', 'ios16.4',
]  // 默认浏览器兼容目标(每个大版本由 pnpm generate-target 重新生成)

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

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——默认编译目标,对应 2025-05-01 这天 Baseline Widely Available 所覆盖的浏览器版本(源码注释 packages/vite/src/node/constants.ts:84;Baseline 由 W3C WebDX 社区组定义,Widely Available 指特性在主流浏览器全部可用已满 30 个月)。这确保兼容性的同时允许使用相对新的特性。

1.19 packages/vite/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——每个命令只加载自己需要的代码。

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

1.20 config.ts 的结构——2703 行讲了什么

config.ts 的 2703 行——node/ 根目录下最大的单文件(整个 src/ 里只有 plugins/css.ts 的 3552 行比它长)——按 section 展开:

  • Line 1-138: imports + 基础类型
  • Line 139-174: ConfigEnv / UserConfigFn* / UserConfigExport 类型
  • Line 175-183: defineConfig 6 个重载 + 实现签名
  • Line 184-338: DevEnvironmentOptions / EnvironmentOptions 等 environment 相关配置类型
  • Line 339-623: UserConfig 接口——用户能写的全部字段
  • Line 624-728: ResolvedConfig
  • Line 729-1355: 各类 resolveXxxOptions(dev / environment / resolve / depOptimization)
  • Line 1356-2144: resolveConfig 主函数——配置合并和处理的核心,configResolved 钩子也在它内部调用
  • Line 2145-2212: resolveBaseUrl / sortUserPlugins——base 归一化、plugin 排序
  • Line 2213-2590: loadConfigFromFile + bundleConfigFile + loadConfigFromBundledFile——读取 vite.config.ts
  • Line 2591-2703: runConfigHook / runConfigEnvironmentHook——插件配置 hook 调用,以及 optimizeDeps 的向后兼容层

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

loadConfigFromFile 也很复杂——默认的 configLoader: 'bundle' 要先用 Rolldown 把 vite.config.ts bundle 一遍(把 TS 和 ESM imports 解析掉),再加载拿到 config object(另有 runner / native 两种加载器)。config loading 本身也需要一个 mini bundler。

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

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

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

Webpack 的工作模型:

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

Vite 的工作模型:

启动时:
  启动 HTTP server、做最小初始化
  依赖预构建(为 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.22 Vite 的学习曲线

初学(读第 1-3 章):

  • 能跑 npm create vite@latest
  • 理解 vite.config.ts 的基本字段
  • 知道 plugin 是什么、怎么加

进阶(读第 4-8 章):

  • 理解 dev server 如何工作
  • 能 debug HMR 不工作的问题
  • 会写简单的 vite plugin

高级(读第 9-14 章):

  • 理解各种文件类型如何被 transform
  • 能写复杂的插件(比如新 framework 的 HMR 支持)
  • 能做 build 性能调优

专家(读第 15-18 章):

  • 理解 SSR、Worker、Environment API
  • 能 fork vite 做企业定制
  • 能贡献上游 PR

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

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

应该迁 Vite:

  • ✓ 项目 dev 启动 > 20s
  • ✓ HMR 响应 > 1s
  • ✓ 构建时间 > 60s
  • ✓ 项目是新建的或未来 1-2 年会大改
  • ✓ 团队 TypeScript / ESM 友好

可能不该迁 Vite:

  • ✗ 已有 50+ 个自定义 webpack loader 或 plugin
  • ✗ 依赖特定 webpack 功能(比如 federation、特定 chunk strategy)
  • ✗ 项目接近 EOL、1 年内会重构或废弃
  • ✗ 团队没有时间做迁移 + 测试

迁移成本估算:

  • 小项目(几千行): 1-2 天、几乎无痛
  • 中型项目(几万行): 1-2 周、需测试各种边界 case
  • 大型 monorepo(几十万行): 1-2 月、要组建迁移小组

迁移后的收益:

以下是定性判断,幅度因项目差异很大:

  • dev 启动显著提速——开发效率显著
  • HMR 从 "卡" 到 "即时"——开发体验飞跃
  • 构建时间通常明显缩短(Rolldown)
  • vite.config.ts 通常比 webpack.config.js 简洁得多——维护成本降

1.24 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 + vitest + prettier + eslint + pnpm = 现代前端开发栈
  • nuxt / sveltekit / remix + vite = 全栈 web 应用

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

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

packages/vite/src/node/plugins/clientInjections.ts 是 Vite 里较短的内置插件之一(150 行)。它的职责是把一些运行时常量注入到 @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) {
      const cleanId = cleanUrl(id)
      if (cleanId === normalizedClientEntry || cleanId === normalizedEnvEntry) {
        return injectConfigValues(code)
      }
    },
  }
}

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

  • name——plugin ID、用于调试
  • buildStart——启动时准备
  • transform——每个 module 被 transform 时的 hook

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

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

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

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

Day 4-7(~8 小时):读 packages/vite/src/node/server/index.ts、packages/vite/src/node/server/moduleGraph.ts,读 1-2 个简单插件(clientInjections.ts、reporter.ts)。

Week 2(~10 小时):读 optimizer/、packages/vite/src/node/plugins/importAnalysis.ts、packages/vite/src/node/plugins/css.ts 前 500 行。

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

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

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

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

这个配置控制 dev server 能从磁盘读取哪些文件。dev server 默认能 serve 任意可访问的文件,如果不限制,用户通过精心构造的 URL 可能读 /etc/passwd、/.env 等敏感文件。Vite 的默认配置(strict: true + 自动 deny .env / 证书 / .git,见 packages/vite/src/node/server/index.ts:1171-1175),开箱即用就有合理的默认 safety。具体实现在 packages/vite/src/node/server/middlewares/static.ts——每次 file request 都走 isFileLoadingAllowed(packages/vite/src/node/server/middlewares/static.ts:292),不符合 allow/deny 的直接 res.statusCode = 403(packages/vite/src/node/server/middlewares/static.ts:342)。

1.28 import.meta.env 和 loadEnv 的合作

用户在 .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 函数(packages/vite/src/node/env.ts)——启动时读所有 .env* 文件、合并(按优先级:specific mode 覆盖 generic)。

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

Step 3:注入——开发模式下由 importAnalysis.ts 在用到 import.meta.env 的模块开头插入一句 import.meta.env = {...} 赋值(packages/vite/src/node/plugins/importAnalysis.ts:245-249、:746-748;clientInjections.ts 只负责 @vite/client、@vite/env 里的占位符,见 §1.25),构建时由 define.ts 完成同样的替换。

前端 bundle 会发给所有用户,如果把所有 env var 都注入,数据库密码、API key 等机密会泄露。前缀 VITE_ 是显式 opt-in——开发者必须选择"这个变量我想暴露给前端"。前缀约定实现的隔离比文档约定健壮——不带前缀的变量根本不会被注入客户端,代码里读到的只是 undefined。

可以改前缀:

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

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

1.29 MODE 和 BASE_URL——还有两个内置 env

除了用户自定义的 VITE_* 变量、Vite 还内置了一组 env var:

  • import.meta.env.MODE——当前模式字符串('development' / 'production' / 自定义)
  • import.meta.env.BASE_URL——config.base(默认 /、生产可能是 /my-app/)
  • import.meta.env.PROD / import.meta.env.DEV——boolean shortcuts
  • import.meta.env.SSR——是否运行在 SSR 环境

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

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

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

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

PROD 和 DEV 看的不是 mode,而是 process.env.NODE_ENV === 'production' 算出的 isProduction(packages/vite/src/node/config.ts:1673、:1932-1933),所以 vite build --mode staging 下 PROD 默认仍为 true(除非 .env.staging 里写了 NODE_ENV=development)。它们是编译期常量,Dead Code Elimination 友好:

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

这就是"内置常量 + 编译时替换 + tree-shaking"的 dev-only code 模式——调试代码完全不进入生产 bundle,零字节。Vite 对非库模式构建里的 process.env.NODE_ENV 也做同样的静态替换(packages/vite/src/node/plugins/define.ts:17-29),React、Vue 发布包里按 process.env.NODE_ENV 分叉的开发期代码正是靠这一步被裁掉。

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

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

这里有两个工程含义。

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

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

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 进程。这个边界是前端工程化里最容易被低估的安全设计之一。