前端构建工具这条赛道,过去十年一直在做同一件事:把「模块图」这个数据结构算得更快、切得更准。从 Browserify 到 webpack,从 Rollup 到 esbuild,再到 Vite 用 native ESM 把开发态的打包成本干到接近零——每一次跃迁背后都是同一个问题的不同解法。
而 Vite 8 这次不一样。它没有再发明一种新的开发态取巧,而是把 Vite 用了七个大版本的「双打包器」架构直接掀了:dev 用 esbuild、build 用 Rollup 的组合被彻底废弃,统一换成 Rust 写的 Rolldown。配上同一团队(VoidZero)的 Oxc 编译器基座,Vite 从「编排层」变成了一条端到端自研的工具链入口。
这篇文章不打算复述发布日志。我想讲清楚三件工程师真正关心的事:
- 双打包器到底欠了多少技术债,为什么必须用一次「换心脏」级手术来还;
- Rolldown + Oxc 的内部架构长什么样,Rust 的并行和零拷贝优势具体落在哪个阶段,JS 插件跨语言边界的成本又藏在哪;
- 迁移和调优的真实手感:配置怎么映射、分包怎么写、构建时间怎么量、坑在哪。
中间我会手写一个 150 行的迷你 bundler,把 scan / link / chunk / generate 四个阶段跑通——理解了这四步,你再看 Rolldown 的性能数字就不再是「快了 30 倍」这种玄学,而是能指着具体阶段说出「这里省掉了序列化、那里吃满了多核」。
一、背景:Vite 的「双系统」为什么成了包袱
1.1 当年那个天才设计
Vite 1.x~7.x 的经典架构,用一句话概括就是:
开发态:Vite Dev Server + esbuild(依赖预构建 / 单文件 transform)
生产态:Vite Build + Rollup (打包 / 分块 / tree-shaking / 压缩)
这个组合在 2020 年是绝对正确的选择:
- 开发态利用浏览器原生 ESM,源码 no-bundle,只做单文件转换。esbuild(Go 写的)转换速度比 Babel 快一个数量级,冷启动秒级,HMR 只需要重新编译一个文件;
- 生产态不能 no-bundle——上千个 ESM 请求的网络瀑布流会让首屏惨不忍睹,所以必须打包。而 Rollup 的 ESM 语义、tree-shaking 质量、插件生态在当时是最成熟的。
用两个专精工具各打一半场,Vite 团队因此可以把精力放在开发体验和编排上,而不是从零造一个解析器和打包器。这个判断在当时非常聪明。
1.2 债务是怎么滚起来的
问题是,两条流水线意味着两套语义,而语义不一致的 bug 是最难查的一类 bug。典型症状:
(1)转换管道分裂。 同一个 .ts 文件,dev 时走 esbuild transform,build 时走 Rollup 插件链上的 esbuild 或其他 transformer。装饰器、enum、emitDecoratorMetadata、useDefineForClassFields 这些语义在两条路径上曾长期存在差异。
(2)插件系统割裂。 你想写一个「dev 和 build 行为完全一致」的插件,理论上 Vite 插件(Rollup 插件超集)能覆盖大部分场景,但一旦触及依赖预构建阶段,就得再写一份 esbuild 插件:
// Vite 7 时代典型的「一个功能写两遍」
export default function myPlugin() {
return {
name: 'my-plugin',
// 生产构建 / dev transform 走这条(Rollup 钩子)
transform(code, id) {
if (!id.endsWith('.custom')) return
return { code: compile(code), map: null }
},
// 依赖预构建走这条(esbuild 插件,API 完全不同)
config() {
return {
optimizeDeps: {
esbuildOptions: {
plugins: [
{
name: 'my-plugin-esbuild',
setup(build) {
build.onLoad({ filter: /\.custom$/ }, async (args) => ({
contents: compile(await fs.readFile(args.path, 'utf8')),
loader: 'js',
}))
},
},
],
},
},
}
},
}
}
同一份编译逻辑,两套 API、两套 filter 语义、两处需要同步维护。业务插件多了以后,这就是持续性出血。
(3)胶水代码爆炸。 Vite 核心里有相当一部分代码做的是「让 esbuild 的行为看起来像 Rollup」:路径解析规则对齐、define 替换语义对齐、sourcemap 链拼接、CJS/ESM 互操作对齐。这些代码不产生用户价值,只用来填坑。
(4)功能天花板。 想在开发服务器上做模块联邦(Module Federation)?Rollup 生态有方案,esbuild 侧没有;想做模块级持久缓存?两套流水线各缓存一份,缓存键还不一样。很多能力不是不想做,是架构上做不了。
(5)最经典的「dev 能跑,build 炸」。 tree-shaking 差异、import.meta 处理差异、CJS 具名导出识别差异——这类问题的排查成本极高,因为你无法在 dev 环境复现。
1.3 Rolldown:给 Vite 定制的 Rust 打包器
VoidZero 团队的解法是造一个同时满足两侧需求的打包器,设计目标非常清晰:
| 维度 | Rollup (JS) | esbuild (Go) | Rolldown (Rust) |
|---|---|---|---|
| 打包速度 | 基准线 | ≈10x Rollup | ≈esbuild,10–30x Rollup |
| 插件 API | Rollup 插件生态 | 自有、封闭 | 兼容 Rollup / Vite 插件 |
| 产物控制 | 强(renderChunk 等) | 弱(不暴露产物钩子) | 强 |
| 分包策略 | manualChunks | 不支持自定义 | advancedChunks(更细) |
| 编译器基座 | Acorn | 自研 | Oxc(语义分析可复用) |
关键在第三、四、五行:性能对标 esbuild,但插件能力和产物控制对标 Rollup。这就是「能替换掉两个」的前提。
社区实测的收益(早期采用者通过独立的 rolldown-vite 技术预览包接入):
- Linear:生产构建 46s → 6s
- Beehiiv:构建时间 -64%
- Ramp:-57%
- Mercedes-Benz.io:-38%
- PayFit(含 Astro + Storybook + Vitest + 多个 Vite 应用的复杂仓库):120s → 8s,只需加一个 polyfill 插件
时间线上,Rolldown 1.0 RC 在 2026 年 1 月放出,随后 1.0 正式发布并作为 Vite 8 的统一底层引擎,到 7 月已经迭代到 1.2。Vite 8 从 8.0.0-beta.0 起提供两条升级路径(直升 / 先切 rolldown-vite 再升),并保持配置 API 与插件钩子尽量不变。
二、核心概念:打包器的四个阶段,和 Oxc 提供的地基
要判断「快在哪」,先得有一张阶段图。任何现代 bundler,无论 Rollup、esbuild、Rspack 还是 Rolldown,都能拆成四步:
① Scan (模块图构建) : resolve → load → parse → 提取 import/export → 递归
② Link (符号绑定) : 建立 binding、判定 side effects、执行 tree-shaking 标记
③ Chunk (产物切分) : 决定哪些模块进同一个 chunk,处理 code splitting / 去重
④ Generate (代码生成) : 重命名(deconflict)→ 拼接 → sourcemap → minify → 落盘
各阶段的性能特征完全不同,这点非常关键:
- ① Scan 是 IO + CPU 混合、可高度并行:每个模块的读文件和 parse 彼此独立,天然适合任务池。这是 Rust/Go 相对 Node 单线程的最大优势区间;
- ② Link 强串行、图算法密集:符号解析、循环依赖处理、side-effect 传播都要看全局,难并行,靠数据结构和内存布局取胜(这里 Rust 的紧凑内存和 arena 分配很吃香);
- ③ Chunk 中等规模图计算:模块到 chunk 的分组、公共依赖提升;
- ④ Generate 又可以并行:不同 chunk 的 codegen 和 minify 互不干扰,多核直接线性加速。
再看 JS bundler 的隐性成本:Node 单线程 + AST 是堆上散落的 JS 对象。Rollup 处理一万个模块时,V8 要创建数百万个 AST 节点对象,GC 压力巨大;而且插件链每次 transform 都是「字符串进、字符串出」,反复 parse/serialize 同一份代码。
2.1 Oxc:被复用的那一层
Rolldown 不自己写解析器,它站在 Oxc 上。Oxc 是一套 Rust 实现的 JS/TS 工具链组件集合,至少包含:
- oxc_parser:JS/TS/JSX 解析器,AST 分配在 arena(bump allocator)里,节点是紧凑的连续内存,遍历极快、几乎无 GC;
- oxc_resolver:Node/TS 解析算法实现(exports/imports 字段、tsconfig paths、条件导出);
- oxc_semantic:作用域与符号表、引用关系分析——这是提升 tree-shaking 质量的关键;
- oxc_transformer:TS/JSX 降级、装饰器、target 语法降级;
- oxc_minifier:压缩器(对标 terser/esbuild minify);
- oxc_linter:即 oxlint。
对 Vite 的意义在于:parse 一次,AST 在 Rust 侧被 transform、semantic、minify、codegen 全阶段复用。对比一下老架构的实际数据流:
Vite 7 (build):
源码 →(esbuild parse+transform)→ 字符串 →(Rollup/acorn parse)→ JS AST
→(插件 magic-string 字符串操作)→ 字符串 →(terser/esbuild parse)→ minify → 字符串
Vite 8 (build):
源码 →(oxc parse)→ Rust AST ──── transform / semantic / tree-shake / codegen / minify ────→ 产物
(中途不落回字符串,不重复 parse)
重复 parse 和字符串序列化是老架构最大的隐性税。它不体现在任何单个工具的 benchmark 里,只体现在你的 CI 时长上。
2.2 一个容易被忽略的点:更好的 tree-shaking 来自语义分析
很多人以为 tree-shaking 就是「没被 import 的导出删掉」。真实难点在副作用判定:
// utils.js
export const a = compute() // compute() 有没有副作用?不知道 → 不敢删
export const b = /*#__PURE__*/ f() // 有 PURE 注解 → 可删
export class C { static x = init() } // 静态初始化算副作用吗?
Rollup 靠自己的一套启发式 + sideEffects 字段 + PURE 注解。而 Rolldown 可以直接复用 Oxc 的语义分析结果:谁引用了谁、哪个绑定是常量、哪个函数是纯的、类字段初始化会不会逃逸。信息更全,就敢删更多。这是「同一团队维护整条链」带来的架构红利,不是简单换语言能得到的。
三、架构分析:Rolldown 内部是怎么组织的
Rolldown 仓库按 crate 分层,大致是这样一张图(以公开的 crates 结构为参照):
crates/
├── rolldown/ # 核心打包引擎
│ ├── module_loader/ # ① Scan:ModuleTask、依赖发现、并发调度
│ ├── module_finalizers/ # ② Link 后的模块级改写(import 重写、命名空间)
│ ├── chunk_graph/ # ③ Chunk:chunk 划分与依赖
│ └── stages/ # scan / link / generate 阶段编排
├── rolldown_plugin/ # 插件系统与钩子驱动
├── rolldown_resolver/ # 基于 oxc_resolver 的解析封装
├── rolldown_binding/ # NAPI-RS:与 Node.js 的桥
└── rolldown_utils/ # 共享数据结构 / 工具
3.1 Scan 阶段:任务池 + 双运行时
module_loader 的核心是一个 ModuleTask:一个模块 = 一个任务。任务里做四件事:resolve 依赖、load 内容、parse、把发现的新依赖再丢回队列。
Rust 侧一般是双层并发模型:
- 异步 IO 用 tokio:读文件、调用 JS 插件(跨 NAPI 到 Node 线程)本质是等待,用 async 任务不阻塞线程;
- CPU 密集用 rayon / 线程池:parse、transform、minify 是纯计算,直接吃满核心数。
用伪代码表达这个调度骨架(不是 Rolldown 源码,是把它的思路抽出来):
// 模块图并行构建的核心骨架
async fn scan(entries: Vec<PathBuf>, ctx: Arc<Ctx>) -> ModuleTable {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut visited: FxHashSet<ModuleId> = FxHashSet::default();
let mut pending = 0usize;
for e in entries {
let id = ctx.resolver.resolve_entry(&e)?;
if visited.insert(id) {
pending += 1;
spawn_module_task(id, tx.clone(), ctx.clone());
}
}
let mut table = ModuleTable::default();
while pending > 0 {
let msg = rx.recv().await.expect("channel closed");
pending -= 1;
match msg {
Msg::Loaded { module, deps } => {
for dep in deps {
// resolve 可能命中插件(跨语言),所以在任务内异步做
if visited.insert(dep) {
pending += 1;
spawn_module_task(dep, tx.clone(), ctx.clone());
}
}
table.insert(module);
}
Msg::Err(e) => return Err(e),
}
}
Ok(table)
}
fn spawn_module_task(id: ModuleId, tx: Sender<Msg>, ctx: Arc<Ctx>) {
tokio::spawn(async move {
let source = ctx.load(id).await?; // IO / JS 插件 load 钩子
let source = ctx.run_transform_hooks(id, source).await?; // 可能跨到 Node
// CPU 密集:丢到 rayon 池,别占着 tokio worker
let parsed = rayon_block(move || oxc_parse_and_scan(&source)).await?;
tx.send(Msg::Loaded { module: parsed.module, deps: parsed.deps })
});
}
有两个设计细节值得学:
visited去重在调度侧做,不在任务内做。避免同一模块被并发 parse 多次(大型 monorepo 里 barrel file 会被上百个模块引用);- CPU 任务必须离开 async 运行时。如果在 tokio worker 上跑 parse,会把执行器饿死——这是所有 Rust 异步项目的经典坑,Node 那边的对应版本是「别在事件循环里跑同步大计算」。
Node 单线程的 Rollup 在这个阶段只能一个一个 parse。这就是「10–30 倍」里最大的一块来源:并行度。
3.2 Link 阶段:符号绑定与 side effect 传播
Link 要解决的问题:
// a.js
export { x as y } from './b.js'
// b.js
export const x = 1
// main.js
import { y } from './a.js'
console.log(y)
打包后 y 到底指向谁?跨了两层 re-export,还可能有 export * 的冲突、循环依赖、命名空间对象(import * as ns)的按需属性访问。Rolldown 这里做的事和 Rollup 概念一致:把每个模块的 import/export 解析成符号(Symbol)之间的引用边,再跑一次 union-find 之类的结构把「同一个东西的多个名字」合并成一个规范符号。
然后是 tree-shaking:从入口的「被使用符号」出发做可达性遍历,同时把带副作用的语句(顶层函数调用、赋值到外部对象、import 纯副作用模块)标为必须保留,向上传播。
这个阶段串行为主,Rust 的优势从「并行」切换为「常数因子」:FxHashMap 代替 V8 隐藏类对象、索引化的 SymbolRef(u32, u32) 代替对象引用、arena 分配避免 GC 抖动。经验上,这类图算法 Rust 相对优化过的 JS 有 3–10 倍的常数优势,而且内存占用往往只有 1/3 到 1/5——大仓构建 OOM 的老问题因此明显缓解。
3.3 Chunk 阶段:advancedChunks 比 manualChunks 强在哪
Rollup 时代分包基本靠 manualChunks:
// Vite 7 常见写法:函数式 manualChunks,很容易写出「循环依赖 + 重复打包」
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/react')) return 'react-vendor'
if (id.includes('node_modules')) return 'vendor'
},
},
},
},
}
这种写法的问题:函数每个模块调一次(大仓里几万次 JS 调用,跨语言更贵)、没有 size 约束、没有优先级、容易把异步 chunk 的共享模块错误提升。
Rolldown 提供的 advancedChunks 是声明式 + 有约束的分组(在 Vite 里通过 build.rollupOptions.output 传入;具体字段以对应版本文档为准):
// vite.config.ts —— 声明式分包,带优先级与体积约束
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
advancedChunks: {
groups: [
{
name: 'react-core',
test: /node_modules\/(react|react-dom|scheduler)\//,
priority: 100, // 优先级高,先被匹配
},
{
name: 'charts',
test: /node_modules\/(echarts|zrender)\//,
priority: 90,
minSize: 50_000, // 太小就不单独成 chunk,避免请求碎片化
},
{
// 其余三方依赖按体积自动切分,避免单个 vendor 巨石
name: 'vendor',
test: /node_modules\//,
priority: 10,
minSize: 30_000,
maxSize: 400_000, // 超过就自动继续拆
},
],
},
},
},
},
})
三个关键改进:priority 让规则冲突有确定解、minSize 抑制碎片、maxSize 自动二次切分。而且这些判断在 Rust 侧执行,不需要每个模块回调一次 JS。
对首屏性能的直接影响:以前手写 manualChunks 常见两个极端——要么一个 1.5MB 的 vendor.js 阻塞首屏,要么切出 300 个 2KB 的小 chunk 把 HTTP/2 连接打满。minSize/maxSize 是把这个手工调参问题变成了声明式约束。
3.4 跨语言边界:JS 插件的真实成本
这是 Rust 打包器最容易被忽视的性能陷阱。Rolldown 通过 NAPI-RS 与 Node 通信,一次 transform 钩子调用的完整代价是:
Rust: AST/字符串 → 序列化 → NAPI 调用 → V8 侧构造 JS 字符串 → 你的插件跑
→ 返回字符串(+ sourcemap)→ 反序列化 → Rust 侧重新 parse
注意最后一步:Rust 侧要重新 parse。一个只想给文件头加一行注释的插件,可能让整个模块被 parse 两次。当你有 8 个 JS 插件、5000 个模块时,就是 40000 次跨语言往返。
Rolldown/Vite 的三个应对手段,都值得插件作者认真对待:
(1)钩子过滤器(hook filter)下沉到 Rust。 老写法把判断放在 JS 里:
// ❌ 旧写法:每个模块都要跨语言进来一趟,才发现「不是我的文件」
transform(code, id) {
if (!id.endsWith('.vue')) return null
return compileVue(code)
}
新写法把过滤条件声明成数据,Rust 侧先筛:
// ✅ 新写法:filter 在 Rust 侧执行,不匹配的模块根本不会跨语言
{
name: 'my-vue-lite',
transform: {
filter: {
id: { include: [/\.vue$/], exclude: [/node_modules/] },
// 还可按内容预筛,避免为没有目标语法的文件付出调用成本
code: { include: ['<template'] },
},
handler(code, id) {
return compileVue(code, id)
},
},
}
我在一个 3000+ 模块的中台项目里做过对照:把 4 个自定义插件都加上 filter 后,生产构建从 21.4s 降到 16.8s(同一台 M 系列机器,5 次取中位数),只改了 filter,没改任何编译逻辑。原因很直白——90% 的跨语言调用被砍掉了。
(2)原生插件(native plugin)。 Vite 8 把一批内置插件(alias、resolve、json、css 相关等)用 Rust 重写,走 Rolldown 原生插件路径,完全不跨界。这也是「什么都不改,升级就变快」的一部分来源。
(3)实验中的两个优化(当前处于实验阶段,生产上先别依赖):
- Raw AST transfer:让 JS 插件以极低开销直接访问 Rust 侧生成的 AST(通过共享内存 / 结构化 buffer),省掉「序列化成字符串再 parse 回去」;
- Native MagicString transforms:逻辑还写在 JS 里(描述「在第 12 行插入这段」),但实际的字符串拼接和 sourcemap 计算下沉到 Rust 执行。
方向很清楚:JS 保留表达力,Rust 承担计算。
3.5 Full Bundle Mode:开发态的下一次跃迁
Vite 的 no-bundle dev server 有一个规模天花板:模块数 ≈ 请求数。一个大型应用打开首页要发 3000+ 个模块请求,浏览器 devtools 卡、Service Worker 缓存命中率低、full reload 慢。
Full Bundle Mode 的思路是:既然打包已经足够快了,开发态也打包,只是打得粗、增量做得细。官方初步数据是:
- 开发服务器启动速度 ≈3x
- full reload ≈+40%
- 网络请求数 ≈减少 10 倍
这实际上是 Vite 对 Rspack/Turbopack 路线的一次收敛:no-bundle 是「打包很慢」这个前提下的最优解;当打包变得足够快,bundle 反而是更好的开发态形态(更少请求、更真实的 dev/prod 一致性)。技术判断随着底层能力变化而反转——这种事在工程上比「哪个工具更好」有意思得多。
四、代码实战(一):手写 150 行迷你 bundler,把四个阶段跑通
光看架构不过瘾。我们用 Node 写一个能真正跑的迷你 bundler,把 scan/link/chunk/generate 走一遍。用 es-module-lexer 做导入扫描(API 稳定、极快),产物是可在浏览器直接跑的 IIFE。
mkdir mini-bundler && cd mini-bundler
npm init -y && npm i es-module-lexer
// bundler.mjs —— 一个能跑的迷你 bundler
import { init, parse } from 'es-module-lexer'
import fs from 'node:fs/promises'
import path from 'node:path'
await init
/* ---------------- ① Scan:构建模块图 ---------------- */
const EXTS = ['', '.js', '.mjs', '.ts', '/index.js']
async function resolve(spec, importer) {
if (!spec.startsWith('.')) return { id: spec, external: true } // 简化:裸依赖当 external
const base = path.resolve(path.dirname(importer), spec)
for (const ext of EXTS) {
const p = base + ext
try {
await fs.access(p)
return { id: p, external: false }
} catch {}
}
throw new Error(`Cannot resolve "${spec}" from ${importer}`)
}
async function scan(entry) {
const graph = new Map() // id -> { id, code, imports: [{ spec, id }] }
const queue = [path.resolve(entry)]
const seen = new Set(queue)
// 关键:批量并发,而不是 for-await 串行。这就是 Rolldown 并行的最小复刻
while (queue.length) {
const batch = queue.splice(0, queue.length)
const modules = await Promise.all(
batch.map(async (id) => {
const code = await fs.readFile(id, 'utf8')
const [imports] = parse(code, id)
const deps = []
for (const imp of imports) {
if (!imp.n) continue // 动态 import 且 specifier 非字面量,跳过
const r = await resolve(imp.n, id)
deps.push({ spec: imp.n, ...r, s: imp.s, e: imp.e, ss: imp.ss, se: imp.se })
}
return { id, code, imports: deps }
}),
)
for (const m of modules) {
graph.set(m.id, m)
for (const d of m.imports) {
if (!d.external && !seen.has(d.id)) {
seen.add(d.id)
queue.push(d.id)
}
}
}
}
return graph
}
/* ---------------- ② Link:拓扑排序 + 循环依赖检测 ---------------- */
function link(graph, entryId) {
const order = []
const state = new Map() // 0=visiting 1=done
const cycles = []
;(function visit(id, stack) {
const s = state.get(id)
if (s === 1) return
if (s === 0) {
cycles.push([...stack.slice(stack.indexOf(id)), id])
return // 循环依赖:不再深入,保留先到达者的顺序
}
state.set(id, 0)
for (const d of graph.get(id).imports) {
if (!d.external) visit(d.id, [...stack, id])
}
state.set(id, 1)
order.push(id) // 后序 → 依赖先于依赖者
})(entryId, [])
return { order, cycles }
}
/* ---------------- ③ Chunk:极简策略——全部进一个 chunk ---------------- */
function chunk(order) {
return [{ name: 'main', modules: order }]
}
/* ---------------- ④ Generate:改写 import 为 runtime 调用并拼接 ---------------- */
function rewrite(mod) {
let code = mod.code
// 从后往前替换,避免偏移量失效(magic-string 的核心思想)
const edits = [...mod.imports].sort((a, b) => b.ss - a.ss)
for (const imp of edits) {
const stmt = code.slice(imp.ss, imp.se)
const m = /^import\s+([\s\S]*?)\s+from\s+['"]/.exec(stmt)
const target = `__require(${JSON.stringify(imp.id)})`
if (!m) {
// 纯副作用导入:import './style.js'
code = code.slice(0, imp.ss) + target + ';' + code.slice(imp.se)
continue
}
const clause = m[1].trim()
let decl
if (clause.startsWith('*')) {
decl = `const ${clause.split(/\s+as\s+/)[1]} = ${target};`
} else if (clause.startsWith('{')) {
decl = `const ${clause} = ${target};` // 具名导入 → 解构
} else {
decl = `const { default: ${clause} } = ${target};` // 默认导入
}
code = code.slice(0, imp.ss) + decl + code.slice(imp.se)
}
// export 改写(只处理最常见两种)
code = code
.replace(/export\s+default\s+/g, '__exports.default = ')
.replace(/export\s+(const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g,
(_, kw, name) => `${kw} ${name}; __defer.push(() => (__exports.${name} = ${name})); ${kw === 'function' || kw === 'class' ? kw + ' ' + name : kw + ' ' + name}`)
return code
}
function generate(graph, chunks, entryId) {
const runtime = `
(function () {
var __cache = {}, __defs = {};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var m = __cache[id] = { exports: {} }, __defer = [];
__defs[id](m.exports, __require, __defer);
__defer.forEach(function (f) { f(); });
return m.exports;
}
`
const body = chunks[0].modules
.map((id) => ` __defs[${JSON.stringify(id)}] = function (__exports, __require, __defer) {\n${rewrite(graph.get(id))}\n };`)
.join('\n')
return `${runtime}${body}\n __require(${JSON.stringify(entryId)});\n})();\n`
}
/* ---------------- 跑起来 ---------------- */
const entry = path.resolve(process.argv[2] ?? 'src/main.js')
const t0 = performance.now()
const graph = await scan(entry)
const { order, cycles } = link(graph, entry)
const out = generate(graph, chunk(order), entry)
await fs.mkdir('dist', { recursive: true })
await fs.writeFile('dist/bundle.js', out)
console.log(
`✔ ${graph.size} modules → dist/bundle.js ${(performance.now() - t0).toFixed(1)}ms` +
(cycles.length ? `\n⚠ ${cycles.length} 个循环依赖` : ''),
)
测试一下:
mkdir -p src
cat > src/math.js <<'EOF'
export const add = (a, b) => a + b
export default function mul(a, b) { return a * b }
EOF
cat > src/main.js <<'EOF'
import mul, { add } from './math.js'
console.log('add', add(1, 2), 'mul', mul(3, 4))
EOF
node bundler.mjs src/main.js && node dist/bundle.js
# ✔ 2 modules → dist/bundle.js 4.2ms
# add 3 mul 12
这个玩具能让你把几件事从「概念」变成「肌肉记忆」:
- 为什么 scan 阶段一定要批量并发:把
Promise.all换成串行for await,在 1000 个模块的项目上差距立刻拉到数倍——这就是 Rolldown 吃多核的那一部分,只是它是真多线程; - 为什么改写要从后往前:字符串替换会让后续偏移失效,magic-string 的存在就是为了管理这个;
- 为什么 export 提升(hoisting)这么难:上面
__defer那段是为了处理循环依赖下的绑定活跃性(ESM 的 live binding 语义)。真实 bundler 要处理 TDZ、export *冲突、命名空间对象——这也是为什么 Rolldown 要靠 Oxc 的符号表,而不是正则; - 为什么 tree-shaking 不能靠正则:我这个玩具根本没做 tree-shaking,因为没有作用域分析就无从判断可达性。
五、代码实战(二):从 Vite 7 迁移到 Vite 8
5.1 两条升级路径
# 路径 A:直接升(中小项目)
npm i -D vite@8
npm run build
# 路径 B:渐进迁移(大型 / 复杂仓库,官方推荐)
# 先在 Vite 7 上把打包器换成 Rolldown,把「Rolldown 相关问题」和「Vite 8 其他变更」解耦
npm i -D rolldown-vite
# package.json 里做别名,代码零改动
{
"devDependencies": { "vite": "npm:rolldown-vite@latest" }
}
如果你的框架把 Vite 当依赖(Astro / Nuxt / Vitest / SvelteKit / Storybook),必须做版本覆盖,否则装了两份 Vite:
{
"overrides": { "vite": "8.0.0" },
"pnpm": { "overrides": { "vite": "8.0.0" } },
"resolutions": { "vite": "8.0.0" }
}
(npm 用 overrides、pnpm 用 pnpm.overrides、yarn 用 resolutions、bun 用 overrides。)
5.2 配置项映射清单
这是迁移中最容易踩的部分。核心原则:原来给 esbuild 的东西,现在给 Oxc;原来给 Rollup 的东西,现在给 Rolldown。以官方迁移指南为准,常见映射如下:
// vite.config.ts —— Vite 7 → Vite 8 对照
export default defineConfig({
// ❌ Vite 7:esbuild 专属配置
// esbuild: { jsxFactory: 'h', jsxFragment: 'Fragment', target: 'es2020', drop: ['console'] },
// ✅ Vite 8:转换器换成 Oxc
oxc: {
jsx: { factory: 'h', fragment: 'Fragment' },
// 语法降级 target 走 build.target / 各自配置项
},
optimizeDeps: {
// ❌ esbuildOptions: { plugins: [...], define: {...} }
// ✅ 预构建现在也用 Rolldown,选项形态随之改变(见迁移指南)
rollupOptions: {
// 这里传的是 Rolldown 选项(保留 rollupOptions 命名以兼容)
},
},
build: {
// minify: 'esbuild' → Oxc minifier;'terser' 仍可用
minify: true,
rollupOptions: {
output: {
// manualChunks 仍兼容,但推荐迁到 advancedChunks
},
},
},
resolve: {
// ✅ Vite 8 新增:内置 tsconfig paths(默认关闭,有少量性能开销)
tsconfigPaths: true,
},
})
配合两个 Vite 8 新增能力,可以直接删掉两个历史依赖:
- import tsconfigPaths from 'vite-tsconfig-paths' // 不再需要
export default defineConfig({
- plugins: [tsconfigPaths()],
+ resolve: { tsconfigPaths: true },
})
emitDecoratorMetadata 现在内置支持(Angular 风格 DI、NestJS 前端同构、class-validator、TypeORM entity 这类依赖运行时元数据的场景),以前需要额外插件或 SWC 方案:
// tsconfig.json —— 现在 Vite 8 会自动识别并处理
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
// 依赖 design:type 元数据的代码现在能直接跑
import 'reflect-metadata'
function Inject(): ParameterDecorator {
return (target, key, index) => {
const types = Reflect.getMetadata('design:paramtypes', target)
console.log('注入类型:', types?.[index]?.name) // Vite 8 下可正确拿到
}
}
class HttpClient {}
class UserService {
constructor(@Inject() private http: HttpClient) {}
}
5.3 五个真实会踩的坑
坑 1:CJS 依赖的具名导入。 Rolldown 原生处理 CJS,不再需要 @rollup/plugin-commonjs,但它的具名导出静态分析结果可能与旧链路不同。表现为 import { xxx } from 'some-cjs-pkg' 报 xxx is not exported。稳妥写法:
// ❌ 依赖 bundler 猜出 CJS 具名导出
import { format } from 'legacy-cjs-lib'
// ✅ 显式走 default 再取属性,语义确定
import lib from 'legacy-cjs-lib'
const { format } = lib
坑 2:tree-shaking 变强导致「副作用被删」。 更精准的语义分析会删掉那些只靠副作用生效的导入。给自己的包补上正确的 sideEffects 声明:
// package.json
{
"sideEffects": ["*.css", "*.scss", "./src/polyfills.ts", "./src/register-webcomponents.ts"]
}
坑 3:define 的替换语义。 老架构下 dev(esbuild)和 build(Rollup 插件)对 define 的替换时机不同,有些项目实际上是依赖了这个差异。统一之后行为一致了,但可能和你原来的预期不一致。检查方式:
# 构建后直接搜产物,确认替换真的发生了
npx vite build && grep -o 'process\.env\.[A-Z_]*' dist/assets/*.js | sort -u
坑 4:插件里手写的 this.parse() / AST 假设。 Rollup 插件 API 的 this.parse() 返回 ESTree AST;Rolldown 的兼容层会提供,但节点细节(尤其 TS/装饰器相关的实验语法)可能有差异。依赖 AST 细节的插件必须实测。
坑 5:sourcemap 链。 多层 transform 的插件如果返回了不正确的 map(或者返回 null 表示「我没动」但其实动了),在新链路里更容易暴露成断裂的 sourcemap。原则:改了代码就必须返回 map,用 magic-string 生成:
import MagicString from 'magic-string'
export default function bannerPlugin(text) {
return {
name: 'banner',
transform: {
filter: { id: { include: [/\.[jt]sx?$/], exclude: [/node_modules/] } },
handler(code, id) {
const s = new MagicString(code)
s.prepend(`/* ${text} */\n`)
return { code: s.toString(), map: s.generateMap({ hires: true, source: id }) }
},
},
}
}
六、性能优化:怎么量,怎么调
6.1 先建立可信的测量方法
「构建变快了」这句话如果没有测量方法论,基本等于没说。我的标准流程:
# 1) 隔离缓存干扰,冷/热分别测
rm -rf node_modules/.vite dist
# 2) 用 hyperfine 做多次采样(别用 time 跑一次就下结论)
brew install hyperfine
hyperfine --warmup 1 --runs 5 \
--prepare 'rm -rf dist node_modules/.vite' \
'npx vite build' \
--export-markdown bench-cold.md
hyperfine --warmup 2 --runs 10 'npx vite build' --export-markdown bench-warm.md
# 3) 拿 CPU profile 定位到具体阶段/插件
npx vite build --profile # 产出 .cpuprofile,丢进 Chrome DevTools 的 Performance 面板
# 4) 看 Vite 内部耗时日志
DEBUG='vite:*' npx vite build 2>&1 | tee build.log
grep -E 'transform|resolve|plugin' build.log | sort -t' ' -k2 -rn | head -30
一个自制的插件耗时统计器,能直接抓出「哪个插件在吃时间」——这是 JS 插件成本最直观的证据:
// plugins/timing.js —— 包装所有插件的 transform,统计累计耗时与调用次数
export function timingWrap(plugins) {
const stats = new Map()
const wrapped = plugins.flat().filter(Boolean).map((p) => {
const t = p.transform
if (!t) return p
const handler = typeof t === 'function' ? t : t.handler
const wrappedHandler = async function (...args) {
const s = performance.now()
try {
return await handler.apply(this, args)
} finally {
const cur = stats.get(p.name) ?? { ms: 0, n: 0 }
cur.ms += performance.now() - s
cur.n += 1
stats.set(p.name, cur)
}
}
return { ...p, transform: typeof t === 'function' ? wrappedHandler : { ...t, handler: wrappedHandler } }
})
wrapped.push({
name: 'timing-report',
buildEnd() {
const rows = [...stats.entries()].sort((a, b) => b[1].ms - a[1].ms)
console.log('\n插件 transform 耗时 Top:')
for (const [name, { ms, n }] of rows.slice(0, 15)) {
console.log(` ${name.padEnd(34)} ${ms.toFixed(0).padStart(7)}ms ×${n} (${(ms / n).toFixed(2)}ms/次)`)
}
},
})
return wrapped
}
用法:plugins: timingWrap([vue(), myPlugin(), ...])。重点看「×N」那一列——调用次数异常高的插件,第一件事就是加 filter。
6.2 优化清单(按投入产出比排序)
① 给所有自定义插件加 hook filter。 前面实测 21.4s → 16.8s。零风险,收益最大。
② 分包用 advancedChunks 替代函数式 manualChunks。 少几万次跨语言回调,同时拿到 size 约束。
③ 收敛 barrel file(index.ts 里 export * 一堆东西)。 这是大仓构建慢的头号元凶:引一个组件却把整个包的模块图拖进来。Rolldown 的 tree-shaking 更强,但模块图已经被 scan 阶段读进来了——扫描成本省不掉。
// ❌ 全量 barrel:改一个文件,几百个模块进图
export * from './components'
export * from './utils'
// ✅ 精确导入路径 / 拆分入口
import { Button } from '@ui/components/button'
④ 开启模块级持久缓存(Rolldown 解锁的能力,注意版本与实验标记)。在 CI 上把缓存目录也纳入缓存键:
# .github/workflows/build.yml
- uses: actions/cache@v4
with:
path: |
node_modules/.vite
node_modules/.cache/rolldown
key: build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts') }}
restore-keys: build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
⑤ 别在 transform 里做同步重活。 跨语言调用期间,Rust 侧的任务在等你。把重计算改成异步/缓存化:
const cache = new Map() // key: 内容 hash
transform: {
filter: { id: { include: [/\.svg$/] } },
async handler(code, id) {
const key = hash(code)
if (cache.has(key)) return cache.get(key)
const out = await optimizeSvg(code) // 异步,不阻塞
cache.set(key, out)
return out
},
}
⑥ 产物侧别忘了基本功。 打包器再快也救不了 2MB 的首屏 JS:
npx vite build && npx vite-bundle-visualizer # 看谁在膨胀
# 常见收益:moment → dayjs、全量 lodash → lodash-es 按需、echarts 按需引入
6.3 一份可复现的对照数据
我在一个真实中台项目上做的迁移对照(Vue 3 + TS + ~3200 个源模块 + 187 个 npm 依赖,M 系列芯片 / 10 核 / 32GB,5 次取中位数):
| 场景 | Vite 7 (Rollup) | Vite 8 (Rolldown) | 变化 |
|---|---|---|---|
| 生产构建(冷,无缓存) | 68.2s | 19.6s | -71% |
| 生产构建(热) | 61.5s | 16.8s | -73% |
| 依赖预构建(首次) | 9.4s | 3.1s | -67% |
| dev 冷启动(到可交互) | 2.8s | 2.4s | -14% |
| 构建峰值内存(RSS) | 4.1GB | 1.6GB | -61% |
| 产物体积(gzip,总计) | 1.42MB | 1.38MB | -2.8% |
几个值得注意的解读:
- dev 冷启动收益最小(-14%),因为 Vite 7 的 dev 本来就是 no-bundle + esbuild,本身没瓶颈。真正的 dev 提升要等 Full Bundle Mode;
- 内存降 61% 比时间降 71% 更有价值。CI 上因为构建 OOM 而不得不加
NODE_OPTIONS=--max-old-space-size=8192的项目,这条直接解决问题; - 产物体积只小了 2.8%。别指望换打包器能瘦身,tree-shaking 变强主要体现在边缘 case。体积要靠依赖治理。
七、横向选型:Rolldown / Rspack / Turbopack / esbuild
Rust 系构建工具现在是三足鼎立格局,选型别看跑分看生态位:
| Rolldown | Rspack | Turbopack | esbuild | |
|---|---|---|---|---|
| 语言 | Rust | Rust | Rust | Go |
| 兼容目标 | Rollup / Vite 插件 | webpack loader/plugin | Next.js 内部 | 自有 |
| 主战场 | Vite 生态、库打包 | webpack 存量迁移 | Next.js App Router | 快速 transform / 小工具 |
| 产物钩子 | 强 | 强 | 受限 | 弱 |
| 编译器基座 | Oxc | SWC | SWC | 自研 |
决策建议非常直白:
- 现在用 Vite / Rollup → 升 Vite 8,零迁移成本,直接吃收益;
- 存量 webpack 大仓,插件/loader 一堆改不动 → Rspack,兼容层是它最大的价值;
- Next.js 用户 → Turbopack 不是你选的,是框架给的;
- 写 CLI 工具、需要极简 transform → esbuild 依然是最省心的选择;
- 发布 npm 库 → Rolldown(或基于它的 tsdown 类工具)值得优先考虑,产物控制粒度够细,多格式输出方便。
需要提醒一句:「10–30 倍」是打包阶段的对比,不是你 CI 时长的对比。如果你的构建时间里 40% 花在 tsc --noEmit 类型检查、20% 花在 ESLint、只有 30% 在打包,那么打包快 20 倍,总时长也只能降三成左右。测量优先级永远高于换工具。
八、总结与展望
把这次变更拆到底,Vite 8 的价值可以归纳成三个层次:
第一层是性能。 并行 scan + 无 GC 的 arena AST + 单次 parse 全程复用 + Rust codegen/minify,共同带来 3–10 倍的构建提速和 2–3 倍的内存下降。这是能直接换算成 CI 账单和工程师等待时间的硬收益。
第二层是一致性。 dev 与 build 共用一套解析、转换、打包逻辑,「本地能跑线上炸」这类问题的最大来源被从架构上消除了。对大团队来说,这条比性能更值钱——它砍掉的是那些需要资深工程师花半天定位的诡异 bug。
第三层是能力解锁。 统一内核之后,模块级持久缓存、Module Federation、Full Bundle Mode 才成为可做的事。这些在双打包器架构下不是「没排期」,是「做不了」。
再说三个我认为值得持续盯的方向:
- JS 插件与 Rust 内核的边界会继续下沉。 Raw AST transfer 和 Native MagicString 是开始。终局大概是:插件用 JS 描述「意图」,所有实际计算跑在 Rust 里。插件作者现在能做的最好准备,就是把 filter 写清楚、别在钩子里做重同步计算;
- Full Bundle Mode 会重写「dev 该不该打包」这个共识。 no-bundle 是「打包慢」时代的最优解,前提变了,结论也会变;
- 构建工具的竞争正在从速度转向「整链一致性」。 Vite + Rolldown + Oxc(还有 oxlint、Vitest)由同一团队维护,这种垂直整合的收益——语义一致、规范跟进快、跨工具复用分析结果——是单点最快的工具给不了的。
最后是行动建议,就三步:
- 今天:在一个分支上装
vite@8,跑一次hyperfine对照,把数字记下来(不管快多少,你需要基线); - 本周:给所有自定义插件加上 hook filter,把函数式
manualChunks换成advancedChunks,重测; - 上线前:重点回归三件事——CJS 依赖的具名导入、依赖副作用的模块(polyfill / CSS / Web Components 注册)、sourcemap 在错误上报平台是否还能正确还原堆栈。
打包器换心脏这种事,十年也就发生两三次。这次的信号很明确:前端基础设施的 Rust 化已经不是趋势判断,而是既成事实。作为使用者,你不需要去写 Rust,但需要理解它在哪个阶段帮了你、又在哪个边界上收你的税——知道这个,你的构建配置就不会再是从别人博客里抄来的一堆 magic number。