Vite 8 + Rolldown 深度拆解:Rust 打包器如何终结「dev ≠ prod」,以及 Cloudflare 收购 VoidZero 之后的前端工具链版图
有个问题,如果你写了几年前端,大概率半夜遇到过:本地 vite dev 跑得好好的,一执行 vite build,页面白屏、import.meta.env 变 undefined、某个 ?raw 导入的字符串莫名其妙变成了模块对象。你翻遍配置找不到原因,最后在某个 GitHub issue 里看到一句轻描淡写的回复:「dev 用 esbuild,build 用 Rollup,这两个东西的行为本来就不一样。」
这不是 bug,这是架构。Vite 从 1.x 到 7.x,心脏一直是两个:开发态 esbuild,生产态 Rollup。这个设计在 2019 年是天才,在 2026 年是债。
Vite 8 干的事情,说白了就一句话:把两颗心脏换成一颗,换成用 Rust 写的 Rolldown。
这篇文章不打算复述发布公告。我们要做的是:把 Rolldown 的内部流水线拆开看,把「快 10-30 倍」这个数字放在显微镜下验证,把 Full Bundle Mode 和模块级持久缓存这些真正解锁的新能力讲透,最后聊聊 Cloudflare 把 VoidZero 收了之后,这条工具链的钱和权力结构会怎么变。
一、背景:Vite 的双打包器架构,是怎么从优势变成负债的
1.1 2019 年那个天才决定
回到 Vite 诞生的那个时间点。当时前端构建的主流是 Webpack,痛点非常明确:
- 冷启动慢:Webpack 必须把整个依赖图扫一遍、打成 bundle,才能让 dev server 跑起来。中型项目 30 秒起步,大型项目 2-5 分钟。
- HMR 慢:改一行代码,Webpack 要重新走一遍受影响的模块链路,热更新延迟随项目规模线性增长。
Vite 的解法是 no-bundle dev:既然现代浏览器原生支持 ESM,那开发时根本不需要打包。浏览器请求哪个模块,dev server 就现场转译哪个模块,转译完直接吐给浏览器。冷启动时间从「和项目规模成正比」变成「和项目规模基本无关」。
但这里有两个问题需要解决:
- node_modules 里的包不是 ESM(大量 CJS),而且一个 lodash 就有 600 多个文件,浏览器逐个请求会直接卡死。→ 解法:依赖预构建(dependency pre-bundling),用 esbuild 把 CJS 转 ESM 并合并成少数几个文件。
- 生产环境不能用 no-bundle。HTTP/2 再快,几千个模块请求的瀑布流也扛不住,而且没有 tree-shaking、没有代码分割。→ 解法:生产构建用 Rollup,成熟稳定,tree-shaking 业界标杆。
于是就有了这个经典架构:
开发态:Vite Dev Server + esbuild(转译 TS/JSX + 依赖预构建)
生产态:Vite Build + Rollup (打包 + chunk 分割 + tree-shaking + minify)
这个决定让 Vite 团队把精力集中在「开发体验和整体编排」上,而不是从零造一个解析器和打包器。战略上完全正确,Vite 也确实靠这个吃下了整个前端脚手架市场。
1.2 债从哪里开始滚的
问题在于,esbuild 和 Rollup 是两个独立演进、设计哲学不同的项目:
| 维度 | esbuild | Rollup |
|---|---|---|
| 语言 | Go | JavaScript |
| 定位 | 极速转译 + 简单打包 | 精细打包 + 生态插件 |
| 插件 API | esbuild plugin(简化版) | Rollup plugin(钩子丰富) |
| tree-shaking | 有,但相对保守 | 业界最激进最精细 |
| CJS 互操作 | 自己一套规则 | 自己另一套规则 |
| 装饰器/Legacy 语法 | 支持有限 | 靠插件生态 |
这带来了三类具体的、每天都在咬人的问题:
**第一类:转换流水线分裂。**同一个 .tsx 文件,dev 时走 esbuild 的 transform,build 时走 @vitejs/plugin-react + Rollup 的 transform。两者对 enum、namespace、参数装饰器、useDefineForClassFields 的处理细节不完全一致。你在 dev 里写的某个 TS 特性,build 出来行为变了。
**第二类:插件系统割裂。**写一个 Vite 插件,你得同时考虑:
// Vite 7 时代典型的「双面人」插件
export default function myPlugin() {
return {
name: 'my-plugin',
// Rollup 钩子:build 时生效
transform(code, id) {
if (!id.endsWith('.special')) return
return transformSpecial(code)
},
// Vite 专属:dev server 生效
configureServer(server) {
server.middlewares.use('/api/special', handler)
},
// 还得单独管依赖预构建阶段的 esbuild
config() {
return {
optimizeDeps: {
esbuildOptions: {
plugins: [esbuildVersionOfMyPlugin()] // 同样逻辑写第二遍
}
}
}
}
}
}
同一个转换逻辑,在依赖预构建(esbuild)、dev transform(Vite 自己的流水线)、prod build(Rollup)三个地方可能要写三次。这不是插件作者矫情,这是架构强加的税。
**第三类:功能天花板。**你想在 dev server 里用 Module Federation?Rollup 生态有对应方案,esbuild 侧没有 → 做不了。你想让 dev 和 prod 的 chunk 策略保持一致做性能验证?dev 根本没有 chunk 概念 → 做不了。
Vite 核心团队为了弥合这些差异,写了大量胶水代码。有维护者在 issue 里说过一句很有画面感的话:「我们感觉像是在同时维护两个不同的构建系统。」
1.3 还有一个被低估的问题:生产构建其实一点都不快
这一点非常容易被忽略。Vite 的宣传语一直是「快」,但那个「快」几乎全部来自 dev server 冷启动。生产构建这块,Vite 用的是 Rollup,而 Rollup 是纯 JavaScript 写的单线程打包器。
结果就是:一个中大型项目,vite dev 300ms 启动,vite build 跑 2 分钟。开发爽翻,CI 里排队等构建。项目越大这个反差越荒诞。
有前端工程师(PayFit 团队)公开分享过他们把 Rolldown 集成进一个复杂代码库的测试结果:构建时间从约 120 秒降到约 8 秒,只需要额外加一个 polyfill 插件。这个 15 倍不是实验室数据,是真实业务代码库。
所以 Vite 8 换引擎,解决的不只是「一致性」这个洁癖问题,更是一个实打实的 CI 成本问题。
二、Rolldown 是什么:Rust + Oxc 的双层结构
2.1 定位:不是又一个打包器,是「为 Vite 定制的 Rollup 替代品」
Rolldown 的项目定位写得很清楚:Fast Rust bundler for JavaScript/TypeScript with Rollup-compatible API。三个关键词:
- Rust:原生速度,多线程,无 GC 停顿。
- Rollup-compatible API:这是最关键的战略选择。它没有另起炉灶发明一套插件协议,而是直接兼容 Rollup/Vite 的插件 API。这意味着现有的 Vite 插件生态大部分可以直接跑。
- for Vite:它不是一个通用打包器碰巧被 Vite 用了,它从第一行代码开始就是为了当 Vite 的引擎。
这个「兼容优先」的决策,是 Rolldown 和 Turbopack 最大的路线差异。Turbopack 选择了自己一套体系(Webpack 迁移成本高),Rolldown 选择了寄生在 Rollup 生态里再逐步替换内核。从落地速度看,Rolldown 这条路明显更快。
2.2 底座:Oxc 工具链
Rolldown 自己不写解析器、不写压缩器,它站在 Oxc(The JavaScript Oxidation Compiler)上。Oxc 同样是 Rust 写的,同样在 VoidZero 旗下,提供一整套组件:
| 组件 | 作用 | 对应的 JS 生态老熟人 |
|---|---|---|
oxc_parser | JS/TS/JSX 解析成 AST | Acorn / SWC parser |
oxc_resolver | 模块路径解析(含 exports/imports 字段) | enhanced-resolve |
oxc_transformer | TS 擦除、JSX 转换、语法降级 | Babel / esbuild transform |
oxc_minifier | 压缩混淆 | Terser / esbuild minify |
oxc_linter (Oxlint) | 代码检查 | ESLint |
oxc_formatter | 格式化 | Prettier |
于是整条链路变成了这样:
Vite ← 开发服务器、编排、插件宿主
└─ Rolldown ← 打包、chunk 分割、tree-shaking
└─ Oxc ← parse / resolve / transform / minify
**同一个团队维护、同一套 AST、同一份语义规则。**这就是「统一工具链」的真正含义——不是把三个包塞进一个 monorepo,而是消除三层之间的语义翻译损耗。
举个具体的:以前 esbuild 解析出来的 AST 和 Rollup(Acorn)解析出来的 AST 是两棵完全不同的树,Vite 想在中间做点什么就得反复 parse-print-parse。现在 dev 和 build 共用 Oxc AST,中间环节可以直接传结构而不是传字符串。这块省下来的时间,在大型项目上是数量级的。
2.3 性能定位:和 esbuild 打平,比 Rollup 快 10-30 倍
官方给出的性能定位是:
- 对比 esbuild:性能水平相当(esbuild 是 Go 写的,本来就极快,Rolldown 做到打平已经是硬指标)
- 对比 Rollup:快约 10-30 倍
这个 10-30 倍的区间跨度很大,原因也很好理解:Rollup 慢在两处,单线程和JS 运行时开销。项目模块数越多、CPU 核数越多,Rolldown 的多线程优势就越明显;小项目上差距会收窄到 5-10 倍。
但这里我要泼一盆冷水:这个倍数说的是打包阶段。你的实际 vite build 时间还包含:
- 你自己写的 JS 插件的 transform 时间(这部分一点都没变快)
@vitejs/plugin-vue/plugin-react的 SFC 编译时间- Sass/Less 编译时间
- 类型检查(如果你在 build 里跑了
vue-tsc) - 静态资源处理、图片压缩
我见过的真实情况是:一个 Vue 项目 build 从 90s 降到 25s,很好,但没到 15 倍——因为里面有 40 秒是 vue-tsc 在跑类型检查,Rolldown 一点忙都帮不上。
**结论:Rolldown 让打包不再是瓶颈,但它会把你项目里真正的瓶颈暴露出来。**这反而是好事。
三、架构拆解:Rolldown 内部到底发生了什么
要理解 Rolldown 为什么快,得看它的流水线。一个现代打包器的核心流程大致分四段,我们逐段看 Rolldown 的做法。
3.1 Scan 阶段:并行模块图构建
这是最容易并行化、也是收益最大的一段。
入口文件 → resolve → load → parse → 提取 import/export → 递归
Rollup 的做法:JS 单线程,虽然 resolve/load 是异步的(可以并发 I/O),但 parse 这个 CPU 密集操作只能排队。100 个文件就得串行 parse 100 次。
Rolldown 的做法:用 Rust 的线程池(基于 rayon 这类并行原语),把 parse 任务分发到所有 CPU 核心。8 核机器上,理论上 parse 阶段直接 8 倍。
用伪代码表达这个差异:
// Rolldown 侧(概念示意,非真实源码)
let modules: Vec<Module> = pending_ids
.par_iter() // 并行迭代器
.map(|id| {
let source = fs::read_to_string(id)?;
let ast = oxc_parser::parse(&source, source_type)?;
let deps = extract_imports(&ast);
Module { id, ast, deps }
})
.collect();
// Rollup 侧(概念示意)
for (const id of pendingIds) {
const source = await load(id)
const ast = acorn.parse(source) // 单线程,逐个来
const deps = extractImports(ast)
modules.push({ id, ast, deps })
}
一个额外收益:Rust 侧的 AST 用 arena 分配(oxc_allocator),整棵 AST 在一块连续内存里,parse 完直接整块释放,没有 GC 遍历成千上万个小对象的开销。这在超大项目上是显著差异——V8 的 GC 在处理几百万个 AST 节点时会成为主要瓶颈之一。
3.2 Link 阶段:符号绑定与 tree-shaking
模块图建好之后,要做符号级的链接:谁 export 了什么、谁 import 了什么、哪些 export 没人用(可以 shake 掉)。
这一步的难点在于 ESM 和 CJS 的互操作。举个经典的坑:
// a.cjs
module.exports = { foo: 1 }
// b.mjs
import a from './a.cjs' // a 是 { foo: 1 }?
import { foo } from './a.cjs' // 这个能不能 work?
答案取决于打包器有没有做静态分析识别出这是个「可命名导入的 CJS」。esbuild 和 Rollup 在这里的规则不完全一样,这就是 Vite 7 时代 dev/prod 行为差异最大的重灾区之一。
Rolldown 统一了这套规则,dev 和 build 用同一份互操作逻辑。这是「dev ≠ prod」问题最实质性的修复——不是修了一个 bug,是消除了一整类 bug 的产生条件。
tree-shaking 方面,Rolldown 需要对齐 Rollup 的精细度,这块是它最难啃的骨头。Rollup 的 tree-shaking 做得非常激进:它会做副作用分析、纯函数标注(/*#__PURE__*/)识别、类属性副作用推导等。Rolldown 在这块是逐步追赶的状态,早期版本在某些边缘 case 上产物会比 Rollup 略大。升级后务必对比一下产物体积,这是我后面会给的检查清单里的一项。
3.3 Chunk 阶段:这是 Vite 8 真正解锁新能力的地方
模块分组成 chunk,决定哪些代码打进哪个文件。Rollup 时代你能用的主要是 manualChunks:
// Vite 7 / Rollup 时代
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/react')) return 'react-vendor'
if (id.includes('node_modules/lodash')) return 'lodash'
if (id.includes('node_modules')) return 'vendor'
}
}
}
}
}
这个 API 的问题是:它是命令式的、逐模块判断的,你没法表达「把所有小于 20KB 的 vendor 合并成一个」「这组模块最多分成 5 个 chunk」这类约束。想做精细的分包策略,只能自己写一堆 hash 逻辑。
Rolldown 引入了声明式的 Advanced Chunks:
// vite.config.ts —— Vite 8 / Rolldown
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
advancedChunks: {
// 全局约束:太小的 chunk 会被合并回去
minSize: 20 * 1024,
// 全局约束:太大的 chunk 会被拆开
maxSize: 300 * 1024,
groups: [
{
name: 'framework',
test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/,
priority: 100, // 优先级高,先匹配
},
{
name: 'ui',
test: /node_modules[\\/](antd|@ant-design)[\\/]/,
priority: 90,
minSize: 50 * 1024, // 组级覆盖全局
},
{
// 动态命名:按 npm 包名拆分
name(moduleId) {
const m = moduleId.match(/node_modules[\\/](?:(@[^\\/]+)[\\/])?([^\\/]+)/)
if (!m) return null
return `vendor-${(m[1] ?? '').replace('@', '')}${m[2]}`
},
test: /node_modules/,
priority: 10,
maxModules: 50, // 单 chunk 最多 50 个模块
},
],
},
},
},
},
})
注意:Advanced Chunks 的字段名和能力在 Rolldown 迭代中仍有调整,落地前请以你锁定版本的官方文档为准。上面这段展示的是设计思路:从「你自己算」变成「你声明约束,打包器求解」。
这个改变在实践中的价值:以前做首屏优化,你要手写脚本分析 stats、算 chunk 大小、反复调 manualChunks 里的 if-else。现在你直接声明「framework 单独一包、UI 库单独一包、剩下的按包名拆但每包不小于 20KB」,打包器帮你求解。
3.4 Codegen 阶段:生成 + 压缩
最后一步是把 chunk 里的模块拼接成最终代码,做 scope hoisting(把模块函数打平进同一个作用域,消除运行时开销),然后 minify。
Rolldown 用 oxc_minifier 做压缩。这块的现状值得说清楚:Oxc minifier 的压缩率目前和 Terser 还有差距,但速度快非常多。Vite 8 默认的选择是速度优先,如果你的场景对包体积极度敏感(比如小程序、嵌入式 H5),可以显式切回 Terser:
export default defineConfig({
build: {
minify: 'terser', // 换回 Terser
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.info'],
},
format: { comments: false },
},
},
})
权衡很直白:Oxc minifier 快 10 倍以上但产物可能大 2-5%,Terser 慢但压得狠。CI 时间贵就用前者,CDN 流量贵就用后者。这是个可以用钱算清楚的决策。
四、Full Bundle Mode:Vite 最有争议、也最重要的一次自我否定
这是 Vite 8 里我认为最值得单独拿出来讲的部分。
4.1 no-bundle dev 的隐藏代价
Vite 的立身之本是 no-bundle dev。但这个设计有一个规模上限,很多人没意识到:
每个模块 = 一个 HTTP 请求。
小项目:200 个模块,浏览器并发请求,200ms 搞定,爽。
中项目:2000 个模块,浏览器开始排队,首次加载 3-5 秒。
大项目:10000+ 模块,Chrome DevTools 的 Network 面板直接卡住,首屏 10 秒起步,改一行代码触发级联失效后又要重来一遍。
更麻烦的是深层依赖链。假设 A → B → C → D → E,浏览器必须:请求 A → 解析出要 B → 请求 B → 解析出要 C → ……五个串行往返。Vite 有依赖预扫描和 modulepreload 优化来缓解,但缓解不等于消除。
所以出现了一个尴尬事实:Vite 在小项目上快得离谱,在超大项目上反而可能不如打过包的 dev server。
4.2 Rolldown 带来的第三条路
既然现在打包器快到「打包一次只要几百毫秒」,那为什么不在 dev 时也打包呢?
这就是 Full Bundle Mode:开发时也用 Rolldown 打成少量 bundle 送给浏览器。
// vite.config.ts —— 概念性配置,实际字段以版本文档为准
export default defineConfig({
experimental: {
// 开发态也走完整打包
fullBundleMode: true,
},
})
三种模式的对比:
| 模式 | 冷启动 | 首屏加载 | HMR 延迟 | dev/prod 一致性 | 适用规模 |
|---|---|---|---|---|---|
| no-bundle(Vite 1-7) | 极快(<500ms) | 随模块数线性劣化 | 快(单模块) | 差 | 小到中型 |
| Full Bundle(Vite 8) | 快(1-3s) | 稳定、和规模弱相关 | 需增量重打包 | 好 | 中到超大型 |
| 传统打包(Webpack) | 慢(30s+) | 稳定 | 慢 | 好 | —— |
Full Bundle Mode 的关键在于增量。全量打包再快,改一行代码重打一次 10000 个模块也不可接受。所以它依赖两件事:
- 模块级持久缓存:每个模块的 parse 结果、transform 结果缓存到磁盘,用内容 hash 做 key。改一个文件只重算这一个模块。
- 增量 link + 增量 chunk:只重新链接受影响的子图。
这也解释了为什么「模块级持久缓存」会被列为 Rolldown 解锁的核心能力之一——它不只是加速二次构建,它是 Full Bundle Mode 能成立的前提。
4.3 我的判断
Vite 从「no-bundle 是唯一正确」到「no-bundle 是一个选项」,本质上是承认:当打包器足够快时,no-bundle 的架构优势就消失了,剩下的只有一致性劣势。
这是一次很体面的自我否定。尤雨溪当年靠 no-bundle 掀翻 Webpack,现在又亲手把 no-bundle 降级成可选项——因为工具的目的是解决问题,不是捍卫某个设计。
实践建议:
- 模块数 < 3000 的项目:继续用 no-bundle,冷启动优势明显。
- 模块数 > 5000,或者你已经在忍受首屏 5 秒的 dev server:试 Full Bundle Mode。
- 对 dev/prod 一致性有强要求的项目(比如需要在 dev 里验证分包策略、SSR 行为、Module Federation):直接上 Full Bundle Mode。
五、迁移实战:从 Vite 7 到 Vite 8 的完整路径
5.1 阶段一:不升 Vite,先换引擎(推荐所有人先做这一步)
Vite 团队提供了一个非常聪明的过渡包:rolldown-vite。它是 Vite 的一个 fork,保持 Vite 7 的 API,但内核换成 Rolldown。你可以在不改任何业务代码的情况下先验证兼容性。
# 用 npm/pnpm/yarn 的 overrides 把 vite 替换掉
// package.json —— pnpm
{
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@latest"
}
}
}
// package.json —— npm
{
"overrides": {
"vite": "npm:rolldown-vite@latest"
}
}
// package.json —— yarn (berry)
{
"resolutions": {
"vite": "npm:rolldown-vite@latest"
}
}
然后:
pnpm install
pnpm build
这一步能在 10 分钟内告诉你:你的插件生态能不能跑、产物有没有问题、快了多少。跑通了再考虑升 Vite 8,跑不通就回滚,成本极低。
5.2 阶段二:正式升级到 Vite 8
pnpm add -D vite@^8
# 相关生态一起升
pnpm add -D @vitejs/plugin-vue@latest # 或 plugin-react
pnpm add -D vitest@latest
一份相对完整的 Vite 8 配置参考:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig(({ mode }) => ({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
// 依赖预构建:Vite 8 下也由 Rolldown 接管,和 build 同一套语义
optimizeDeps: {
include: ['lodash-es', 'dayjs'],
exclude: ['@my-org/local-pkg'],
},
build: {
target: 'es2020',
sourcemap: mode !== 'production',
// 速度优先用默认(oxc),体积优先切 terser
minify: 'oxc',
chunkSizeWarningLimit: 600,
rollupOptions: {
output: {
advancedChunks: {
minSize: 20 * 1024,
groups: [
{ name: 'vue-vendor', test: /node_modules[\\/](vue|vue-router|pinia)[\\/]/, priority: 100 },
{ name: 'vendor', test: /node_modules/, priority: 10 },
],
},
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash][extname]',
},
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
},
},
},
}))
5.3 迁移中最容易踩的六个坑
坑 1:依赖了 Rollup 内部 API 的插件会挂。
有些插件为了实现高级功能,直接引用了 rollup 包的内部类型或方法,比如访问 this.parse() 返回的 ESTree AST 结构、或者读 bundle 对象的私有字段。Rolldown 兼容的是公开 API,不保证内部结构一致。
排查方法:
# 看看哪些依赖直接依赖了 rollup
pnpm why rollup
# 或者搜插件源码
grep -rn "from 'rollup'" node_modules/your-plugin/dist
遇到这类插件,优先找它有没有 Rolldown 兼容版本,没有就得找替代或者自己 patch。
坑 2:CommonJS 互操作行为变了。
前面提到 Rolldown 统一了 CJS/ESM 互操作规则。「统一」意味着至少有一边要改变。如果你的代码之前依赖了 esbuild 那套宽松规则,升级后可能报错。典型症状:
// 之前能跑,现在报 "does not provide an export named 'foo'"
import { foo } from 'some-cjs-package'
// 改成
import pkg from 'some-cjs-package'
const { foo } = pkg
坑 3:产物体积变大了。
tree-shaking 精细度和 minifier 压缩率的双重影响。必须量化验证:
# 升级前
git stash && pnpm build && du -sh dist && mv dist dist-before
# 升级后
git stash pop && pnpm build && du -sh dist
# 逐文件对比
ls -la dist-before/assets/*.js | sort -k5 -n > /tmp/before.txt
ls -la dist/assets/*.js | sort -k5 -n > /tmp/after.txt
diff /tmp/before.txt /tmp/after.txt
体积涨了 3% 以内我认为可以接受(换来 10 倍构建速度);涨了 15% 以上就要查原因,通常是某个大依赖没被 shake 干净。
坑 4:define 替换和 import.meta.env 的边缘 case。
define 的实现从「字符串替换」变成了 AST 级替换,行为更正确了,但如果你之前利用了字符串替换的不精确性(比如在注释里、在字符串字面量里做替换),现在会失效。
坑 5:sourcemap 精度。
新 codegen 的 sourcemap 生成路径不同,个别情况下调试断点位置会有偏移。生产环境上 Sentry 之类的错误监控前,务必抽查几个报错的栈还原是否正确。
坑 6:Legacy 浏览器支持。
@vitejs/plugin-legacy 依赖 Babel 和特定的 Rollup 行为。如果你还要支持 IE11 或老 Android WebView,这块要重点测。前面提到的 PayFit 案例说「只需加一个 polyfill 插件」,就是这类问题。
5.4 迁移检查清单
把这个贴到你的迁移 PR 描述里:
[ ] rolldown-vite overrides 方式先跑通 build
[ ] 全量单元测试 / E2E 通过
[ ] 产物体积对比(总体积、单文件 top 10),涨幅 < 5%
[ ] 首屏 LCP / FCP 线上灰度对比无劣化
[ ] sourcemap 抽查 3 个报错栈还原正确
[ ] 目标浏览器矩阵实机验证(含最低支持版本)
[ ] CI 构建时间记录(升级前 / 后)
[ ] 所有自定义插件在 dev 和 build 两侧行为一致
[ ] SSR 场景(如有)单独验证
[ ] 依赖预构建产物(node_modules/.vite)清空后重新验证
六、插件开发:JS 插件的性能陷阱与「原生插件」的未来
6.1 一个反直觉的现实:你的 JS 插件会拖慢 Rust 打包器
这是升级后最容易出现的困惑:「说好的快 20 倍,我这才快了 2 倍?」
原因在于跨语言边界的开销。Rolldown 是 Rust,你的插件是 JS,中间隔着 Node.js 的 N-API。每一次 transform 钩子调用,都要:
Rust 侧 → 序列化 code 字符串 → 跨 N-API → V8 分配 JS 字符串
→ 执行你的 JS transform
→ 返回字符串 → 跨 N-API → Rust 侧反序列化
一次两次没关系,5000 个模块 × 3 个插件 = 15000 次跨界调用,开销就上来了。而且更要命的是:JS 插件是单线程的,Rolldown 的并行扫描到你这里会被强行串行化(或者说,被 Node 主线程的事件循环节流)。
6.2 写插件的四条性能准则
准则一:transform 一定要加精确的 filter。
// ❌ 差:每个模块都会进 JS 侧走一遍
export default function badPlugin() {
return {
name: 'bad',
transform(code, id) {
if (!id.endsWith('.svg')) return null // 判断发生在 JS 侧,跨界已经付过费了
return svgToComponent(code)
},
}
}
// ✅ 好:filter 下沉到 Rust 侧,不匹配的模块根本不会跨界
export default function goodPlugin() {
return {
name: 'good',
transform: {
filter: {
id: { include: /\.svg$/ }, // Rust 侧预过滤
},
handler(code, id) {
return svgToComponent(code)
},
},
}
}
这个 filter 下沉机制是 Rolldown 时代插件性能的第一优化项。一个 5000 模块的项目,如果你的 SVG 插件只处理 50 个文件,加了 filter 就是 100 倍的跨界调用削减。
准则二:能用 code 字符串判断的,别急着 parse。
transform: {
filter: { id: { include: /\.[jt]sx?$/ } },
handler(code, id) {
// 先用便宜的字符串检查兜底,避免无谓的 AST 解析
if (!code.includes('__MY_MACRO__')) return null
const ast = this.parse(code) // 只有真需要时才 parse
// ...
},
},
准则三:resolveId 钩子尤其要克制。
resolveId 的调用频率远高于 transform(每个 import 语句一次,不是每个模块一次)。一个 5000 模块的项目可能有 30000 次 import。在这里做同步文件系统操作(fs.existsSync)是灾难级的。
// ❌ 每次 resolve 都摸一次磁盘
resolveId(source, importer) {
const p = path.resolve(path.dirname(importer), source + '.custom')
if (fs.existsSync(p)) return p
}
// ✅ 加 filter + 内存缓存
const cache = new Map()
resolveId: {
filter: { id: { include: /^~custom\// } },
handler(source, importer) {
const key = `${importer}::${source}`
if (cache.has(key)) return cache.get(key)
const resolved = doResolve(source, importer)
cache.set(key, resolved)
return resolved
},
},
准则四:CPU 密集的转换,考虑走 worker。
如果你的插件要做重活(比如图片处理、复杂的代码生成),别在主线程干:
import { Worker } from 'node:worker_threads'
import os from 'node:os'
// 简易 worker 池
class WorkerPool {
constructor(script, size = os.cpus().length) {
this.workers = Array.from({ length: size }, () => new Worker(script))
this.idx = 0
}
run(payload) {
const w = this.workers[this.idx++ % this.workers.length]
return new Promise((resolve, reject) => {
const onMsg = (m) => { cleanup(); resolve(m) }
const onErr = (e) => { cleanup(); reject(e) }
const cleanup = () => { w.off('message', onMsg); w.off('error', onErr) }
w.on('message', onMsg)
w.on('error', onErr)
w.postMessage(payload)
})
}
}
const pool = new WorkerPool(new URL('./transform-worker.js', import.meta.url))
export default function heavyPlugin() {
return {
name: 'heavy',
transform: {
filter: { id: { include: /\.heavy$/ } },
async handler(code, id) {
return await pool.run({ code, id })
},
},
}
}
6.3 原生插件:下一步棋
Rolldown 支持用 Rust 写「原生插件」,直接编译进打包器,零跨界开销。目前这块生态还早,但方向很明确:高频、通用的插件会逐步 Rust 化(比如 plugin-vue 的 SFC 解析、plugin-react 的 Fast Refresh 注入),业务侧的定制插件继续用 JS。
对普通开发者的含义:你不需要学 Rust,但你要知道哪些插件已经原生化了,优先选它们。这会成为未来选插件的一个新维度,就像今天我们看「有没有 TypeScript 类型」一样。
七、性能测量方法论:别被 15 倍忽悠,也别被 2 倍劝退
网上各种「提速 30 倍」的截图,大部分测得不严谨。给一套我自己用的方法。
7.1 一个可用的基准测试脚本
// scripts/bench-build.mjs
import { execSync } from 'node:child_process'
import { rmSync, existsSync, statSync, readdirSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import path from 'node:path'
const ROUNDS = Number(process.env.ROUNDS ?? 5)
const WARMUP = 1
function cleanAll() {
for (const p of ['dist', 'node_modules/.vite', 'node_modules/.cache']) {
if (existsSync(p)) rmSync(p, { recursive: true, force: true })
}
}
function dirSize(dir) {
let total = 0
for (const f of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, f.name)
total += f.isDirectory() ? dirSize(full) : statSync(full).size
}
return total
}
function runOnce({ cold }) {
if (cold) cleanAll()
else rmSync('dist', { recursive: true, force: true })
const t0 = performance.now()
execSync('npx vite build', { stdio: 'ignore' })
return performance.now() - t0
}
function stats(arr) {
const s = [...arr].sort((a, b) => a - b)
const sum = s.reduce((a, b) => a + b, 0)
return {
min: s[0],
max: s[s.length - 1],
mean: sum / s.length,
median: s[Math.floor(s.length / 2)],
p90: s[Math.floor(s.length * 0.9)],
}
}
const cold = []
const warm = []
for (let i = 0; i < ROUNDS + WARMUP; i++) {
const c = runOnce({ cold: true })
const w = runOnce({ cold: false })
if (i >= WARMUP) { cold.push(c); warm.push(w) }
}
const fmt = (o) => Object.fromEntries(
Object.entries(o).map(([k, v]) => [k, `${(v / 1000).toFixed(2)}s`])
)
console.table({
'冷构建(清缓存)': fmt(stats(cold)),
'热构建(有缓存)': fmt(stats(warm)),
})
console.log('产物体积:', (dirSize('dist') / 1024 / 1024).toFixed(2), 'MB')
跑法:
# Vite 7 基线
ROUNDS=5 node scripts/bench-build.mjs > /tmp/vite7.txt
# 切到 Vite 8 / rolldown-vite
ROUNDS=5 node scripts/bench-build.mjs > /tmp/vite8.txt
diff /tmp/vite7.txt /tmp/vite8.txt
7.2 测量的六条纪律
- 区分冷热构建。有持久缓存的热构建才是你日常 CI 的真实场景(如果 CI 配了缓存的话)。
- 至少 5 轮取中位数。单次结果受系统噪声影响可能差 30%。
- 关掉杀毒软件和实时索引。Windows Defender 扫
node_modules能让构建慢一倍,这是真的。 - CPU 频率锁定。笔记本插不插电源,构建时间能差 40%。测之前插电、关省电模式。
- 拆分阶段耗时。总时间没意义,要知道钱花在哪:
DEBUG=vite:* npx vite build 2>&1 | tee /tmp/build.log
# 或者用 Vite 的构建分析插件看各插件耗时
- 单独测类型检查。如果你的 build 脚本是
vue-tsc --noEmit && vite build,先把两部分拆开计时,否则你会得出「Rolldown 没啥用」的错误结论。
7.3 真实项目的期望值
基于公开案例和我自己的观察,给一个粗略的期望区间(仅供参考,你的项目一定不一样):
| 项目规模 | Vite 7 build | Vite 8 build(估计) | 加速比 |
|---|---|---|---|
| 小型(<500 模块) | 5-10s | 2-4s | 2-3x |
| 中型(1000-3000 模块) | 30-60s | 6-15s | 4-6x |
| 大型(5000-15000 模块) | 90-180s | 10-25s | 8-15x |
| 超大(>20000 模块,monorepo) | 5-15min | 30-90s | 10-20x |
规律很清楚:项目越大,收益越大。因为 Rollup 的单线程劣势是随规模放大的。小项目升级主要图的是一致性,不是速度。
八、生态与权力结构:Cloudflare 收购 VoidZero 意味着什么
技术之外还有一层,很多人没注意。
8.1 VoidZero 是什么
尤雨溪在 2023 年创办了 VoidZero,目标是「为 JavaScript 生态构建统一的、高性能的工具链」。旗下项目:
- Vite:构建工具 / 开发服务器
- Vitest:测试框架
- Rolldown:打包器(Rust)
- Oxc:底层编译器工具链(Rust)——包含 Oxlint、oxc-parser 等
这套组合的野心非常明确:把前端工具链从「一堆各自为政的 npm 包」整合成「一个厂商维护的完整栈」。类比一下,就是 JS 生态版的 Rust cargo 或 Go 官方工具链。
8.2 2026 年 6 月:Cloudflare 收购 VoidZero
这件事的信号量很大。
对 Cloudflare 而言:它有 Workers(边缘计算)、Pages(静态托管)、R2、D1,缺的是开发侧的入口。收了 VoidZero,等于拿到了几乎所有现代前端项目的构建入口。你 npm create vite@latest 的那一刻,就进入了 Cloudflare 的漏斗。
对 Vite 生态而言,这是把双刃剑:
好的一面:
- 钱有了。开源基础设施最大的风险是维护者用爱发电然后 burnout。有大厂发工资,Rolldown/Oxc 这种需要长期投入的 Rust 项目才有可能做完。
- 边缘部署会被打通。可以预见 Vite 8+ 会有非常丝滑的 Cloudflare Workers 适配,SSR/边缘渲染的开箱体验会显著变好。
需要警惕的一面:
- 中立性问题。当 Vite 的默认 SSR 适配器、默认部署目标都倾向某一家云时,「开源工具」和「厂商漏斗」的边界会变模糊。
- 优先级问题。对 Cloudflare 有商业价值的特性(边缘运行时适配)和对社区有价值的特性(比如 Legacy 浏览器支持、非 Cloudflare 平台的适配)之间,资源怎么分?
我的看法比较务实:**短期是好事,长期要观察。**开源基础设施被大厂收编不是第一次(想想 npm→GitHub→Microsoft),关键看治理结构有没有保住。Vite 有一个相对独立的核心团队和 RFC 流程,这是缓冲垫。真正的信号要看未来 12-24 个月:有没有 Cloudflare 专属的能力进了核心而不是插件、有没有非 Cloudflare 的适配器被边缘化。
8.3 打包器战争的当前局势
顺便理一下 2026 年这个赛道的格局:
| 项目 | 语言 | 背后 | 定位 | 状态 |
|---|---|---|---|---|
| Rolldown | Rust | VoidZero / Cloudflare | Vite 的引擎,Rollup 兼容 | 上升期,随 Vite 8 全面铺开 |
| Turbopack | Rust | Vercel | Next.js 的引擎 | 深度绑定 Next |
| esbuild | Go | 个人 (Evan Wallace) | 极速转译 | 稳定,逐步退居工具层 |
| Rspack | Rust | 字节跳动 | Webpack 兼容替代 | Webpack 存量迁移的主力 |
| Bun (bundler) | Zig→Rust | Oven | 运行时自带打包 | 一体化路线 |
| Rollup | JS | 社区 | 库打包 | 库作者仍在用 |
| Webpack | JS | 社区 | 老项目 | 存量维护 |
有意思的是分层清晰化了:
- 应用打包:Rolldown(Vite 系)vs Turbopack(Next 系)vs Rspack(Webpack 存量)
- 库打包:Rollup 还在守着,但 Rolldown 明确要抢这块
- 转译层:Oxc vs SWC,两个 Rust 方案在竞争
对普通开发者的选型建议很简单:
- Next.js 项目:跟 Vercel 走,用 Turbopack,没得选也不用选。
- Vue/Svelte/Solid/纯 React SPA:Vite 8 + Rolldown,这是主路。
- Webpack 老项目要提速又不想重写配置:Rspack,兼容性是它最大的卖点。
- 写 npm 库:Rollup 依然稳,或者 tsdown(Rolldown 上层的库打包工具)。
九、什么时候不该升级 Vite 8
写了这么多好话,得给一个冷静的边界。以下情况建议再等等:
1. 项目重度依赖 Rollup 内部 API 的插件,且这些插件没人维护了。
你会陷入自己 patch 别人代码的泥潭。评估一下:这些插件占你构建流程多大比重?能不能替换?替换成本 vs 构建提速收益,算清楚。
2. 你的构建瓶颈根本不是打包。
前面说过的:如果 build 时间里 70% 是 vue-tsc/tsc 在跑,或者是图片压缩,升级 Rolldown 只能砍掉那 30% 里的大部分。这时候更值得做的是:把类型检查从 build 流程里拆出去并行跑(tsc --noEmit 和 vite build 同时跑),收益比升级大得多。
{
"scripts": {
"build": "run-p type-check build-only",
"type-check": "vue-tsc --noEmit",
"build-only": "vite build"
}
}
3. 产物体积对你是硬约束。
小程序分包限制、嵌入式设备、按流量计费的场景。至少要先用 rolldown-vite 验证产物体积,涨了就别升,或者升了之后 minify: 'terser' 兜底。
4. 你的项目在关键交付期。
这条是常识但总有人忘:构建工具是所有环节的地基,换地基不要在赶工期时做。等一个迭代间隙,留出完整的验证时间。
5. Legacy 浏览器矩阵很重的项目。@vitejs/plugin-legacy 这条链路涉及 Babel、core-js、SystemJS,是兼容性问题的重灾区。要升,先在测试机上把最低支持的浏览器实机跑一遍。
十、总结:这次换的到底是什么
回到开头那个白屏的深夜。
Vite 8 最大的价值,不是「构建快了 10 倍」——虽然这个也很爽,CI 账单能省不少。最大的价值是消除了一整类问题的存在条件。
以前你调试「dev 能跑 build 挂了」,本质上是在两个不同的构建系统之间做考古。现在这个问题类别从根上少了大半——不是因为 bug 被修了,是因为产生 bug 的那个结构性裂缝被焊上了。
从工程哲学上看,这次升级有三层意义:
**第一层,性能。**Rust + 多线程 + arena 内存管理,把打包这个 CPU 密集任务从 JS 单线程的枷锁里放出来。10-30 倍不是营销数字,是架构差异的必然结果。
**第二层,一致性。**Vite → Rolldown → Oxc 三层同一团队维护、共享同一套 AST 和语义规则。这是从「集成多个工具」到「设计一个系统」的转变。
第三层,能力天花板。Full Bundle Mode、Advanced Chunks、模块级持久缓存、Module Federation——这些不是「顺便做的新功能」,是只有在打包器足够快、且 dev/build 共享内核之后才可能存在的能力。以前不做不是不想做,是架构上做不了。
最后说一句关于 no-bundle 的。Vite 靠 no-bundle 起家,现在把它降级成一个可选模式。这种自我否定在开源项目里其实很罕见——大多数项目会死守自己的招牌设计,哪怕它已经不再是最优解。
工具的目的是解决问题。当解决问题的最优路径变了,工具就应该变。这个道理讲起来简单,做起来要有人愿意拆掉自己盖的房子。
附:快速上手清单
# 1. 零成本验证(推荐所有人先做)
# package.json 加 overrides,把 vite 换成 rolldown-vite
pnpm install && pnpm build
# 记录:耗时、产物体积、是否有报错
# 2. 正式升级
pnpm add -D vite@^8 @vitejs/plugin-vue@latest vitest@latest
# 3. 跑基准
ROUNDS=5 node scripts/bench-build.mjs
# 4. 对比产物
du -sh dist && ls -la dist/assets/*.js | sort -k5 -rn | head -20
# 5. 全量测试
pnpm test && pnpm test:e2e
# 6. 目标浏览器实机验证
# 7. 灰度发布,盯 LCP / JS 错误率 48 小时
关键配置速查:
| 需求 | 配置 |
|---|---|
| 构建速度优先 | build.minify: 'oxc'(默认) |
| 产物体积优先 | build.minify: 'terser' |
| 大项目 dev 提速 | experimental.fullBundleMode: true |
| 精细分包 | build.rollupOptions.output.advancedChunks |
| 插件性能 | 所有钩子加 filter,让过滤下沉到 Rust 侧 |
| 排查耗时 | DEBUG=vite:* vite build |
文中涉及的具体 API 字段(尤其是
advancedChunks、fullBundleMode等实验性配置)在 Rolldown / Vite 8 的迭代中仍可能调整,落地前请以你锁定版本的官方文档为准。性能数据均为公开案例与区间估算,不同项目差异很大,务必自己跑基准。