Vite 8 深度拆解:当 Vite 亲手拆掉「esbuild + Rollup」双引擎——Rolldown、Oxc 与打包式开发的一次范式回收
2026 年 3 月 12 日,Vite 8.0 正式发布。这一版没有加什么花哨的语法糖,它做的是一件更狠的事:把用了六年的双打包器架构,一次性拆了。
esbuild 负责开发、Rollup 负责生产——这套 2019 年定下来的组合拳,在 Vite 8 里被一个 Rust 写的打包器 Rolldown 全盘接管。官方的原话是「这是 Vite 2 以来最重大的架构变更」。
如果你只把它当成一次「构建变快了」的常规升级,那你会在升级当天被 CJS 互操作、manualChunks 被删、装饰器降级失效这些东西按在地上摩擦。这篇文章要做的,是把 Vite 8 这一刀切下去之后露出来的东西,从架构原理到迁移实战,一层层拆开讲清楚。
本文覆盖:
- 双打包器架构的历史债到底债在哪
- Rolldown / Oxc / Lightning CSS 三层栈是怎么咬合的
- 自动分包与手动分包的完整算法模型(这是
manualChunks被删的根因) - Lazy Barrel 优化:为什么 import 一个 antd 组件能少编译 92% 的模块
- CJS 互操作语义统一——最容易在生产环境炸的一个变更
- Vite 8.1 的 Bundled Dev Mode:Vite 亲手推翻了自己的 no-bundle 信仰
- 完整迁移剧本 + 十六条踩坑清单
一、背景:一个「务实的赌注」欠了六年的债
1.1 双引擎不是设计,是取舍
2019 年 Vite 出生的时候,前端构建的痛点非常明确:Webpack 冷启动要在启动 dev server 之前把整个依赖图扫一遍打一遍包,项目一大就是分钟级等待。
Vite 的解法分两半:
开发态:不打包。利用浏览器原生 ESM,源码按需请求、按需转换。要转 TypeScript / JSX,用 esbuild——Go 写的,快到几乎感知不到。依赖预构建(dep pre-bundling)也交给 esbuild,把 CJS 依赖转成 ESM 并合并请求。
生产态:老老实实打包。用 Rollup——它的 tree-shaking 成熟,产物干净,最重要的是它那套 resolveId / load / transform / renderChunk 插件 API 设计得太好了,好到整个 Vite 插件生态直接建在上面。
官方在 Vite 8 的发布博客里把这称作「a pragmatic bet on two bundlers」——一个务实的赌注。这个赌注赢了六年。Vite 现在每周被下载 6500 万次,Vite 8.1 单版本周下载量就有 4160 万,差不多追平了整个 Vite 7。
1.2 债务清单
但两个打包器就是两套世界观,代价是实打实的:
两条转换流水线。同一个 .tsx 文件,dev 走 esbuild 的 transform,build 走 Rollup 插件链里的 esbuild 调用 + Rollup 自己的解析。两条链路对语法边界、对 sourcemap、对 define 替换的处理都有微妙差异。
两套插件语义。Vite 插件本质是 Rollup 插件的超集,但 dev 阶段很多 Rollup 钩子根本不会被调用(因为 dev 不走打包),插件作者要写 apply: 'build'、enforce: 'pre' 这类条件分支来适配。
越来越厚的胶水层。为了让 dev 和 build 表现一致,Vite 核心里堆了大量对齐代码。官方原话是「every alignment fix in one pipeline risked introducing differences in the other」——你在一条流水线上修 bug,很可能在另一条上引入不一致。
这就是典型的架构性技术债:不影响功能,但持续吞噬维护带宽,而且边界情况会随时间累积。
1.3 时间线
- 2024-2025:VoidZero 团队启动 Rolldown,同时发布
rolldown-vite包作为技术预览版(面向 Vite 6/7),让早期用户能在不动稳定版的前提下试水 - 2025 年 12 月:Vite 8 Beta 发布,Rolldown 完整集成
- 2026 年 3 月 12 日:Vite 8.0 稳定版发布
- 2026 年 6 月 23 日:Vite 8.1 发布,带来 Bundled Dev Mode 和 Chunk Import Map
- 当前:v8.2.x
值得注意的是官方的迁移策略:先出一个独立包让社区在真实代码库上跑,同时搭一套专门的 CI 验证主流插件和框架(SvelteKit、React Router、Storybook、Astro、Nuxt 都参与了早期测试)。这个做法值得所有做基础设施的人抄——不要指望在自己的测试用例里能覆盖生态的长尾。
二、核心概念:三层栈是怎么咬合的
Vite 8 之后,你面对的不再是「Vite」这一个东西,而是一个分层明确的工具链:
┌─────────────────────────────────────────┐
│ Vite 构建工具 / 编排层 │
│ - dev server, HMR, 插件容器, 环境 API │
├─────────────────────────────────────────┤
│ Rolldown 打包器 (Rust) │
│ - 模块图, 分包, tree-shaking, 产物生成 │
├─────────────────────────────────────────┤
│ Oxc 编译器 (Rust) │
│ - parser, resolver, transformer, │
│ minifier, 语义分析 │
├─────────────────────────────────────────┤
│ Lightning CSS CSS 处理 (Rust) │
│ - CSS 压缩 (默认), 语法降级 │
└─────────────────────────────────────────┘
2.1 Rolldown:一个「兼容 Rollup API」的 Rust 打包器
Rolldown 的三个设计目标,官方讲得很直白:
性能:Rust 编写,原生速度运行。在 rolldown/benchmarks 的基准里比 Rollup 快 10-30 倍,与 esbuild 处在同一性能量级。
兼容性:支持与 Rollup 和 Vite 相同的插件 API。大多数现有 Vite 插件在 Vite 8 里开箱即用。
这一点是整个迁移能成的关键。如果 Rolldown 另起炉灶设计一套插件 API,那 Vite 生态里几千个插件全要重写,这个迁移根本不可能发生。官方在博客最后专门致谢 Rollup:「Its elegant plugin API design proved so well-conceived that Rolldown adopted it as its own」——插件 API 设计得太好了,好到 Rolldown 直接照搬。
高级能力:单一打包器解锁了双引擎时代做不到的东西:Full Bundle Mode(现改名 Bundled Dev Mode)、更灵活的分包控制、模块级持久化缓存、Module Federation 支持。
2.2 Oxc:被藏在下面的真正主角
很多人只盯着「Rollup → Rolldown」,忽略了另一半:esbuild 的角色被 Oxc 接走了。
打包器不是一个单体,它由若干组件构成:
| 组件 | Vite 7 | Vite 8 |
|---|---|---|
| Parser(语法解析) | esbuild / Rollup 内置 | Oxc |
| Resolver(模块解析) | esbuild / @rollup/plugin-node-resolve | Oxc resolver |
| Transformer(TS/JSX 转换) | esbuild | Oxc |
| Minifier(JS 压缩) | esbuild | Oxc minifier |
| CSS Minifier | esbuild | Lightning CSS |
| Bundler | esbuild(dev) / Rollup(build) | Rolldown |
统一带来的不只是「少一份依赖」。官方点出了一个更深的收益:可以利用 Oxc 的语义分析结果做更好的 tree-shaking。
这是跨层优化才可能做到的事。在双引擎时代,esbuild 的 AST 和 Rollup 的 AST 是两个互不认识的东西,Rollup 拿不到 esbuild 的语义信息,只能自己再解析一遍。现在 Rolldown 和 Oxc 共享同一套 Rust 数据结构,作用域分析、绑定关系、副作用推断都能直接复用。
2.3 一个容易被忽略的成本:安装体积
官方在发布博客里专门开了一节讲这个,态度很坦诚:
Vite 8 比 Vite 7 大约大 15 MB。
- 约 10 MB 来自
lightningcss:以前是可选 peer 依赖,现在是普通依赖,为了开箱即用的 CSS 压缩 - 约 5 MB 来自 Rolldown 二进制:比 esbuild + Rollup 更大,主要因为性能优化牺牲了体积
对 CI 缓存命中率不高、或者要往 Lambda 塞构建环境的团队,这 15MB 是要算进账的。
三、架构分析:模块图是怎么变成 chunk 的
这一节是全文最硬的部分,也是理解「为什么 manualChunks 对象形式被直接删掉」的唯一路径。
3.1 自动分包:两类 chunk
Rolldown 的自动分包(Automatic Code Splitting)不可配置,它按固定规则跑。产出两类 chunk。
Entry Chunks(入口 chunk)
把静态连通的模块合并成一个 chunk。「静态」指的是 import ... from '...' 或者 require(...)。
Entry chunk 又分两种:
- Initial chunks:来自用户配置。
input: ['./a.js', './b.js']定义了两个 initial chunk - Dynamic chunks:来自动态导入。
import()的目标要按需加载,所以不能和 importer 放一起
看个例子:
// entry.js(在 input 选项里)
import foo from './foo.js';
import('./dyn-entry.js');
// dyn-entry.js
require('./bar.js');
// foo.js
export default 'foo';
// bar.js
module.exports = 'bar';
这里有两组静态连通的模块:
Group 1 (initial chunk): entry.js --static--> foo.js
Group 2 (dynamic chunk): dyn-entry.js --require--> bar.js
entry.js --import()--> dyn-entry.js (跨组,虚线)
最终生成两个 chunk。注意 require('./bar.js') 被算作静态连接——这一点和很多人的直觉不一样。
Common Chunks(公共 chunk)
当一个模块被至少两个不同的 entry 静态导入时,它被抽到独立 chunk。
目的有两个,都很本质:
- 保证每个 JS 模块在最终产物里是单例(不然模块顶层的副作用会执行多次)
- entry 执行时,只执行它真正导入的模块
关键规则来了:模块能否放进同一个 common chunk,取决于它是否被同一组 entry 导入。
// entry-a.js
import 'shared-by-ab.js';
import 'shared-by-abc.js';
// entry-b.js
import 'shared-by-ab.js';
import 'shared-by-bc.js';
import 'shared-by-abc.js';
// entry-c.js
import 'shared-by-bc.js';
import 'shared-by-abc.js';
三个 entry,三个共享模块。产物是六个 chunk:
// entry-a.js
import './common-ab.js';
import './common-abc.js';
// entry-b.js
import './common-ab.js';
import './common-bc.js';
import './common-abc.js';
// entry-c.js
import './common-bc.js';
import './common-abc.js';
// common-ab.js ← 被 {a, b} 导入
// common-bc.js ← 被 {b, c} 导入
// common-abc.js ← 被 {a, b, c} 导入
shared-by-ab 和 shared-by-abc 不能合并,因为它们的「导入者集合」不同——如果合并了,entry-c 就会被迫加载并执行 shared-by-ab 的顶层代码,违反第 2 条原则。
这个算法的本质是按导入者集合做等价类划分。理解了它,你就理解了为什么自动分包只能这样、也只会这样。
3.2 手动分包:codeSplitting 取代 manualChunks
自动分包只看静态导入关系,完全不考虑加载性能和缓存失效。这就是手动分包存在的理由。
官方文档里的例子很直观。一个 React 应用:
// index.jsx
import * as ReactDom from 'react-dom';
import App from './App.jsx';
ReactDom.createRoot(document.getElementById('root')).render(<App />);
// App.jsx
import * as React from 'react';
import { Button } from 'ui-lib';
export default function App() {
return <Button onClick={() => alert('Button clicked!')} />;
}
默认产出一个 output-hash0.js,里面塞了 react + react-dom + ui-lib + 你的业务代码。
现在你改了 App.jsx 里一句 alert 文案。文件 hash 变了,浏览器要重新下载整个 bundle——包括那 300KB 一年没动过的 React。
用 codeSplitting 把库拆出来:
// rolldown.config.js / vite.config.ts 里的 build.rolldownOptions.output
export default {
output: {
codeSplitting: {
groups: [
{
test: /node_modules/,
name: 'libs',
},
],
},
},
};
现在改 App.jsx,libs-hash0.js 的 hash 不变,浏览器直接命中缓存。
完整的分组配置模型
codeSplitting 的类型是 boolean | object:
true:默认,自动分包false:把所有动态导入内联进单个 bundle(等价于已废弃的inlineDynamicImports: true)object:高级手动分包配置
一份生产级的分组配置长这样:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rolldownOptions: {
output: {
codeSplitting: {
// 全局兜底:小于这个尺寸的组不单独成 chunk
minSize: 20000,
groups: [
{
name: 'react-vendor',
test: /node_modules[\\/]react/,
priority: 20,
},
{
name: 'ui-vendor',
test: /node_modules[\\/]antd/,
priority: 15,
},
{
name: 'vendor',
test: /node_modules/,
priority: 10,
},
{
// 业务侧公共模块:被至少 2 个 chunk 共享且够大才抽出来
name: 'common',
minShareCount: 2,
minSize: 10000,
priority: 5,
},
],
},
},
},
},
})
关键字段:
| 字段 | 作用 |
|---|---|
test | 正则匹配模块 id |
name | 生成的 chunk 名 |
priority | 优先级,数字大的先匹配(决定 react 进 react-vendor 而不是 vendor) |
minSize / maxSize | 尺寸下限 / 上限,超过 maxSize 会继续切分 |
minModuleSize / maxModuleSize | 单模块尺寸过滤 |
minShareCount | 至少被几个 chunk 共享才抽取 |
includeDependenciesRecursively | 是否递归把依赖一起拉进这个组 |
按尺寸切分的用法:
codeSplitting: {
groups: [
{
name: 'large-libs',
test: /node_modules/,
minSize: 100000, // 100KB
maxSize: 250000, // 250KB,超了自动继续切
priority: 10,
},
],
}
maxSize 这个能力在 Rollup 时代要靠自己在 manualChunks 函数里手写哈希分桶,现在是声明式的。
一个必须知道的副作用陷阱
官方文档给了明确警告:
手动分包可能改变应用行为——如果副作用在对应模块真正被使用之前就触发了。
原因很直接:分包改变了模块的执行顺序。原本按依赖图拓扑序执行的模块,被塞进不同 chunk 后,chunk 的加载/执行顺序未必和源码顺序一致。
两个解法:
- 调整分组配置,把顺序敏感的模块放在一起
- 用
output.strictExecutionOrder强制保持源码执行顺序
strictExecutionOrder 的实现是把模块包一层,让函数体按源码顺序执行——代价是产物体积增加。Rolldown 还提供了 experimental.onDemandWrapping,用「基于预测的 chunk 执行风险的保守计划」替代全量包裹,只在真正可能出问题的地方加 wrapper。
如果你的项目里有那种「import 一下就注册全局副作用」的模块(polyfill、i18n 注册、CSS-in-JS 主题注入),这一条要重点关注。
为什么对象形式的 manualChunks 被直接删了
现在答案很清楚了:
// ❌ Vite 8 不再支持
build.rollupOptions.output.manualChunks = {
vendor: ['react', 'react-dom'],
}
// ⚠️ 函数形式已弃用(还能用,但会警告)
build.rollupOptions.output.manualChunks = (id) => {
if (id.includes('node_modules')) return 'vendor'
}
// ✅ Vite 8 推荐
build.rolldownOptions.output.codeSplitting = {
groups: [{ name: 'vendor', test: /node_modules/ }],
}
对象形式表达能力太弱(只能列包名,没法表达优先级、尺寸约束、共享次数),函数形式虽然灵活但是JS 回调——在 Rust 打包器里,每个模块都要跨 FFI 边界回调一次 JS,这是性能杀手。声明式的 groups 配置可以整个下沉到 Rust 侧执行,一次 FFI 都不用。
这是一个典型的「架构决定 API」案例:不是设计师觉得声明式更优雅,是 Rust 内核跑 JS 回调太贵。
3.3 Lazy Barrel:让 antd 少编译 92% 的模块
这是 Rolldown 里最实用、也最被低估的一个优化。
问题:大型组件库(antd、lodash-es、@mui/material)大量使用 barrel 模块——一个 index.js 里 export * from './xxx' 上千次。你只 import 一个 Button,传统打包器要把这上千个模块全部编译一遍,然后再靠 tree-shaking 摇掉。
编译了再摇掉,等于白干。
Lazy Barrel 的做法:先分析哪些 export 真正被用了,只编译那些模块。未使用的 re-export 模块直接跳过,压根不进编译流水线。
官方给的真实数据(import { Button } from 'antd'):
| 指标 | 关闭 lazy barrel | 开启 lazy barrel |
|---|---|---|
| 编译模块数 | 2986 | 250 |
| 构建耗时(macOS) | ~65ms | ~28ms |
| 构建耗时(Windows) | ~210ms | ~50ms |
模块数减少 92%,构建提速 2-4 倍。Windows 上提速更明显——因为文件系统 IO 更贵,少读 2700 个文件的收益被放大了。
支持的模式
// 星号 re-export
export * from './components';
// 具名 re-export
export { Component } from './Component';
export { helper as utils } from './helper';
export { default as Button } from './Button';
export { Button as default } from './Button';
// 命名空间 re-export
export * as ns from './module';
// import-then-export(等价形式,同样被优化)
import { a } from './a';
export { a };
解析策略也很聪明:当一个 import 能在具名 export 里找到时,不再搜索星号 export。只有找不到时才去加载所有 export * 的目标——而如果那些目标也是 barrel 模块,同样只加载对应的 specifier。
一个必须知道的语义坑
文档里专门标了红:
// ❌ 这两个不等价
export { Button as default } from './Button.js'
import { Button } from './Button.js'; export default Button
前者导出的是同一个变量的绑定(live binding),后者 export default ... 会创建一个新变量,值被快照。
// Button.js
export let Button = 1;
export const increment = () => { Button++; };
// re-exporter.js
import { Button } from './Button.js';
export default Button; // 快照
export { Button as ReExportedButton } from './Button.js'; // live binding
// main.js
import { Button, increment } from './Button.js';
import ExportDefaultButton, { ReExportedButton } from './re-exporter.js';
console.log(Button); // 1
console.log(ReExportedButton); // 1
console.log(ExportDefaultButton); // 1
increment();
console.log(Button); // 2
console.log(ReExportedButton); // 2
console.log(ExportDefaultButton); // 1 ← 没变!
因为 export default ... 算作「自有 export」而不是纯 re-export,它会阻止 lazy barrel 优化。
所以如果你在维护一个组件库,index.ts 里请老老实实写 export { default as Button } from './Button',不要写 import Button from './Button'; export default Button——前者能被优化,后者不能,而且语义还不一样。
这是一条可以直接写进团队规范的规则。
3.4 CJS 处理:__commonJS 与按需执行
Rolldown 对 CommonJS 是原生支持,不需要 @rollup/plugin-commonjs。这也是为什么 build.commonjsOptions 在 Vite 8 里直接变成 no-op。
更重要的是它保留了 CJS 的按需执行语义:
// index.js
import { value } from './foo.js';
const getFooExports = () => require('./foo.js');
// foo.js
module.exports = { value: 'foo' };
打包后:
// #region \0rolldown/runtime.js
// ...runtime code
// #endregion
// #region foo.js
var require_foo = __commonJS({
'foo.js'(exports, module) {
module.exports = { value: 'foo' };
},
});
// #endregion
// #region index.js
const getFooExports = () => require_foo();
// #endregion
foo.js 的模块体被包进一个函数,只有 getFooExports() 被调用时才执行。这是 CJS 的核心语义,很多打包器为了简化会直接求值,Rolldown 保住了。
ESM 导入 CJS 时走 __toESM:
// #region index.js
var import_foo = __toESM(require_foo());
console.log(import_foo.value);
// #endregion
四、代码实战:完整迁移剧本
4.1 升级路径:两步走还是一步到位
官方给了两条路:
小项目:直接升。
pnpm add -D vite@^8.0.0
Vite 8 内置了兼容层,会自动把 esbuild 和 rollupOptions 配置转换成 Rolldown / Oxc 等价物,多数项目零配置变更就能跑。
大项目 / 复杂项目:走两步。
// 第一步:Vite 7 上换成 rolldown-vite,隔离打包器变更
{
"devDependencies": {
"vite": "npm:rolldown-vite@^7.0.0"
}
}
// 第二步:确认无 Rolldown 相关问题后,回退 alias 并升到 8
{
"devDependencies": {
"vite": "^8.0.0"
}
}
这个两步法的价值在于问题归因:如果构建挂了,你能立刻知道是「打包器换了」导致的,还是「Vite 8 其他变更」导致的。项目越大,这个信息越值钱。
4.2 配置迁移对照表
依赖预构建
optimizeDeps 现在用 Rolldown 而不是 esbuild。兼容层自动转换:
| Vite 7 | Vite 8 |
|---|---|
esbuildOptions.minify | rolldownOptions.output.minify |
esbuildOptions.treeShaking | rolldownOptions.treeshake |
esbuildOptions.define | rolldownOptions.transform.define |
esbuildOptions.loader | rolldownOptions.moduleTypes |
esbuildOptions.preserveSymlinks | !rolldownOptions.resolve.symlinks(取反!) |
esbuildOptions.resolveExtensions | rolldownOptions.resolve.extensions |
esbuildOptions.mainFields | rolldownOptions.resolve.mainFields |
esbuildOptions.conditions | rolldownOptions.resolve.conditionNames |
esbuildOptions.keepNames | rolldownOptions.output.keepNames |
esbuildOptions.platform | rolldownOptions.platform |
esbuildOptions.plugins | rolldownOptions.plugins(部分支持) |
注意 preserveSymlinks 是取反关系,这种地方最容易在手工迁移时写错。
想确认兼容层到底转成了什么,写个探针插件:
const plugin = {
name: 'log-config',
configResolved(config) {
console.log('optimizeDeps:', config.optimizeDeps.rolldownOptions)
console.log('oxc:', config.oxc)
},
}
JS 转换:esbuild → oxc
| Vite 7 | Vite 8 |
|---|---|
esbuild.jsxInject | oxc.jsxInject |
esbuild.include / exclude | oxc.include / oxc.exclude |
esbuild.jsx: 'preserve' | oxc.jsx: 'preserve' |
esbuild.jsx: 'automatic' | oxc.jsx: { runtime: 'automatic' } |
esbuild.jsxImportSource | oxc.jsx.importSource |
esbuild.jsx: 'transform' | oxc.jsx: { runtime: 'classic' } |
esbuild.jsxFactory | oxc.jsx.pragma |
esbuild.jsxFragment | oxc.jsx.pragmaFrag |
esbuild.jsxDev | oxc.jsx.development |
esbuild.jsxSideEffects | oxc.jsx.pure |
esbuild.define | oxc.define |
esbuild.banner / footer | 自己写 transform 钩子插件 |
esbuild.supported 选项 Oxc 不支持,暂时没有等价物。
迁移前后的配置:
// Vite 7
export default defineConfig({
esbuild: {
jsxFactory: 'h',
jsxFragment: 'Fragment',
jsxInject: `import { h, Fragment } from 'preact'`,
define: { __VERSION__: '"1.0.0"' },
},
})
// Vite 8
export default defineConfig({
oxc: {
jsx: {
runtime: 'classic',
pragma: 'h',
pragmaFrag: 'Fragment',
},
jsxInject: `import { h, Fragment } from 'preact'`,
define: { __VERSION__: '"1.0.0"' },
},
})
弃用清单
build.rollupOptions → build.rolldownOptions
worker.rollupOptions → worker.rolldownOptions
build.commonjsOptions → no-op(Rolldown 原生支持 CJS)
build.dynamicImportVarsOptions.warnOnError → no-op
resolve.alias[].customResolver → 用 enforce: 'pre' 的 resolveId 插件替代
build.rollupOptions.watch.chokidar → build.rolldownOptions.watch.watcher
4.3 装饰器:目前唯一需要「退回 JS 工具」的场景
Oxc 转换器尚不支持原生装饰器的降级转换(在等规范推进)。如果你在用 Angular、NestJS 风格的装饰器,或者 MobX、TypeORM,需要临时挂回 Babel 或 SWC。
Babel 方案:
pnpm add -D @rolldown/plugin-babel @babel/plugin-proposal-decorators
// vite.config.ts
import { defineConfig } from 'vite'
import babel from '@rolldown/plugin-babel'
function decoratorPreset(options: Record<string, unknown>) {
return {
preset: () => ({
plugins: [['@babel/plugin-proposal-decorators', options]],
}),
rolldown: {
// 关键:只有包含 '@' 的文件才跑这个转换
filter: {
code: '@',
},
},
}
}
export default defineConfig({
plugins: [babel({ presets: [decoratorPreset({ version: '2023-11' })] })],
})
SWC 方案:
pnpm add -D @rollup/plugin-swc @swc/core
import { defineConfig, withFilter } from 'vite'
import swc from '@rollup/plugin-swc'
export default defineConfig({
plugins: [
withFilter(
swc({
swc: {
jsc: {
parser: { decorators: true, decoratorsBeforeExport: true },
transform: { decoratorVersion: '2023-11' },
},
},
}),
// 只在包含装饰器的文件上运行
{ transform: { code: '@' } },
),
],
})
这里的 filter 是性能命门。 把 Babel 挂上去等于在 Rust 流水线里插了一段 JS,如果不加 filter,每个文件都要跨 FFI 走一趟 Babel,你辛苦升上来的性能会瞬间还回去。code: '@' 这个过滤器在 Rust 侧做字符串预筛,只有真正含 @ 的文件才触发 JS 回调。
好消息是 emitDecoratorMetadata 在 Vite 8 里已经内置支持,不再需要外部插件。
4.4 插件作者需要改什么
如果你维护 Vite 插件,有几个点必须处理。
moduleType 自动推断
Rolldown 有实验性的 Module Types 支持(类似 esbuild 的 loader),会根据解析出的 id 的扩展名自动设置 module type。
如果你在 load 或 transform 里把其他类型的内容转成了 JS,必须显式标注:
const plugin = {
name: 'txt-loader',
load(id) {
if (id.endsWith('.txt')) {
const content = fs.readFileSync(id, 'utf-8')
return {
code: `export default ${JSON.stringify(content)}`,
moduleType: 'js', // ← 必须加,否则 Rolldown 按 .txt 处理
}
}
},
}
这是插件迁移里最高频的一个坑。
build() 抛 BundleError
JS API 用户注意:build() 现在抛 BundleError 而不是插件里的原始错误。
try {
await build()
} catch (e) {
if (e.errors) {
for (const error of e.errors) {
console.log(error.code) // 单个错误
}
}
}
类型是 Error & { errors?: RolldownError[] }。如果你有 CI 脚本在解析构建错误,这里要改。
并行钩子变串行
Rollup 里所有的 parallel hooks(buildStart、buildEnd、renderStart 等),在 Rolldown 里都按串行执行。
如果你的插件依赖多个插件的 buildStart 并发执行来抢占某个资源,或者靠并发来省时间,行为会变。
bundle 对象的变化
generateBundle / writeBundle 拿到的 bundle 对象:
// ❌ 不再支持直接赋值
bundle['extra.js'] = { ... }
// ✅ 用 emitFile
this.emitFile({ type: 'asset', fileName: 'extra.js', source: '...' })
// ❌ 报 DataCloneError
structuredClone(bundle)
// ✅ 先浅展开
structuredClone({ ...bundle })
而且 bundle 的引用在钩子之间不共享了——你在 generateBundle 里改的东西,writeBundle 里未必看得到。
被移除的钩子
以下 Rollup 钩子 Rolldown 不支持,Vite 8 也就不再支持:
shouldTransformCachedModuleresolveImportMetarenderDynamicImportresolveFileUrl
另外 parseAst / parseAstAsync 被弃用,改用功能更全的 parseSync / parse。
还有一个容易漏的:注释现在在 renderChunk 钩子之前被移除,而 Rollup 是之后。如果你的插件在 renderChunk 里靠注释做标记(比如某些 legal comment 处理、某些 banner 注入),逻辑会失效。
五、性能优化:数字和它们的成本
5.1 生产构建:真实项目数据
官方在 rolldown-vite 预览期和 beta 期收集的生产数据:
| 公司/项目 | 效果 |
|---|---|
| Linear | 生产构建从 46s → 6s |
| Ramp | 构建时间减少 57% |
| Mercedes-Benz.io | 最高减少 38% |
| Beehiiv | 减少 64% |
Linear 那个 46s → 6s 是接近 8 倍的提升,属于「改变工作流」级别——46 秒你会切窗口去刷别的,6 秒你会盯着看完。
要注意提升幅度差异很大(38% 到 87%),这取决于:
- 项目里 JS 转换 vs 插件逻辑的时间占比。如果你的构建时间大头在某个 JS 写的自定义插件上,换 Rust 打包器提升有限
- barrel 模块的使用密度(lazy barrel 的收益)
- 模块总数(Rust 的并行优势在大项目上才充分体现)
升级前先做一次构建 profiling,看看时间到底花在哪。 如果 70% 的时间在你自己写的一个正则替换插件里,那先优化插件比升 Vite 8 收益大。
5.2 Bundled Dev Mode:Vite 推翻了自己
这是 Vite 8.1(2026-06-23)最重磅的东西,也是最有戏剧性的:Vite 开始在开发态打包了。
为什么要打脸自己
Vite 的成名招式就是 no-bundle dev server。官方在 8.1 博客里对此的表述相当坦诚:
这个方案最初是一个实验,看看不用传统打包能把开发服务器性能推到什么边界。
然而,随着项目规模和复杂度增长,Vite 的 unbundled dev 方案会拖累开发性能。因为每个模块都单独请求,浏览器必须处理大量请求,这增加了启动和刷新开销。在大型应用上尤其明显,当开发者处于网络代理之后时更严重。
这个诚实值得尊敬。No-bundle 的数学模型很清楚:模块数 N,请求数 O(N)。N = 500 时 no-bundle 完胜打包;N = 10000 时,浏览器要处理一万个 HTTP 请求,每个都有解析、connection 复用、缓存校验的固定开销,这个常数项会把优势吃干净。
实测数据
一个加载 10000 个 React 组件的应用:
- 启动快约 15 倍
- 整页刷新快约 10 倍
- HMR 保持即时,不随应用规模劣化
Linear 团队的真实项目:
- 冷启动渲染快 3 倍
- 整页刷新快约 40%
- 网络请求数减少 10 倍
开启方式
vite --experimental-bundle
或者:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
experimental: {
bundledDev: true,
},
})
现状与边界
目前只聚焦浏览器端 + 基础插件 + 主要特性。官方明说:
- 第三方插件可能不工作
- 使用小众特性可能异常
它的设计目标是「兼得两者」:打包带来的快速启动 + 减少刷新时的网络开销,同时在 ESM 输出之上保持高效 HMR——这句是关键,它不是退回 Webpack 那种整包重编译,HMR 粒度仍然基于 ESM。
我的判断:如果你的项目模块数在几千以下,别急着开,收益不明显还要冒插件不兼容的风险。如果你在维护一个万级模块的大型 SPA,或者团队里有人天天抱怨「刷新一次要等五秒」,这个值得专门排期试一下。
5.3 Chunk Import Map:干掉 hash 级联
这是 8.1 另一个很聪明的实验特性。
问题:产物 chunk 的 import 语句里包含被导入 chunk 的 hash(为了内容变了能加载新版本)。但这导致——
utils.[e5f6 → 88xx].js ← 内容真的改了
↑ imports (embeds hash)
page.[c3d4 → 77yy].js ← 被级联重哈希(内容没改!)
↑ imports (embeds hash)
entry.[a1b2 → 99zz].js ← 被级联重哈希(内容没改!)
你改了一个底层工具函数,整条导入链上的所有 chunk hash 全变,用户全量重新下载。
解法:用 import map 把 chunk 名到实际带 hash 文件的映射抽出来,chunk 内部只引用稳定的逻辑名。这样只有真正改动的 chunk 换 hash,上游 chunk 内容不变、hash 不变、缓存命中。
这个特性建立在 Rolldown 的能力之上,Vite 层补了对 Vite 特有特性的支持。
注意:experimental.renderBuiltUrl 目前和这个选项不兼容。
5.4 其他调优点
build.target 默认值提升。'baseline-widely-available' 现在对应:
| 浏览器 | Vite 7 | Vite 8 |
|---|---|---|
| Chrome | 107 | 111 |
| Edge | 107 | 111 |
| Firefox | 104 | 114 |
| Safari | 16.0 | 16.4 |
对应 Baseline 在 2026-01-01 时的「widely available」集合,都是大约两年半前发布的版本。产物语法更新 = 体积更小、运行更快,但如果你有硬性的老浏览器要求,记得显式锁定 build.target。
CSS 压缩默认 Lightning CSS。语法降级做得比 esbuild 好,但产物体积可能略微增大。想切回去:
build: { cssMinify: 'esbuild' } // 需要自己装 esbuild 作为 devDependency
Vite 8.1 已经和 Lightning CSS 团队补齐了两个 PostCSS 才有的能力(CSS 中导入外部 CSS 文件、插件注册文件依赖),官方在考虑下个大版本把默认 CSS 预处理器也换成 Lightning CSS。可以提前用 css.transformer: 'lightningcss' 试。
resolve.tsconfigPaths 是有代价的。Vite 8 内置了 tsconfig 路径别名解析,但默认不开,因为有小幅性能开销:
resolve: { tsconfigPaths: true }
如果你原来用 vite-tsconfig-paths 插件,可以换成内置的——内置实现在 Rust 侧,比 JS 插件快。
JS 压缩选项迁移:
// 旧
esbuild: { minifyIdentifiers: true, drop: ['console'] }
// 新
build: {
rolldownOptions: {
output: {
minify: {
compress: { drop_console: true },
},
},
},
}
Oxc minifier 不支持属性混淆(mangleProps、reserveProps、mangleQuoted、mangleCache)。如果你在做体积极致优化并且依赖属性混淆,暂时留在 esbuild。
esbuild 和 Oxc 压缩器对源码做的假设略有不同。如果你怀疑压缩把代码搞坏了,对比两边的 assumptions 文档(esbuild 的 minify considerations 和 Oxc 的 ASSUMPTIONS.md)。
六、十六条踩坑清单
按「会不会在生产炸」排序。
1. CJS default 导入语义统一了——这是最容易炸的
现在的规则:满足以下任一条件,default 导入拿到的是 CJS 模块的 module.exports 本身;否则拿到 module.exports.default:
- importer 是
.mjs/.mts - importer 最近的
package.json的type是module - 被导入 CJS 模块的
module.exports.__esModule不是true
而 Vite 7 里,dev 和 build 的判定条件不一样(dev 多一个「importer 是否在依赖优化范围内」的前置条件,build 多一个「module.exports.default 是否存在」)。统一是好事,但会打破一些现有代码。
临时救急:
legacy: { inconsistentCjsInterop: true } // 已弃用,只是缓兵之计
正确做法是找到受影响的包,给作者提 issue 或 PR。
2. 移除了基于格式探测的模块解析
以前 package.json 里同时有 browser 和 module 字段时,Vite 会读文件内容判断,给浏览器挑 ESM 文件。现在不猜了,严格按 resolve.mainFields 顺序。
依赖旧行为的话:用 resolve.alias 映射,或者 pnpm patch / patch-package 打补丁。
3. 外部化模块的 require 调用被保留
不再自动转成 import,为了保住 require 的惰性求值语义。要转的话:
import { defineConfig, esmExternalRequirePlugin } from 'vite'
export default defineConfig({
plugins: [
esmExternalRequirePlugin({
external: ['react', 'vue', /^node:/],
}),
],
})
注意:插件必须拥有它转换的 externals——列在插件自己的 external 选项里,不是顶层的 external。
platform: 'node' 时 Rolldown 会用 module.createRequire 生成 require 函数,完整保留语义,但要求运行时支持 module.createRequire,且不适合期望被再次打包的库。
4. UMD / IIFE 里 import.meta.url 不再 polyfill
默认被替换成 undefined。需要的话用 define 配合 build.rolldownOptions.output.intro。
5. manualChunks 对象形式被删,函数形式弃用 — 见 3.2 节
6. 手动分包可能改变副作用执行顺序 — 见 3.2 节,用 strictExecutionOrder 兜底
7. export default X 会阻止 lazy barrel 优化 — 见 3.3 节
8. 插件 load/transform 返回非 JS 内容时要加 moduleType: 'js' — 见 4.4 节
9. Oxc 不支持原生装饰器降级 — 用 Babel/SWC 插件 + filter 兜底
10. plugin-legacy 不支持降级到 ES5 及以下
如果你还要支持 IE 或者非常老的移动端 WebView,Vite 8 暂时不适合你。
11. format: 'system' 和 format: 'amd' 不支持
Rolldown 目前没实现。用 SystemJS 做微前端的项目要注意。
12. build.target 传同一浏览器的多个版本现在报错
esbuild 会自动选最新的,这大概率不是你想要的,所以现在直接报错。
13. define 传对象时不共享引用
每个变量会拿到对象的一份独立拷贝。如果你靠 define 注入一个对象然后期望全局单例,行为变了。
14. Extglob 暂不支持
!(a|b).js 这类扩展 glob 语法在 import.meta.glob 等场景暂时不可用。
15. TypeScript legacy namespace 只部分支持
老代码里的 namespace Foo { ... } 可能有边界情况,详见 Oxc Transformer 文档。
16. "use strict" 有时不注入
Rolldown 的策略和 Rollup 不同。如果你的代码依赖严格模式下的行为差异(比如 this 在函数里是 undefined 而不是 globalThis),要验证。
另外:Node.js 要求 20.19+ 或 22.12+(和 Vite 7 一样,保证 require(esm) 无 flag 可用,Vite 才能纯 ESM 分发)。传 URL 给 import.meta.hot.accept 不再支持,改传 id。
七、被顺手带进来的几个小惊喜
Vite 8 除了 Rolldown 还塞了几个不显眼但很实用的东西:
集成 Devtools。devtools 选项开启 Vite Devtools,直接从 dev server 拿项目的深度分析数据。
浏览器 console 转发。server.forwardConsole 把浏览器的 console 日志和错误转发到 dev server 终端。官方明说这个是为 coding agent 设计的——AI 写代码时看不到浏览器运行时错误,转发到 CLI 输出之后 agent 就能看见了。检测到 coding agent 时自动激活。
这是一个信号:构建工具开始把「AI 是使用者之一」当成一等公民需求来设计了。
Wasm SSR 支持。.wasm?init 导入现在能在 SSR 环境工作。8.1 进一步支持了 Wasm ESM integration 提案:
import { add } from './add.wasm'
console.log(add(1, 2)) // 3
import.meta.glob 大小写不敏感匹配:
const modules = import.meta.glob('./dir/module*.js', {
caseSensitive: false, // 能匹配到 ./dir/Module1.js
})
自定义 HTML 元素/属性的资源发现:
html: {
additionalAssetSources: {
'html-import': { srcAttributes: ['src'] },
img: { srcAttributes: ['data-src-dark', 'data-src-light'] },
},
}
暗黑模式双图片方案终于不用写插件了。
@vitejs/plugin-react v6。用 Oxc 做 React Refresh 转换,Babel 不再是依赖,安装体积变小。需要 React Compiler 的项目用 reactCompilerPreset 配合 @rolldown/plugin-babel 显式 opt-in。v5 在 Vite 8 上仍然能用,可以先升 Vite 再升插件。
八、未来:还没做完的四件事
官方明确列了下一步方向:
Raw AST transfer。让 JS 插件能以极小的序列化开销访问 Rust 侧产生的 AST。这是当前最大的性能瓶颈——每次 JS 插件要看 AST,都得把 Rust 的数据结构序列化一遍传过去。做完之后,JS 插件和 Rust 内核之间的性能鸿沟会大幅收窄。
Native MagicString transforms。让自定义转换的逻辑写在 JS、字符串操作跑在 Rust。这个思路很妙:大多数简单转换的逻辑就是「找到位置、替换字符串」,逻辑判断在 JS 里跑没关系(次数少),但字符串拼接是热路径(次数多),下沉到 Rust 收益巨大。
稳定 Environment API。多环境(client / ssr / edge / worker)的一等公民抽象,生态已经开始定期开会协作。
Bundled Dev Mode 扩大支持面。目前只覆盖浏览器端和基础插件,官方在准备一份说明「插件侧需要做什么改动」的文档。
九、总结:这次升级到底值不值
从工程角度看,Vite 8 做对了三件事:
第一,它承认了旧架构的债并且真的还了。双打包器不是不能继续维护,但每年多一层胶水,五年之后就是无法收拾的局面。VoidZero 选择在生态最鼎盛的时候动刀,而不是等到实在维护不动。
第二,它用兼容性换取了迁移可行性。Rolldown 完全照搬 Rollup 的插件 API,Vite 8 内置兼容层自动转换配置。这不是技术上最优雅的选择——重新设计一套面向 Rust 的插件 API 肯定更快——但它是唯一能让几千个插件平滑过渡的选择。基础设施迁移的第一原则是不要让生态重写。
第三,它敢推翻自己的招牌。Bundled Dev Mode 等于承认「no-bundle 在大项目上不成立」。一个项目能公开否定自己最出名的设计决策,说明团队还在解决真问题,而不是在维护叙事。
从使用者角度,我的建议是:
- 中小项目(模块数 < 3000):可以升,主要收益是构建变快和工具链统一。直接
pnpm add -D vite@^8,跑一遍 CI,重点验证 CJS 依赖的 default 导入 - 大型项目:走两步迁移法,先
rolldown-vite再 Vite 8。升级前先 profiling 确认瓶颈在哪 - 有装饰器 / 需要 ES5 / 用 SystemJS 格式 / 依赖属性混淆:先别升,或者做好挂 Babel 补丁的准备
- 万级模块的大型 SPA:Bundled Dev Mode 值得专门排期做 POC,15 倍启动提升不是小数
要盯的一个风险:整个栈(Vite / Rolldown / Oxc / Lightning CSS)现在高度集中在 VoidZero 一家手上。好处是跨层优化能做到极致——用 Oxc 的语义分析改善 Rolldown 的 tree-shaking,这种事在多方协作下几乎不可能落地。代价是生态的多样性下降了。
这个权衡目前看是划算的,但值得长期观察。前端工具链在过去十年最健康的时候,恰恰是 Webpack、Rollup、Parcel、esbuild 互相竞争的时候。
最后一句给准备升级的人: 这次升级最大的风险不是性能回退,是 CJS 互操作语义变更。这类问题不会在构建时报错,会在运行时以 undefined is not a function 的形式出现在某个不常走的分支里。升级后请把 E2E 测试完整跑一遍,尤其是那些引用了老 CJS 包的路径。