Vite 8 深度拆解:当前端决定把 esbuild 和 Rollup 一起删掉——Rolldown/Oxc 统一内核、Full Bundle Mode 与 Vite+ 全链路迁移实战
一句话概括这次变革:Vite 不再是「开发用 esbuild、生产用 Rollup」的双引擎混合动力车,而是换上了一颗用 Rust 打造的统一内核 Rolldown。这不是一次常规的 major 版本升级,这是把发动机拆下来重装的手术。
如果你维护过一个超过 3000 个模块的前端项目,你一定经历过这些荒诞时刻:
- 本地
vite dev跑得飞起,一上 CIvite build就要等 3 分钟,你怀疑人生; - 开发环境里
import.meta.env.VITE_XXX好好的,生产构建出来变成undefined,你查了两小时发现是某个插件只在 Rollup 阶段生效; - 你写了个 Vite 插件,本地开发一切正常,构建时
transform钩子的入参格式变了,你才想起来开发走的是 esbuild 的预构建管线; - 你想给项目上 Module Federation,翻遍文档发现 Vite 的 dev server 阶段根本没有这个概念。
这些不是 bug,是架构债。Vite 从 2020 年那个"给 Vue 用的实验性原型"一路长成 npm 周下载量 2000 万+的基建层,代价就是底层缝合了 esbuild、Rollup、SWC 等一堆职责重叠的工具。Vite 8 干的事情,就是把这笔债一次性还清。
本文会从架构层面把这次变革拆开讲透:Rolldown 凭什么比 Rollup 快 10-30 倍、Oxc 在里面扮演什么角色、Full Bundle Mode 到底改变了什么、迁移过程中哪些配置会炸、以及 Vite+ 这个"统一工具链超集"值不值得跟。全文有大量可直接抄的配置和代码,建议配合你手上的项目一起读。
一、背景:双打包器架构是怎么变成技术债的
1.1 Vite 1-7 的经典架构
先把老架构画清楚,不然后面理解不了为什么非改不可:
┌──────────────────────────────┐
│ Vite 1.x ~ 7.x │
└──────────────────────────────┘
│
┌───────────────────┴───────────────────┐
│ │
┌────────▼────────┐ ┌─────────▼────────┐
│ 开发阶段 dev │ │ 生产构建 build │
├─────────────────┤ ├──────────────────┤
│ 原生 ESM 直出 │ │ Rollup 打包 │
│ esbuild 预构建 │ │ Rollup 插件链 │
│ esbuild transform│ │ Rollup chunking │
│ esbuild 插件 API │ │ Terser/esbuild │
└─────────────────┘ └──────────────────┘
│ │
└───────────────┬───────────────────────┘
│
┌─────────▼─────────┐
│ 大量 glue code │
│ 用来对齐两边行为 │
└───────────────────┘
这个设计在 2020 年是极其聪明的:
- esbuild 用 Go 写,转译速度是 tsc/babel 的几十倍,拿来做 dev 阶段的依赖预构建和 TS 剥离,冷启动直接进入"秒开"时代;
- Rollup 的产物质量和 tree-shaking 能力当时无人能敌,插件生态庞大,拿来做生产构建稳如老狗;
- Vite 自己只负责"编排",不重造轮子,团队规模小也能跑得动。
1.2 债务的四个具体形态
但当项目规模上去、当框架层(Nuxt、SvelteKit、Astro、Remix、TanStack Start)全都把 Vite 当底座之后,问题就藏不住了。
债务一:转换管线不一致
esbuild 和 Rollup 各有一套 transform 流水线。同一段 TS 代码,dev 阶段走 esbuild 的 transform,build 阶段走 @rollup/plugin-* 或 Vite 内置的 esbuild transform 插件——两条路径对装饰器、enum、import type、JSX 自动运行时的处理细节存在细微差别。绝大多数时候没事,但一旦踩到就是"只在生产环境复现"的地狱级 bug。
债务二:插件系统割裂
写一个只在 dev 生效的能力,你得写 esbuild 插件;写一个只在 build 生效的能力,你得写 Rollup 插件;要两边都生效,Vite 给你包了一层 Vite Plugin API,但底层仍要分别下沉。插件作者维护成本翻倍。
债务三:性能天花板
Rollup 是 JavaScript 写的,单线程为主。一个中型项目生产构建 40 秒起步,大型 monorepo 3-5 分钟很常见。更糟的是"多次重复解析":Vite 的插件链里,同一个文件可能被 parse 成 AST 三四次(Vite 内部一次、某个插件一次、Rollup 一次),每次都是 JS 侧完整的 parse → transform → codegen,字符串在 JS 和 native 之间反复搬运。
债务四:高阶能力做不了
极致拆包(fine-grained chunking)、模块级增量构建、Module Federation、持久化缓存——这些能力要求打包器暴露底层模块图的可控入口。Rollup 能做一部分,esbuild 基本封闭,两边还对不上,结果就是 Vite 想做也做不了。
1.3 VoidZero 的解法:自己造一整条链
Vite 团队的答案不是"优化 glue code",而是把整条链自己造一遍,全部用 Rust:
| 层次 | 项目 | 职责 |
|---|---|---|
| 构建工具 | Vite | 编排、dev server、HMR、配置、生态入口 |
| 打包器 | Rolldown | 模块图、chunking、tree-shaking、产物生成 |
| 编译器 | Oxc | parser / resolver / transformer / minifier / linter / formatter |
三层由同一个团队维护,语义完全对齐。这是本次变革最关键的一句话——性能提升只是副产品,"行为一致"才是主目标。
时间线大致是这样的(以公开信息为准):
- 2023 年 ViteConf,Rolldown 项目首次公开;
- 2024 年 Rolldown 开源;
- 2025 年
rolldown-vite作为技术预览包发布,供早期用户试水; - 2025 年底 Vite 8 Beta 发布;
- 2026 年 1 月 Rolldown 1.0 RC;
- 2026 年 3 月 Vite 8.0 正式发布;
- 随后 Rolldown 1.0 正式版、Vite+(Vite Plus)以 MIT 协议开源。
二、核心概念:Rolldown、Oxc、Vite+ 到底各是什么
很多人把这三个名字混着用,先分清楚。
2.1 Rolldown:Rust 版的 Rollup,但目标不是"复刻"
Rolldown 常被描述为"Rust 重写的 Rollup",这个说法只对了一半。准确的定位是:
一个兼容 Rollup 插件 API、性能对标 esbuild、专门为 Vite 的使用场景设计的打包器。
三个设计约束:
- 性能:Rust 编写,多线程并行,接近原生速度。官方口径是与 esbuild 同级,比 Rollup 快约 10-30 倍。
- 兼容性:支持与 Rollup / Vite 相同的插件 API。这意味着绝大多数 Vite 插件在 Vite 8 里开箱即用——这是能推动整个生态迁移的前提。
- 能力扩展:Full Bundle Mode、细粒度 chunk 控制、模块级持久缓存、Module Federation。
真实迁移收益(官方公布的早期用户数据):
| 团队 | 迁移前构建耗时 | 迁移后 | 提升 |
|---|---|---|---|
| Linear | 46s | 6s | ~87% |
| Mercedes-Benz.io | — | — | 最多缩短 38% |
| Beehiiv | — | — | 缩短 64% |
注意 Linear 那个 46s → 6s 不是营销数字,是把整条链换掉之后的结果:省掉的不只是 Rollup 的打包时间,还有 JS/native 边界的字符串搬运、重复 parse、以及 dev/prod 两套管线的 glue 开销。
2.2 Oxc:真正的地基
Oxc(The JavaScript Oxidation Compiler)是这套体系里最容易被忽略、但技术含量最高的一层。它提供:
- oxc-parser:极快的 JS/TS/JSX parser,产出 ESTree 兼容 AST;
- oxc-resolver:模块解析(
node_modules查找、exports字段、tsconfig paths); - oxc-transformer:TS 剥离、JSX 转换、装饰器、target 降级;
- oxc-minifier:压缩器;
- oxc-linter(Oxlint):600+ 条 ESLint 兼容规则,官方口径比 ESLint 快 50-100 倍;
- oxc-formatter(Oxfmt):目标是与 Prettier 99% 兼容。
为什么它是关键?因为语义分析(semantic analysis)可以复用。
传统链路里,parser 产出 AST 之后就丢掉了作用域信息,打包器 tree-shaking 时要重新分析一遍绑定关系。Rolldown 直接复用 Oxc 的 semantic 结果——变量绑定、引用关系、副作用推断全在一次遍历里搞定。这带来两个直接后果:
- Tree-shaking 更准:能识别更复杂的间接引用与副作用边界,产物更小;
- 不需要重复 parse:一个文件在整条链路里理论上只 parse 一次。
这就是"统一工具链"最实在的技术红利,不是简单的"Rust 就是快"。
2.3 Vite+:把整个前端工具链塞进一个 CLI
Vite+ 是建立在 Vite 之上的即插即用超集,MIT 协议开源。它整合的东西是这样一张表:
| 工具 | 用途 |
|---|---|
| Vite + Rolldown | 开发服务器、应用构建 |
| Vitest | 测试框架 |
| Oxlint + Oxfmt | 代码检查与格式化 |
| tsdown | 库构建、独立可执行文件 |
| Vite Task | monorepo 任务编排(带智能缓存,类似 turborepo) |
对应的命令集:
vp new # 脚手架,推荐 monorepo 结构,也可用于生成新 package
vp dev # 开发服务器
vp build # 生产构建
vp preview # 预览产物
vp test # 测试(Vitest 内核,兼容 Jest API,支持浏览器模式与视觉回归)
vp lint # Oxlint
vp fmt # Oxfmt
vp check # 格式化 + lint + 类型检查,一次跑完
vp pack # 库打包(tsdown + Rolldown)
vp run <task> # 带缓存的 monorepo 任务运行器
vp install / add / remove # 代理到 packageManager 声明的包管理器
vp env pin 22.18.0 # Node 版本固定(生成 .node-version)
Vite+ 想解决的是另一个层面的问题:JS 生态的工具碎片化。一个典型项目要管运行时、包管理器、dev server、linter、formatter、test runner、bundler、task runner——每个都有独立配置文件、独立版本、独立升级周期。多团队组织里这些责任分散到没人负责,结果就是依赖版本不同步、构建越来越慢、代码质量滑坡。
三、架构分析:Rolldown 快在哪、Full Bundle Mode 改变了什么
3.1 一次 parse 走天下
用伪代码把新旧链路对比一下最直观。
旧链路(Vite 7):
文件 foo.tsx
↓ Vite 插件链(JS):读文件 → parse(acorn)→ 分析 → magic-string 改写 → stringify
↓ 字符串跨边界传给 esbuild(Go)
↓ esbuild:parse → transform TS/JSX → codegen → 字符串
↓ 字符串传回 JS
↓ Rollup(JS):parse → 建模块图 → tree-shake → chunk → codegen
↓ 字符串传给 minifier(esbuild/terser)
↓ 再 parse 一次 → 压缩 → 输出
一个文件被完整 parse 了 4 次,跨语言边界搬运字符串 3 次。
新链路(Vite 8 + Rolldown + Oxc):
文件 foo.tsx
↓ Rolldown(Rust):oxc-parser 一次 parse → AST + Semantic
↓ 同一份 AST 上:transform(TS/JSX)→ 模块图构建 → tree-shake → chunk
↓ oxc-minifier 直接吃 AST(无需重新 parse)
↓ codegen 输出
↓ JS 插件按需通过 Raw AST transfer / MagicString 桥接介入
parse 次数从 4 降到 1,跨边界搬运接近 0。这才是 10-30 倍的来源——不是 Rust 比 JS 快 10 倍,是重复劳动被消除了。
3.2 dev 与 prod 语义统一
统一内核最直接的工程价值:import.meta.env、?raw、?url、?worker、CSS Modules、asset 处理这些行为在 dev 和 build 阶段走的是同一套代码路径。
以前你写这样的代码要提心吊胆:
// Vite 7 时代的经典地雷
import shaderSource from './shader.glsl?raw'
import workerUrl from './heavy.worker.ts?worker&url'
// dev 下 shaderSource 是字符串,build 下某些插件组合会变成模块对象
// dev 下 workerUrl 是 blob URL,build 下是打包产物路径,行为不完全一致
Vite 8 之后,这些 query 后缀的语义由 Rolldown 内部统一实现,dev/prod 不再有第二条实现路径。插件作者也解脱了:一套 Rollup 兼容的钩子,走遍 dev 和 build。
3.3 Full Bundle Mode:对原生 ESM 教条的一次修正
这是我认为 Vite 8 时代最有意思的架构反转。
Vite 立身之本是"dev 阶段不打包,直接用浏览器原生 ESM"。这个理念在中小项目上无敌——冷启动毫秒级,HMR 与项目规模解耦。但在超大型项目上,它的代价暴露无遗:
- 一个页面首次加载可能触发 上万个 HTTP 请求(每个模块一个请求);
- 浏览器的并发连接数、devtools 的 Network 面板、Service Worker 全部被拖垮;
- 全量刷新(full reload)慢得离谱,因为要重走整个请求瀑布。
Full Bundle Mode 的做法是:dev 阶段也用 Rolldown 打包,但打得足够快,快到你感觉不出来在打包。
官方初步测试数据:
| 指标 | 提升 |
|---|---|
| 开发服务器启动速度 | ~3 倍 |
| 完整页面刷新(full reload) | ~40% |
| 网络请求数量 | 减少约 10 倍 |
这是一次务实的自我修正:原生 ESM 不是目的,快才是目的。当 Rust 打包器快到可以在 dev 阶段实时打包时,"不打包"这个手段就不再必要了。
架构上大概是这样:
请求 /src/main.ts
│
┌────────────┴────────────┐
│ │
[Unbundled 模式] [Full Bundle 模式]
按需 transform 单文件 Rolldown 增量打包成少量 chunk
返回 1 个模块 返回 1 个 chunk(含数百模块)
→ N 个请求 → N/100 个请求
│ │
└────────────┬────────────┘
│
HMR boundary 计算
(两种模式共用同一套模块图)
关键在于共用模块图:因为 dev 和 build 都是 Rolldown,HMR 边界计算逻辑不需要两套实现。
四、代码实战:从 Vite 7 迁移到 Vite 8
4.1 两条升级路径
路径 A:直接升级(推荐给中小项目)
pnpm add -D vite@^8
# 然后照常
pnpm dev
pnpm build
路径 B:渐进迁移(推荐给大型/复杂项目)
先切到 rolldown-vite 技术预览包,把「Rolldown 相关的不兼容」和「Vite 8 其他变更」分开暴露:
# 第一步:Vite 7 + rolldown-vite,只换打包器
pnpm add -D rolldown-vite
package.json 里做别名覆盖:
{
"devDependencies": {
"vite": "npm:rolldown-vite@latest"
}
}
跑通、修完插件兼容问题之后,第二步再升到正式的 Vite 8。这样出问题时你至少知道是哪一层的锅。
4.2 框架依赖的版本覆盖(必看)
如果你用的是 Nuxt / Astro / SvelteKit / Vitest 这类把 Vite 当内部依赖的框架,光升级顶层 vite 没用,得覆盖:
// npm
{
"overrides": {
"vite": "^8.0.0"
}
}
// pnpm
{
"pnpm": {
"overrides": {
"vite": "^8.0.0"
}
}
}
// yarn
{
"resolutions": {
"vite": "^8.0.0"
}
}
改完之后必须删 lock 文件重装,否则 pnpm 的 peer 解析很可能给你留一个旧版本的幽灵副本:
rm -rf node_modules pnpm-lock.yaml
pnpm install
4.3 配置映射:esbuild 选项没了
这是迁移过程中最高频的报错来源。Vite 7 里控制转译的 esbuild 顶层选项,在 Vite 8 里换成了 oxc。
Vite 7:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
esbuild: {
jsxFactory: 'h',
jsxFragment: 'Fragment',
target: 'es2020',
drop: ['console', 'debugger'],
legalComments: 'none',
},
})
Vite 8:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
oxc: {
jsx: {
// Oxc 的 JSX 配置采用结构化写法
runtime: 'classic',
pragma: 'h',
pragmaFrag: 'Fragment',
},
target: 'es2020',
drop: ['console', 'debugger'],
},
build: {
// 压缩器同样切到 oxc
minify: 'oxc',
target: 'es2020',
},
})
具体字段名以官方迁移指南为准,Vite 8 内置了一层兼容映射,很多老写法仍能工作但会打 deprecation 警告。别忽略这些警告,它们是下一个 major 会删的东西。
如果你的插件里出现这样的警告:
warning: `esbuild` option was specified by "vite:xxx-plugin" plugin.
This option is deprecated, please use `oxc` instead.
说明是上游插件还没适配,不影响构建,但要盯着上游更新。
4.4 rollupOptions 还能用吗
能用,但含义变了。Vite 8 保留 build.rollupOptions 作为兼容入口,映射到 Rolldown 的对应能力:
export default defineConfig({
build: {
rollupOptions: {
output: {
// 手动分包依然支持
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react') || id.includes('scheduler')) return 'react-vendor'
if (id.includes('lodash')) return 'lodash-vendor'
return 'vendor'
}
},
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
},
// 外部化
external: ['vue', 'vue-router'],
},
},
})
踩坑预警:部分 Rollup 专有选项在 Rolldown 里没有等价实现,典型的是:
output.file(单文件输出)与output.dir的校验冲突,会报[INVALID_OPTION] Warning: Invalid value for option "output.dir",但产物是对的,属于上游校验 bug;preserveModules的行为细节有差异;- 某些
treeshake的细粒度开关取值不同。
4.5 Rolldown 原生的 chunk 控制
比 manualChunks 更强的是 Rolldown 提供的高级分包策略。manualChunks 是"给我一个 id,我告诉你去哪个 chunk"的命令式接口,容易写出重复打包和循环依赖。Rolldown 允许声明式地描述分包意图:
export default defineConfig({
build: {
rollupOptions: {
output: {
advancedChunks: {
groups: [
{
name: 'framework',
test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/,
priority: 100,
},
{
name: 'ui',
test: /node_modules[\\/]@radix-ui[\\/]/,
priority: 90,
},
{
// 按体积自动切:超过 200KB 的 vendor 自动拆
name: 'vendor',
test: /node_modules/,
minSize: 20_000,
maxSize: 200_000,
priority: 10,
},
],
},
},
},
},
})
priority + minSize / maxSize 的组合能解决 manualChunks 最恶心的两个问题:vendor 巨包和小碎片过多。我的经验值:
- 框架层(react/vue + 路由 + 状态管理)单独一个 chunk,长期缓存命中率最高;
- UI 组件库单独一个,升级频率中等;
- 其余 vendor 按 20KB-200KB 自动切,避免一个 800KB 的
vendor.js拖垮首屏; - 业务代码交给路由级 dynamic import,别手动管。
4.6 Vite 8 的新特性
内置 tsconfig paths 支持
以前要装 vite-tsconfig-paths 插件,现在原生支持:
export default defineConfig({
resolve: {
tsconfigPaths: true, // 默认关闭,有少量性能开销
},
})
对应你的 tsconfig.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@shared/*": ["../shared/src/*"]
}
}
}
注意这个开关默认是 false,因为解析 tsconfig 继承链、通配符匹配是有成本的。只在真的用了 paths 的项目里开。
emitDecoratorMetadata 原生支持
用 TypeORM、NestJS 前端 SDK、InversifyJS 这类依赖装饰器元数据的库,以前在 Vite 里是件痛苦的事(要么上 SWC 插件,要么上 babel)。现在 Oxc 原生支持:
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
一个实际能跑的依赖注入例子:
// container.ts
import 'reflect-metadata'
type Ctor<T = any> = new (...args: any[]) => T
const registry = new Map<Ctor, any>()
export function Injectable(): ClassDecorator {
return (target) => {
// 有了 emitDecoratorMetadata,这里能拿到构造函数参数类型
const params: Ctor[] = Reflect.getMetadata('design:paramtypes', target) || []
Reflect.defineMetadata('di:params', params, target)
}
}
export function resolve<T>(token: Ctor<T>): T {
if (registry.has(token)) return registry.get(token)
const params: Ctor[] = Reflect.getMetadata('di:params', token) || []
const deps = params.map((p) => resolve(p))
const instance = new token(...deps)
registry.set(token, instance)
return instance
}
// service.ts
import { Injectable, resolve } from './container'
@Injectable()
class HttpClient {
get(url: string) {
return fetch(url).then((r) => r.json())
}
}
@Injectable()
class UserService {
// 参数类型 HttpClient 会被 emitDecoratorMetadata 保留到运行时
constructor(private http: HttpClient) {}
getUser(id: string) {
return this.http.get(`/api/users/${id}`)
}
}
const userService = resolve(UserService)
export { userService }
在 Vite 7 里,上面这段代码构建后 design:paramtypes 会是空的(esbuild 不生成元数据),运行时直接崩。Vite 8 里开箱即用。
4.7 插件迁移:一个真实的例子
假设你有一个 Vite 7 插件,做的事情是把源码里的 __BUILD_TIME__ 替换成构建时间戳:
// Vite 7 写法
import type { Plugin } from 'vite'
import MagicString from 'magic-string'
export function buildTimePlugin(): Plugin {
return {
name: 'build-time',
enforce: 'pre',
transform(code, id) {
if (!/\.[jt]sx?$/.test(id)) return null
if (!code.includes('__BUILD_TIME__')) return null
const s = new MagicString(code)
let index = code.indexOf('__BUILD_TIME__')
while (index !== -1) {
s.overwrite(index, index + '__BUILD_TIME__'.length, JSON.stringify(new Date().toISOString()))
index = code.indexOf('__BUILD_TIME__', index + 1)
}
return {
code: s.toString(),
map: s.generateMap({ hires: true }),
}
},
}
}
这段代码在 Vite 8 里原样可用——这是 Rolldown 兼容 Rollup 插件 API 的直接价值。但如果你想吃到性能红利,有两个进阶方向。
方向一:用 filter 收窄触发范围
Rolldown 支持在钩子上声明 filter,让 Rust 侧先过滤,避免每个模块都跨边界调一次 JS 函数:
import type { Plugin } from 'vite'
export function buildTimePlugin(): Plugin {
const BUILD_TIME = JSON.stringify(new Date().toISOString())
return {
name: 'build-time',
enforce: 'pre',
transform: {
// 关键:filter 在 Rust 侧执行,命中才回调 JS
filter: {
id: /\.[jt]sx?$/,
code: '__BUILD_TIME__',
},
handler(code) {
return {
code: code.replaceAll('__BUILD_TIME__', BUILD_TIME),
map: null,
}
},
},
}
}
在一个 5000 模块的项目里,这个改动能把插件的 JS 回调次数从 5000 次降到十几次。跨语言边界调用的开销是真实存在的,一次调用大约几十微秒,5000 次就是几百毫秒白烧。
方向二:能用内置能力就别写插件
上面这个需求其实用 define 就够了:
export default defineConfig({
define: {
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
})
define 的替换发生在 Rust 侧,零 JS 回调。Vite 8 时代写插件的第一原则:先确认内置能力搞不定,再动手。
4.8 Environment API 与多环境构建
Vite 6 引入的 Environment API 在 Vite 8 里终于有了配得上它的底层。典型场景是同一份代码同时构建 client / SSR / edge 三份产物:
import { defineConfig } from 'vite'
export default defineConfig({
environments: {
client: {
build: {
outDir: 'dist/client',
rollupOptions: {
input: 'src/entry-client.ts',
},
},
},
ssr: {
build: {
outDir: 'dist/server',
ssr: true,
rollupOptions: {
input: 'src/entry-server.ts',
},
},
resolve: {
// SSR 环境走 node 条件
conditions: ['node', 'import'],
noExternal: ['some-esm-only-pkg'],
},
},
edge: {
build: {
outDir: 'dist/edge',
ssr: true,
rollupOptions: {
input: 'src/entry-edge.ts',
},
},
resolve: {
// Edge Runtime 走 worker 条件,不能用 node 内置模块
conditions: ['worker', 'browser', 'import'],
external: [],
},
},
},
})
插件里区分环境:
import type { Plugin } from 'vite'
export function envAwarePlugin(): Plugin {
return {
name: 'env-aware',
transform: {
filter: { id: /\/src\// },
handler(code, id) {
// this.environment 提供当前环境上下文
const envName = this.environment?.name
if (envName === 'edge') {
// Edge 环境下把 node:fs 的调用替换成报错桩
if (code.includes('node:fs')) {
this.warn(`[env-aware] ${id} 在 edge 环境引用了 node:fs`)
}
}
return null
},
},
}
}
以前这种需求要靠 process.env.SSR 加一堆 if-else,现在环境是一等公民。
五、性能优化:怎么把 Vite 8 的红利吃满
升级完只是拿到了"免费的那部分"。真正把构建从 40 秒压到 5 秒,还得动手。
5.1 先测量,别猜
不要凭感觉优化。先拿到数据:
# 1. 构建耗时基线(跑三次取中位数,避免冷缓存干扰)
for i in 1 2 3; do
rm -rf dist node_modules/.vite
/usr/bin/time -f "run$i: %e s" pnpm build
done
# 2. 打开 Rolldown 的构建剖析
DEBUG=rolldown:* pnpm build 2>&1 | tee build-trace.log
# 3. 产物体积分析
pnpm add -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
visualizer({
filename: 'dist/stats.html',
gzipSize: true,
brotliSize: true,
template: 'treemap', // treemap 最直观
}),
],
})
一个简易的构建耗时插件,用来定位是哪个阶段慢:
import type { Plugin } from 'vite'
export function timingPlugin(): Plugin {
const marks: Record<string, number> = {}
const durations: Record<string, number> = {}
let transformCount = 0
let transformTotal = 0
const mark = (k: string) => { marks[k] = performance.now() }
const measure = (k: string) => {
durations[k] = performance.now() - (marks[k] ?? performance.now())
}
return {
name: 'timing',
buildStart() { mark('build') },
transform(code, id) {
const t = performance.now()
transformCount++
// 这里不做实际转换,只记账
transformTotal += performance.now() - t
return null
},
renderStart() { measure('build'); mark('render') },
closeBundle() {
measure('render')
console.table({
'buildStart→renderStart (ms)': Math.round(durations.build),
'renderStart→closeBundle (ms)': Math.round(durations.render),
'transform 回调次数': transformCount,
'transform 累计耗时 (ms)': Math.round(transformTotal),
})
},
}
}
5.2 七条经过验证的调优规律
规律一:插件链是新的瓶颈
Rolldown 快了之后,构建时间的大头往往从"打包"变成了"跑 JS 插件"。用上面那个 timing 插件量一下,如果 transform 回调次数是模块数的好几倍,说明你的插件链有严重的重复触发。逐个给插件加 filter。
规律二:干掉 babel
如果你的项目里还有 @vitejs/plugin-react 的 babel 模式、或者独立的 vite-plugin-babel,这是最大的性能黑洞。babel 是纯 JS 单线程,一个文件跑一遍 babel 的开销比 Rolldown 打包整个项目还夸张。
// 差:走 babel
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react({ babel: { plugins: ['babel-plugin-styled-components'] } })],
})
// 好:走 oxc / swc,或者干脆用编译时替代方案
import react from '@vitejs/plugin-react-oxc'
export default defineConfig({
plugins: [react()],
})
如果某个 babel 插件实在无法替代,至少用 include 把它限制在最小的文件集合里。
规律三:optimizeDeps 的角色变了
Vite 7 时代 optimizeDeps 是 esbuild 预构建,是 dev 启动慢的主因。Vite 8 里预构建也走 Rolldown,速度大幅提升,但手动 include 依然有价值——尤其是那些 CJS-only 或者深层动态 import 的包:
export default defineConfig({
optimizeDeps: {
include: [
'lodash-es',
'echarts/core',
'echarts/charts',
'echarts/components',
// 深层路径要显式声明,否则 dev 时会触发二次预构建 + 页面 reload
'@monaco-editor/react',
],
exclude: ['@my-org/local-linked-pkg'],
},
})
判断标准:如果你 pnpm dev 之后打开页面,控制台出现 new dependencies optimized: xxx 并伴随一次自动刷新,就把 xxx 加进 include。
规律四:sourcemap 是隐形税
生产构建开 sourcemap: true 通常要多付 30%-50% 的时间和大量内存。分环境处理:
export default defineConfig(({ mode }) => ({
build: {
// 只在需要上报错误的环境生成,且用 hidden 不暴露给用户
sourcemap: mode === 'production' ? 'hidden' : true,
},
}))
hidden 会生成 .map 文件但不在产物里写 //# sourceMappingURL 注释,你可以上传到 Sentry 之后把 map 文件从 CDN 删掉。
规律五:CSS 处理是另一条独立管线
Rolldown 加速的是 JS 链路,Sass/Less 编译不在其中。如果你的项目 CSS 很重:
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
// 用 modern-compiler API,比 legacy 快数倍
api: 'modern-compiler',
// 避免每个文件都 @import 一遍巨大的变量文件
additionalData: `@use "@/styles/vars" as *;`,
},
},
// 生产环境关掉 devSourcemap
devSourcemap: false,
},
})
顺带一提:additionalData 里塞的东西会注入到每一个 scss 文件,塞的是 @use(只解析一次)还是 @import(每次展开)性能差距是数量级的。
规律六:monorepo 用 Vite Task 的缓存
多包构建最大的浪费是"没变的包也重新构建"。vp run build 带内容哈希缓存:
// vite.config.ts 里的 task 配置(示意)
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": ["dist/**"]
}
}
}
关键是 inputs 要写准。写太宽(比如包含 README.md)会导致改个文档就全量重建;写太窄会导致漏掉真实依赖,产出脏缓存。
规律七:CI 上把 node_modules/.vite 缓存住
# GitHub Actions 示例
- uses: actions/cache@v4
with:
path: |
node_modules/.vite
node_modules/.cache
~/.cache/rolldown
key: vite-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('vite.config.ts') }}
restore-keys: |
vite-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
vite-${{ runner.os }}-
注意 key 里要包含 vite.config.ts 的哈希——配置变了缓存必须失效,否则会出现"本地好好的、CI 上产物是旧的"这种见鬼事件。
5.3 未来的两个性能杀手锏
Vite 团队正在推进的两个实验特性,值得提前了解:
Raw AST transfer:让 JS 插件以极低开销直接访问 Rust 侧生成的 AST,而不是接收字符串再自己 parse。一旦落地,transform 钩子里写 acorn.parse(code) 的插件全部可以省掉一次 parse。
Native MagicString transforms:你依然用 JS 写转换逻辑(s.overwrite()、s.append()),但实际的字符串操作和 sourcemap 计算下沉到 Rust 执行。对于那些做大量小改写的插件,这个优化收益极大。
这两个方向共同指向一个目标:让 JS 插件生态不成为 Rust 内核的性能拖累。这比"再快 10%"重要得多——毕竟 Vite 的护城河从来不是性能,是生态。
六、真实迁移踩坑清单
以下是从公开的迁移案例和实践中整理的高频坑,按出现概率排序。
坑 1:类型导入链断裂
在 Vite+ 场景下,从 vite-plus 导入类型可能导致 dts 生成失败(类型定义引用了 vite-plus-test,解析链断掉):
// ❌ 可能导致 dts 生成失败
import type { Plugin, UserConfig } from 'vite-plus'
// ✅ 类型仍从 vite 导入
import type { Plugin, UserConfig } from 'vite'
规律:运行时 API 从新包导,类型从老包导,直到上游修复。
坑 2:库构建的 CSS 文件名变了
tsdown 默认把 CSS 产物命名为 style.css,而你的 package.json exports 里可能写的是 index.css:
{
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
},
// 补上正确的 CSS 路径
"./dist/style.css": "./dist/style.css"
}
}
消费侧同步改:
// 旧
import 'my-lib/dist/index.css'
// 新
import 'my-lib/dist/style.css'
坑 3:external 被弃用
// ❌ 会打警告:external is deprecated
{ external: ['preact'] }
// ✅
{ deps: { neverBundle: ['preact'] } }
坑 4:单文件输出的假警告
[INVALID_OPTION] Warning: Invalid value for option "output.dir"
配 outputOptions.file 做单文件输出时会报这个,但产物是正确的。属于 Rolldown 内部校验的 bug,暂时忽略,盯上游修复。
坑 5:插件还没适配 oxc
warning: `esbuild` option was specified by "vite:preact-jsx" plugin.
This option is deprecated, please use `oxc` instead.
上游插件的锅,无法自行解决。临时方案是降低日志级别:
export default defineConfig({
logLevel: 'warn', // 或 'error'
})
但别忘了定期回来检查上游是否更新了。
坑 6:CI 脚本检测启动失败
Vite+ 的启动日志格式和 Vite 不同,那些靠正则匹配日志判断"服务起来了"的脚本会挂:
// ❌ 只认 Vite 的输出
if (data.includes('ready in')) isReady = true
// ✅ 两种格式都认
if (data.includes('ready in') || data.includes('Local:')) isReady = true
更稳妥的做法是别匹配日志,改用端口探测:
import net from 'node:net'
function waitPort(port: number, timeout = 60_000): Promise<void> {
const start = Date.now()
return new Promise((resolve, reject) => {
const tick = () => {
const sock = net.connect(port, '127.0.0.1')
sock.once('connect', () => { sock.destroy(); resolve() })
sock.once('error', () => {
sock.destroy()
if (Date.now() - start > timeout) return reject(new Error('port timeout'))
setTimeout(tick, 300)
})
}
tick()
})
}
坑 7:Oxfmt 用的是 Prettier 命名,不是 Biome 命名
从 Biome 迁过来的项目要做一次映射:
| Biome | Oxfmt |
|---|---|
indentStyle: "space" + indentWidth: 2 | tabWidth: 2 |
lineWidth: 80 | printWidth: 80 |
quoteStyle: "single" | singleQuote: true |
semicolons: "asNeeded" | semi: false |
trailingCommas: "all" | trailingComma: 'all' |
七、Vite+:要不要跟
Vite+ 把 29 个配置文件压缩到 13 个、把 5 个 devDependencies 压缩到 1 个,听起来很美。但这是一个架构选型决策,不是一个升级决策。
7.1 迁移的实际操作
Vite+ 官方提供了给 coding agent 用的迁移提示词,核心命令就一条:
# 前置:确保 Vite 8+、Vitest 4.1+
vp migrate --no-interactive
它会自动做:
- 重写
vite导入为vite-plus; - 重写
vitest导入为vite-plus/test; - 更新
package.json的依赖和 scripts; - 更新 workspace 配置。
剩下的手动活是把各工具的独立配置合并进 vite.config.ts:
import { defineConfig } from 'vite-plus'
export default defineConfig({
// 原 vitest.config.ts
test: {
environment: 'happy-dom',
coverage: { provider: 'v8', reporter: ['text', 'lcov'] },
},
// 原 .prettierrc / biome.jsonc 的 formatter 部分
fmt: {
singleQuote: true,
semi: false,
tabWidth: 2,
printWidth: 80,
trailingComma: 'all',
arrowParens: 'always',
bracketSpacing: true,
},
// 原 .eslintrc / biome.jsonc 的 linter 部分
lint: {
rules: {
'no-explicit-any': 'off',
'no-non-null-assertion': 'off',
},
},
// 原 tsdown.config.ts
pack: {
platform: 'browser',
entry: ['./src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
deps: { neverBundle: ['preact'] },
},
})
Git hooks 和 Node 版本也一并接管:
rm lefthook.yml .nvmrc
vp config --hooks # 自动配 pre-commit → vp staged
vp env pin 22.18.0 # 生成 .node-version,自动装对应 Node
验证四连:
vp install && vp check && vp test && vp build
vp check 的输出长这样,一次跑完格式化+lint+类型检查:
pass: All 42 files are correctly formatted (88ms, 16 threads)
pass: Found no warnings, lint errors, or type errors in 42 files (184ms, 16 threads)
42 个文件、272 毫秒。对比一下 eslint . && tsc --noEmit && prettier --check . 串行跑一遍要多久,你就知道"统一工具链"省下来的不只是配置文件。官方说 vp check 比分开跑「类型感知的 lint 规则 + 类型检查」快约 2 倍,原因是类型信息只算一次。
7.2 决策矩阵
| 项目特征 | 建议 |
|---|---|
| 全新项目 | ✅ 直接上,没有历史包袱 |
| monorepo,多包配置分散 | ✅ 收益最大 |
| 工具链复杂(5 个以上独立工具) | ✅ 值得 |
| 依赖冷门 Vite 插件 | ⚠️ 先验证插件兼容性 |
| 构建流程高度定制(自研 CI 深度耦合) | ⚠️ 等 Vite+ 配置面更完整 |
| 生产稳定性要求极高、变更窗口小 | ❌ 再等半年 |
| 只有一个 app、配置就三行 | ❌ 收益抵不上迁移成本 |
时间成本参考:中小项目 2-4 小时,大型 monorepo 1-2 天。
7.3 一点冷静的判断
我对 Vite+ 的态度是"看好但不着急"。理由有三条:
第一,"统一"的代价是耦合。 以前 linter 出问题你可以单独降 ESLint 版本,现在 lint 出问题你得动整个 vite-plus。工具链的独立可替换性是有价值的,被统一之后这个价值就没了。
第二,Oxlint 的规则覆盖还不是 ESLint。 600+ 条规则听起来多,但 ESLint 生态有上万条社区规则。如果你重度依赖 eslint-plugin-import、eslint-plugin-testing-library、某个公司内部规则集,迁过去大概率有缺口。
第三,MIT 开源不等于永远免费。 Vite+ 由 VoidZero 主导,商业公司做基建,MIT 协议是当下的选择。这不是唱衰,只是提醒:把整条工具链绑在单一供应商上,你至少要知道自己在做这个决策。
但 Vite 8 本身不一样,Vite 8 应该升。 它是 Vite 主线,兼容 Rollup 插件 API,风险可控,收益(构建速度、行为一致性、装饰器元数据、tsconfig paths)是实打实的。
八、更大的图景:前端工具链的"Rust 化"到了什么阶段
把视野拉远一点,2024-2026 这三年,前端工具链发生的事情可以概括成一句话:JavaScript 写的工具链正在被系统语言全面替换。
| 层次 | 老方案 | 新方案 | 语言 |
|---|---|---|---|
| 打包器 | Rollup / webpack | Rolldown / Turbopack | Rust |
| 转译器 | Babel | Oxc / SWC | Rust |
| 压缩器 | Terser | Oxc minifier / esbuild | Rust / Go |
| Linter | ESLint | Oxlint / Biome | Rust |
| Formatter | Prettier | Oxfmt / Biome | Rust |
| 包管理器 | npm / yarn | pnpm (Node) / bun (Zig) / pacquet (Rust) | 混合 |
| 类型检查 | tsc (TS) | tsgo (Go) | Go |
| 测试运行器 | Jest | Vitest | Node |
| 运行时 | Node.js | Bun / Deno | Zig / Rust |
有意思的是,这一轮替换里出现了两条不同的路线:
- VoidZero 路线(Rolldown + Oxc + Vite+):Rust,强调兼容既有生态(Rollup 插件 API、ESLint 规则、Prettier 配置),用"无痛迁移"换采用率;
- 微软路线(TypeScript 7 用 Go 重写编译器):Go,强调与现有 TS 代码库的结构对应关系,用"逐行移植"降低正确性风险。
两条路线选了不同的语言,但共享同一个判断:JS 写的工具链已经触到了性能天花板,而这个天花板正在成为整个生态的效率瓶颈。
对我们写业务的人来说,这轮变革的实际含义很朴素:
- 构建时间从"泡杯咖啡"变成"眨个眼",CI 成本下降,反馈循环变紧;
- 工具的行为更可预测,因为实现收敛到了同一套语义;
- 插件生态会经历一次洗牌,那些用 babel、用 acorn 手写 AST 遍历的插件会逐渐被内置能力或原生实现替代;
- 配置的心智负担在降低,但供应商集中度在升高。
第 4 条值得单独琢磨。十年前我们抱怨 webpack 配置地狱,今天我们抱怨的可能是"整条链都在一家手里"。这不是坏事也不是好事,是工程演进的常态——从碎片化到集中化,再从集中化裂变出新的碎片。你要做的不是站队,是搞清楚每一次选择的代价是什么。
九、总结:一份可执行的行动清单
如果你只想带走一份 checklist,就是下面这个。
立刻可以做(半小时):
# 1. 看看你现在多慢,留个基线
rm -rf dist node_modules/.vite && time pnpm build
# 2. 建分支
git checkout -b chore/vite8-migration
# 3. 升级
pnpm add -D vite@^8
升级过程中(1-3 小时):
- 用
overrides/resolutions覆盖框架内部的 vite 版本,删 lock 重装 -
esbuild顶层选项 →oxc -
build.minify: 'esbuild'→'oxc' - 检查
rollupOptions里的 Rollup 专有选项,尤其output.file、preserveModules - 跑一遍构建,把所有 deprecation 警告记下来,逐条处理
- 对比新旧产物体积和 chunk 划分(
rollup-plugin-visualizer存两份 stats.html 对比) - 跑完整的 E2E,重点测
?raw/?url/?worker/ CSS Modules / 动态 import 的路径
升级之后(持续):
- 给自研插件加
transform.filter,收窄触发范围 - 干掉 babel,换 oxc / swc 版本的插件
- 用
advancedChunks替代手写manualChunks -
sourcemap改hidden - scss 切
modern-compilerAPI - CI 缓存
node_modules/.vite,key 带上 config 哈希 - 关注 Full Bundle Mode 的正式发布,大型项目 dev 体验会再上一个台阶
保持观望:
- Vite+ 的
vp工具链——新项目和 monorepo 可以试,老项目再等等 - Raw AST transfer / Native MagicString——插件作者提前关注
最后说句实在的。这些年前端的"版本升级"太多了,多到大家产生了免疫。但 Vite 8 这一次不太一样——它不是加了几个 API,是把地基换了。换地基这种事,早做比晚做痛苦少,因为整个插件生态会跟着 Rolldown 走,你拖得越久,遇到"这个插件已经不维护 Vite 7 分支了"的概率越大。
至于 Vite+,我的建议是:看着,学着,但别急着把整个团队的工具链押上去。基建这东西,快一步是先锋,快三步是先烈。