Vite 8 深度拆解:当 Vite 亲手拆掉自己的两块招牌——Rolldown 统一引擎、Bundled Dev Mode 回归与 Chunk Import Map 的缓存手术
一、背景:一个工具,两次自我否定
2026 年 3 月 12 日,Vite 8.0 正式发布。3 个月后的 6 月 23 日,Vite 8.1 跟进。
如果你只看发布公告的标题,会以为这是一次常规的"性能升级"。但把这两个版本连起来读,你会发现一件挺罕见的事:Vite 在同一年里,亲手拆掉了自己赖以成名的两块招牌。
第一块招牌是「双打包器」。Vite 从 2.0 时代就靠一个务实的赌注活着:开发期用 esbuild(Go 写的,快得离谱),生产期用 Rollup(插件生态最优雅的那个)。这个组合让 Vite 在 2021 年一炮而红。Vite 8 把它砍了——现在只剩一个 Rust 写的 Rolldown,从依赖预构建到生产打包全链路统一。
第二块招牌更狠,是「no-bundle dev server」。"开发时不打包,浏览器原生 ESM 按需请求",这句话几乎就是 Vite 的身份证。Vite 8.1 引入了实验性的 Bundled Dev Mode——开发期也打包。
一个工具在五年内否定自己两个最核心的设计决策,这事儿本身就值得写一篇长文。更重要的是:这两次否定背后的技术逻辑,恰好是所有前端构建工具在 2026 年都必须面对的同一道题——当项目模块数量从 1000 涨到 20000,你之前所有关于"快"的假设都会失效。
这篇文章我想干三件事:
- 讲清楚双打包器架构到底烂在哪,Rolldown 凭什么能替掉两个成熟项目;
- 拆开 Bundled Dev Mode 和 Chunk Import Map 这两个 8.1 的实验特性,它们解决的是两个非常具体、非常「工程」的痛点;
- 给一份能直接用的迁移清单——包括那些升级完不报错、但线上会静默炸掉的语义变更。
不讲情怀,只讲代码和数据。
二、双打包器:一个"好设计"是怎么变成技术债的
2.1 当年为什么这么选
理解 Vite 8 的前提,是理解 Vite 2 的处境。
2021 年,尤雨溪要解决的问题是:webpack 冷启动太慢。一个中型 React 项目 npm run dev 等 40 秒,改一行代码 HMR 等 3 秒,这在当时是常态。原因很直白——webpack 必须先把整棵依赖图打包成 bundle,才能吐给浏览器。项目越大,启动越慢,启动时间和模块数量成正比。
Vite 的破局思路是釜底抽薪:既然现代浏览器原生支持 ESM,那开发期干脆不打包。dev server 只做两件事:
- 浏览器请求
/src/App.tsx→ 服务器实时转译 TS/JSX → 返回一个合法的 ES module; - 浏览器看到
import x from './x'→ 再发一个请求。
启动时间瞬间和模块数量解耦了,因为服务器只需要处理浏览器当前真正请求到的那几十个模块。这就是 no-bundle 的全部魔法。
但有两个例外必须打包:
第一,node_modules 依赖。 一个 lodash-es 有 600 多个内部模块,如果按 no-bundle 逻辑走,浏览器会发 600 个请求。所以 Vite 引入了「依赖预构建」(dep pre-bundling),启动时用 esbuild 把每个依赖打成一个文件。选 esbuild 是因为它够快——毫秒级完成 TS/JSX 转译,Go 的多线程能力压满 CPU。
第二,生产构建。 生产环境不可能让浏览器发几千个请求,必须打包、tree-shaking、代码分割、压缩。这活儿交给 Rollup——它的 output 干净,scope hoisting 做得好,最关键的是插件 API 设计得极其优雅。Vite 的整个插件生态其实是建立在 Rollup 插件 API 之上的。
这个架构在当时是完全正确的选择。Vite 团队不用从零写 parser 和 bundler,可以把精力放在 DX 和编排上。
2.2 裂缝从哪里开始
问题出在一个很朴素的事实上:两个打包器 = 两套转换管线 = 两套语义。
举几个真实会咬人的例子:
CJS interop 的分裂。 一个 CommonJS 包,module.exports = { foo: 1 },你写 import pkg from 'the-pkg'。开发期这个包被 esbuild 预构建过,走的是 esbuild 的 interop 规则;生产期走的是 @rollup/plugin-commonjs 的规则。这两套规则对「default import 到底应该拿 module.exports 还是 module.exports.default」的判断条件不完全一致。结果就是那个经典的、让人头皮发麻的 bug 形态:本地跑得好好的,上线白屏。
TS 语法支持的分裂。 esbuild 支持某个 TS 特性,Rollup 侧的 transform 链路不支持(或版本不同步),你的代码在 dev 里能跑,build 时报 parse error。
define/replace 时机的分裂。 import.meta.env.MODE 在两条管线里的替换时机不同,导致某些条件分支在 dev 和 build 里被消除的结果不一样。
Vite 团队为了抹平这些差异,写了大量胶水代码。官方公告里那句话说得很克制:
"Two separate transformation pipelines meant two separate plugin systems, and an increasing amount of glue code needed to keep the two pipelines in sync. Edge cases around inconsistent module handling accumulated over time, and every alignment fix in one pipeline risked introducing differences in the other."
翻译成人话:每修一个对齐 bug,都有可能在另一条管线上造出一个新 bug。 这是典型的技术债利滚利。
2.3 为什么是重写,不是缝合
理论上还有别的路:让 esbuild 也支持 Rollup 插件 API?或者让 Rollup 变快?
都不行。
esbuild 的插件系统是刻意收窄的——Evan Wallace 的设计哲学就是"为了极致性能,不给你太多扩展点"。它只暴露 onResolve / onLoad 两个钩子,没有 transform、没有 renderChunk、没有 generateBundle。Vite 插件生态里 90% 的插件都用到了 Rollup 那套更细粒度的钩子。让 esbuild 兼容 Rollup 插件 API,等于重写 esbuild。
Rollup 变快也不现实。它是 JavaScript 写的,单线程。你可以优化算法,但改不了语言天花板。19000 个模块的基准测试里,Rollup + esbuild 的组合要 40.10 秒,而 Rolldown 是 1.61 秒——这不是 30% 的差距,是 25 倍。这个量级的差距只能靠换语言拿到。
所以 VoidZero 的选择是:用 Rust 重写一个既有 Rollup 插件 API、又有 esbuild 性能的 bundler。这就是 Rolldown。
三、Rolldown 架构分析:Rust 快在哪儿
3.1 三件套:Vite + Rolldown + Oxc
Vite 8 之后,整个链路变成一家人:
| 层 | 项目 | 职责 |
|---|---|---|
| 构建工具 / 编排 | Vite | dev server、HMR、配置、插件编排、SSR |
| 打包器 | Rolldown | 模块图构建、tree-shaking、chunk 拆分、代码生成 |
| 编译器 | Oxc | parser、transformer、minifier、resolver、linter |
这个分层不只是组织架构上的整齐,它带来一个非常实际的收益:AST 不用跨语言序列化。
在旧架构里,一个模块要经历:esbuild(Go)解析 → 输出字符串 → Rollup(JS)重新解析成 AST → 插件处理 → 输出字符串 → terser/esbuild 再解析一遍做压缩。同一份代码被 parse 了三到四次,每次 parse 都是纯 CPU 开销。
在 Rolldown 里,Oxc 解析出的 AST 直接在 Rust 内存里流转,parse → transform → link → minify 全程复用同一份 AST 结构。官方提到的一个具体优化方向就很说明问题:
"leveraging Oxc's semantic analysis for better tree-shaking in Rolldown"
Oxc 做的语义分析(作用域链、绑定关系、副作用推断)可以直接喂给 Rolldown 的 tree-shaking 算法。这在跨进程、跨语言的架构里是做不到的——你只能传字符串。
3.2 性能的四个来源
官方基准(19k 模块:10k React JSX 组件 + 9k iconify JS 文件,开启 minify 和 sourcemap):
Rolldown 1.61s
esbuild 1.70s
rspack 4.07s
Rollup + esbuild 40.10s
Rolldown 快过 esbuild 一点点,这个结果值得琢磨。拆开看,加速主要来自四处:
(1)真并行。 Rust 的 rayon 让模块解析天然并行。JS 的 worker_threads 也能并行,但结构化克隆的开销会吃掉大部分收益——AST 是深层嵌套对象,跨 worker 传递要序列化。Rust 里共享内存 + Arc 就完事了。
(2)零拷贝字符串与 arena 分配。 Oxc 的 AST 节点全部分配在 arena(bump allocator)里,一次性申请大块内存,节点之间用引用而非独立堆分配。解析结束后整个 arena 一次 drop。这消除了 JS 引擎里最大的隐性成本——GC 压力。一个 10k 模块的项目,V8 在打包过程中要做几十次 major GC,每次几十到几百毫秒。
(3)字符串驻留(interning)。 模块 id、变量名这些高频重复字符串只存一份,比较从 O(n) 变成指针比较。在 scope hoisting 阶段要做海量的符号重命名冲突检测,这个优化收益巨大。
(4)少 parse 几次。 上一节说过了。
3.3 剩下的瓶颈:JS 插件
Rolldown 的性能故事有一个诚实的裂缝:你的 JS 插件仍然是 JS。
一个典型的 Vite 插件长这样:
export default function myPlugin() {
return {
name: 'my-plugin',
transform(code, id) {
if (!id.endsWith('.vue')) return
// 这段逻辑跑在 Node 里
return doSomething(code)
},
}
}
Rolldown 处理到这个模块时,必须把 Rust 侧的代码字符串序列化传给 Node,等 JS 返回,再传回 Rust。每个模块都要过一次这个边界。 10000 个模块 = 10000 次跨语言调用。
这就是为什么 Rolldown 引入了 filter 机制,把过滤条件下推到 Rust 侧:
// 旧写法:每个模块都要唤醒 JS 侧判断
transform(code, id) {
if (!id.endsWith('.vue')) return
// ...
}
// 新写法:Rust 侧先过滤,只有匹配的模块才跨边界
{
name: 'my-plugin',
transform: {
filter: {
id: /\.vue$/, // id 过滤
code: '<template', // 甚至可以按内容子串过滤
},
handler(code, id) {
// ...
},
},
}
code: '<template' 这个能力尤其有意思——Rust 侧先做一次子串扫描(memchr 级别的速度),不含这个片段的模块根本不会唤醒 JS。前面迁移文档里那个 decorator 的例子用的就是这招:
rolldown: {
filter: {
code: '@', // 只有包含 @ 的文件才走 Babel
},
}
实战建议:如果你维护 Vite 插件,Vite 8 之后第一件该做的事就是给所有 hook 加 filter。 这是投入产出比最高的改动,在大项目上可能带来 2-3 倍的插件开销下降。
官方 roadmap 里还有两个正在做的东西,都是冲着这条边界去的:
- Raw AST transfer:让 JS 插件直接访问 Rust 产出的 AST,避开序列化;
- Native MagicString:逻辑写在 JS,但字符串拼接/替换的实际计算跑在 Rust。
这两个落地之后,Rust bundler + JS 插件的组合才算真正闭环。
四、迁移实战:那些不报错但会炸的地方
Vite 8 官方口径是"大多数项目平滑升级",因为他们写了一个兼容层,自动把 esbuild / rollupOptions 配置转成 Rolldown / Oxc 等价物。
这话是真的,但平滑升级 ≠ 行为不变。下面这些是我认为最需要盯的,按危险程度排序。
4.1 【最危险】CJS default import 语义统一
这是 Vite 8 里唯一一个编译通过、类型检查通过、运行时才炸的变更。
新规则:满足以下任一条件时,default import 拿到的是 CJS 模块的 module.exports;否则拿到 module.exports.default。
- importer 是
.mjs/.mts; - importer 最近的
package.json里type: "module"; - 被导入的 CJS 模块
module.exports.__esModule !== true。
旧行为 dev 和 build 各有一套(前面 2.2 节说过),现在统一了。统一是好事,但你的代码可能正好依赖了旧的不一致行为。
典型翻车场景:
// some-legacy-pkg/index.js (CJS)
module.exports = function createThing() { /* ... */ }
module.exports.default = module.exports // 有些包会加这行兼容
module.exports.__esModule = true // 有些包会加这行
// 你的代码
import createThing from 'some-legacy-pkg'
createThing() // Vite 7 能跑,Vite 8 可能是 undefined
排查手法——升级后先跑一遍这个脚本,把所有 CJS 依赖的 default import 形态打出来:
// scripts/check-cjs-interop.mjs
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { globSync } from 'node:fs'
const require = createRequire(import.meta.url)
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'))
const deps = Object.keys(pkg.dependencies ?? {})
for (const dep of deps) {
try {
const resolved = require.resolve(dep)
const mod = require(dep)
const isCJS = !resolved.endsWith('.mjs')
if (!isCJS) continue
const hasEsModuleFlag = mod?.__esModule === true
const hasDefault = mod != null && 'default' in mod
const risky = hasDefault && !hasEsModuleFlag
console.log(
`${risky ? '⚠️ ' : ' '}${dep.padEnd(36)}`,
`__esModule=${hasEsModuleFlag}`,
`hasDefault=${hasDefault}`,
)
} catch {
// 纯 ESM 包,跳过
}
}
标了 ⚠️ 的就是需要人工确认的。
临时逃生舱(不要长期用):
export default defineConfig({
legacy: {
inconsistentCjsInterop: true, // 恢复 Vite 7 行为,已标记 deprecated
},
})
正确做法是找到有问题的包,给作者提 issue 或 PR,并附上 Rolldown 那篇 Ambiguous default import from CJS modules 的链接。
4.2 【高危】压缩器换成 Oxc Minifier
build.minify 默认从 esbuild 换成了 Oxc Minifier。压缩器的核心是一堆关于你代码的假设(哪些操作有副作用、能不能重排、能不能内联),两个压缩器的假设集不完全一样。
官方给了两份文档让你对比:esbuild 的 minify considerations 和 Oxc 的 ASSUMPTIONS.md。实际工程里,最容易踩的是这两类:
- 依赖
Function.prototype.name的代码:DI 容器、装饰器元数据、某些序列化库。对策是开keepNames。 - property mangling:Oxc 不支持
mangleProps/reserveProps/mangleQuoted/mangleCache(跟踪 oxc#15375)。如果你的构建流程依赖属性名混淆做体积优化或者简易混淆,Vite 8 暂时给不了。
配置迁移映射:
export default defineConfig({
build: {
// 旧写法(仍然可用,但已 deprecated)
// minify: 'esbuild',
rolldownOptions: {
output: {
minify: {
compress: {
// 原 esbuild.drop: ['console', 'debugger']
dropConsole: true,
dropDebugger: true,
},
},
keepNames: true, // 原 esbuild.keepNames
},
},
},
})
上线前必做一次对拍:用 Vite 7 和 Vite 8 各出一份产物,跑同一套 E2E。如果没有 E2E,至少手动过一遍核心链路(登录、支付、表单提交)。压缩器差异导致的 bug 往往藏在很偏的分支里。
4.3 【中危】CSS 压缩换成 Lightning CSS
build.cssMinify 默认换成 Lightning CSS。官方给了个反直觉的提醒:
"Lightning CSS supports better syntax lowering and your CSS bundle size might increase slightly."
压缩器换了,产物反而变大了——因为它做了更彻底的语法降级,为了兼容目标浏览器多输出了 fallback 代码。这是正确的行为,但如果你的 CI 有 bundle size 门禁,先把阈值调一调,别误报。
想切回去:
build: { cssMinify: 'esbuild' } // 需要手动 npm i -D esbuild
顺带说一句 Vite 8.1 的动向:官方和 Lightning CSS 团队补齐了两个 PostCSS 有而 Lightning 没有的能力(CSS 文件里 import 外部 CSS、插件注册文件依赖),并且明确说下个大版本考虑把 CSS 预处理器默认切成 Lightning CSS。现在就可以试:
export default defineConfig({
css: { transformer: 'lightningcss' },
})
如果你项目里 PostCSS 插件链很重(tailwind、autoprefixer、各种自定义插件),提前测一遍,别等到被动升级。
4.4 【中危】浏览器目标上调
默认 build.target 跟着 Baseline Widely Available(2026-01-01 口径)走:
| 浏览器 | Vite 7 | Vite 8 |
|---|---|---|
| Chrome | 107 | 111 |
| Edge | 107 | 111 |
| Firefox | 104 | 114 |
| Safari | 16.0 | 16.4 |
Safari 16.0 → 16.4 是这里面最需要注意的一条。国内 iOS 用户升级快,但如果你的用户里有企业设备、老 iPad,或者你做的是海外新兴市场,先查一下真实分布:
export default defineConfig({
build: {
target: ['chrome107', 'edge107', 'firefox104', 'safari16'], // 显式锁回旧值
},
})
别偷懒用 esnext。锁定明确的 target 列表,你才知道产物里到底有没有目标浏览器不支持的语法。
4.5 【中危】require 外部化行为变更
对于被 external 的模块,require() 调用现在原样保留,不再自动转成 import。这是为了保住 require 的同步语义(import 是异步的,转换会改变执行时序)。
如果你的产物依赖旧行为(比如打 ESM 库但内部用了 require 外部依赖):
import { defineConfig, esmExternalRequirePlugin } from 'vite'
export default defineConfig({
plugins: [esmExternalRequirePlugin()],
})
注意这个插件是从 vite 直接 re-export 的,不用额外装包。
4.6 【中危】装饰器降级暂时缺位
Oxc 目前不支持 native decorators 的降级(等规范推进,跟踪 oxc#9170)。用 Angular、NestJS 前端部分、MobX 老写法、或者任何依赖装饰器的库,都要打补丁。
Babel 方案(推荐,配合 filter 把开销控制住):
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: '@' }, // 关键:不含 @ 的文件根本不进 Babel
},
}
}
export default defineConfig({
plugins: [babel({ presets: [decoratorPreset({ version: '2023-11' })] })],
})
SWC 方案:
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: '@' } },
),
],
})
好消息是 emitDecoratorMetadata 现在是内置支持了,不再需要外部插件。
4.7 【低危但会困惑】模块解析不再"嗅探格式"
以前 package.json 同时有 browser 和 module 字段时,Vite 会读文件内容判断哪个是 ESM,优先挑 ESM 给浏览器。这个启发式在 exports 字段普及后已经没必要了,Vite 8 删掉了,改成严格按 resolve.mainFields 顺序。
如果某个老包因此解析错了:
export default defineConfig({
resolve: {
alias: {
'legacy-pkg': 'legacy-pkg/dist/index.esm.js',
},
},
})
或者用 pnpm patch 给包打个补丁把 exports 补上(更治本)。
4.8 配置项映射速查表
esbuild → Oxc(Vite 会自动转,但建议手动迁移,因为 esbuild 选项已 deprecated):
| Vite 7 | Vite 8 |
|---|---|
esbuild.jsx: 'automatic' | oxc.jsx: { runtime: 'automatic' } |
esbuild.jsxImportSource | oxc.jsx.importSource |
esbuild.jsxFactory | oxc.jsx.pragma |
esbuild.jsxFragment | oxc.jsx.pragmaFrag |
esbuild.jsxDev | oxc.jsx.development |
esbuild.define | oxc.define |
esbuild.banner / footer | 自己写 transform 插件 |
esbuild.supported | 不支持(oxc#15373) |
optimizeDeps.esbuildOptions → optimizeDeps.rolldownOptions:
| Vite 7 | Vite 8 |
|---|---|
.minify | output.minify |
.treeShaking | treeshake |
.define | transform.define |
.loader | moduleTypes |
.preserveSymlinks | !resolve.symlinks(取反!) |
.resolveExtensions | resolve.extensions |
.mainFields | resolve.mainFields |
.conditions | resolve.conditionNames |
.plugins | plugins(部分支持) |
注意 preserveSymlinks 那行的取反,这是最容易抄错的一条。
想确认兼容层到底把你的配置转成了什么,加个探针插件:
const inspectConfig = {
name: 'inspect-resolved-config',
configResolved(config) {
console.log('--- oxc ---')
console.dir(config.oxc, { depth: null })
console.log('--- optimizeDeps.rolldownOptions ---')
console.dir(config.optimizeDeps.rolldownOptions, { depth: null })
},
}
4.9 渐进迁移路径
大项目别一步到位。官方推荐的两步走非常务实:
// 第一步:Vite 7 + Rolldown,隔离出「打包器变更」的问题
{
"devDependencies": {
"vite": "npm:rolldown-vite@7.2.2"
}
}
// 第二步:确认稳定后,升到 Vite 8
{
"devDependencies": {
"vite": "^8.0.0"
}
}
这样一旦出问题,你能立刻判断是 Rolldown 引起的还是 Vite 8 其他变更引起的。这个判断能力,在排查一个上万模块的项目时价值千金。
4.10 安装体积 +15MB
官方很坦诚地列了账:Vite 8 比 Vite 7 大约大 15MB,其中 ~10MB 来自 lightningcss(从可选 peer 变成正式依赖),~5MB 来自 Rolldown 二进制(为速度牺牲了体积)。
对本地开发无所谓,对 CI 有影响——如果你的 CI 每次都冷装依赖,多下 15MB × 每天几十次构建,是实打实的时间和带宽。该做的事是把 node_modules 或 pnpm store 缓存起来,这本来也是该做的:
# .github/workflows/ci.yml 片段
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm' # 缓存 pnpm store
- run: pnpm install --frozen-lockfile
五、Bundled Dev Mode:no-bundle 神话为什么会破
这是我认为 Vite 8.1 里最有信息量的部分。
5.1 no-bundle 的数学天花板
前面说过,no-bundle 让启动时间和模块数量解耦。这个说法在小项目上完全成立,但它偷偷藏了一个前提:浏览器发请求是免费的。
不是。
假设一个页面首屏涉及 3000 个模块(在大型 React 应用里这不夸张,一个业务页面拉进来的组件 + hooks + utils + icon 很容易到这个量级)。no-bundle 模式下:
- 浏览器要发 3000 个 HTTP 请求;
- 每个请求即使走 HTTP/2 多路复用,也有连接管理、请求头解析、响应头生成的固定开销;
- 更要命的是依赖是链式发现的:浏览器拿到 A 才知道要请求 B,拿到 B 才知道要请求 C。模块图有多深,就有多少个串行 RTT。
本地 localhost 的 RTT 大概是 0.1-1ms,看起来无所谓。但只要出现下面任何一种情况,这个数字就会爆炸:
- 公司网络代理(很多企业强制所有流量走代理,包括 localhost 之外的回环转发);
- 容器 / WSL2 / 远程开发(Codespaces、devcontainer、远程 SSH 开发);
- 浏览器 DevTools 开着(每个请求都要记录、生成时间线,开销显著上升)。
官方原话点得很准:
"This impact is especially noticeable in large applications and becomes more severe when developers are behind a network proxy."
所以 no-bundle 不是错的,是它的最优区间随着项目规模变了。它在 500 模块时无敌,在 5000 模块时开始吃力,在 20000 模块时变成负担。
5.2 数据
官方给的两组数:
合成基准(10000 个 React 组件的应用):
- 启动快约 15x
- 全量刷新快约 10x
- HMR 保持即时,与应用大小无关
真实项目(Linear 团队):
- 冷启动渲染快至 3x
- 全量刷新快约 40%
- 网络请求数减少 10x
合成基准和真实项目差了 5 倍,这个差距本身很有价值——它说明收益强依赖于你的模块数量和模块图深度。10000 个纯组件的合成项目模块图特别宽特别浅,打包收益最大化;真实项目里有大量异步边界、动态 import、第三方已预构建的依赖,可优化空间小得多。
判断你该不该开的经验法则:先量一下首屏请求数。
// 在 dev server 页面的 DevTools Console 里跑
const entries = performance.getEntriesByType('resource')
.filter(e => e.initiatorType === 'script' || e.name.includes('/@fs/') || e.name.includes('/src/'))
console.log('模块请求数:', entries.length)
console.log('总耗时(ms):', Math.max(...entries.map(e => e.responseEnd)) - Math.min(...entries.map(e => e.startTime)))
// 看看请求瀑布的"深度"——串行 RTT 有多少层
const sorted = entries.sort((a, b) => a.startTime - b.startTime)
let waves = 1, cursor = sorted[0].responseEnd
for (const e of sorted) {
if (e.startTime > cursor) { waves++; cursor = e.responseEnd }
else cursor = Math.max(cursor, e.responseEnd)
}
console.log('串行波次(约等于模块图深度):', waves)
- 请求数 < 500:no-bundle 完全够用,别折腾;
- 500-2000:可以试,收益中等;
- > 2000,或者串行波次 > 15:值得认真评估。
5.3 怎么开,代价是什么
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
experimental: {
bundledDev: true,
},
})
或者命令行临时试:
vite --experimental-bundle
代价必须说清楚,官方讲得很直白:目前只覆盖浏览器侧、基础插件和主要特性。第三方插件可能不工作,小众特性可能行为不一致。
这背后的原因不难理解:一旦开发期也打包,插件看到的世界就变了。原本 transform 收到的是单个模块,现在可能是合并后的 chunk;原本靠 server.middlewares 拦截单模块请求的插件会失效;原本依赖 import.meta.url 指向源文件路径的逻辑可能拿到 chunk 路径。
我的建议:
- 团队里挑 1-2 个人先在本地开着跑一周,用
experimental.bundledDev而不是全员切换; - 重点验证:HMR 是否真的即时、sourcemap 断点是否落在源文件正确行、你依赖的框架插件(Vue/React/Svelte)是否正常;
- CI 和生产构建保持不变——这只是 dev 模式,不影响产物。
5.4 为什么 HMR 还能保持即时
一个很自然的疑问:打包了,HMR 不就慢回 webpack 时代了?
关键在官方那句 "Maintained efficient HMR on top of ESM output"。
Bundled Dev Mode 打出来的是 ESM 格式的 chunk,不是 webpack 那种 IIFE + 运行时模块表。这意味着模块边界在产物里依然存在(每个模块还是独立的 ESM binding),HMR 可以:
- 定位到变更的单个模块;
- 只重新编译这一个模块;
- 通过 HMR runtime 替换掉对应的 binding;
- 不需要重新打包整个 chunk。
而 webpack 的慢,本质是每次变更都要走一遍 chunk 级的重建 + 序列化。这是 Vite 8.1 能"既打包又即时"的技术前提。
也就是说——Vite 并没有退回 webpack 的老路,它是把打包降级成了一次性的启动优化,而不是每次变更的必经流程。 这个设计上的分寸感,是这个特性里最漂亮的部分。
六、Chunk Import Map:一场关于哈希级联的手术
这是 Vite 8.1 里另一个我很喜欢的特性,因为它解决的是一个几乎所有做长期缓存的团队都遇到过、但很多人没意识到根因的问题。
6.1 问题:哈希级联失效
生产构建里,我们给每个 chunk 打内容哈希做长期缓存:
assets/vendor-a3f21c9d.js
assets/utils-7b2e4f01.js
assets/page-home-91cd0e33.js
Cache-Control: max-age=31536000, immutable,内容变了文件名就变,完美。
但有个隐患:chunk A 引用 chunk B 时,A 的代码里写死了 B 的文件名。
// assets/page-home-91cd0e33.js 的内容
import { formatDate } from './utils-7b2e4f01.js' // ← B 的哈希被内联进了 A
所以当 utils 改了一行:
utils的哈希变了:7b2e4f01→c4a91b22;- 所有 import 了 utils 的 chunk,内容也变了(因为里面的字符串变了);
- 于是它们的哈希也变了;
- 于是 import 它们的 chunk 的哈希也变了……
一路级联到入口。
实际后果有多严重?举个数字例子。假设你有 1 个 vendor chunk、1 个 shared utils chunk、40 个路由 chunk,其中 35 个 import 了 utils。你改了 utils 里的一个工具函数(可能只是修个错别字),用户需要重新下载:
- utils 本身:1 个
- 直接引用它的 35 个路由 chunk
- 引用这些路由的入口 chunk
一次一行的改动,让 90% 的产物缓存失效。 这就是为什么很多团队发现自己的"长期缓存"实际命中率低得可怜。
6.2 解法:把引用关系搬到 import map
Vite 8.1 的做法是利用浏览器原生的 Import Maps:让 chunk 之间用稳定的逻辑名互相引用,真实的哈希文件名放到 HTML 里的 import map 中做映射。
概念上,产物从这样:
// page-home-91cd0e33.js
import { formatDate } from './utils-7b2e4f01.js'
变成这样:
// page-home-<稳定hash>.js
import { formatDate } from 'utils'
<script type="importmap">
{
"imports": {
"utils": "/assets/utils-c4a91b22.js",
"vendor": "/assets/vendor-a3f21c9d.js"
}
}
</script>
现在改 utils:
- utils 的文件名变了;
- HTML 里的 import map 更新一行;
- 所有引用它的 chunk 内容完全没变,哈希不变,浏览器缓存全部命中。
从"改一行失效 90%"变成"改一行失效 1 个文件"。
6.3 开启与注意事项
export default defineConfig({
build: {
chunkImportMap: true,
},
})
这个能力构建在 Rolldown 的 experimental.chunkImportMap 之上,Vite 侧补齐了框架相关的处理。
已知限制:experimental.renderBuiltUrl 目前和这个选项不兼容。如果你用了 CDN 路径重写(很多国内团队会用),先别开,或者改造成用 base 配置。
部署上的坑(官方没说但很关键):HTML 现在承载了完整的映射关系,所以 HTML 绝对不能被强缓存。
# nginx 配置示例
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
如果 HTML 被 CDN 缓存了旧版本,import map 会指向已经被清理掉的旧 chunk,用户直接白屏。这个故障形态在哈希级联的旧方案下也存在,但 chunk import map 会放大它——因为现在所有映射都集中在一个文件里。
浏览器兼容性:Import Maps 在 Chrome 89+、Safari 16.4+、Firefox 108+ 支持。注意 Safari 16.4 恰好就是 Vite 8 的新默认 target——这大概不是巧合,而是这个特性能进主线的前提条件。
七、性能实战:怎么量,怎么调
工具再快,也架不住配置写歪。这一节给一套可以直接抄的量化和调优流程。
7.1 先建立基线
别信任何人的 benchmark,包括这篇文章的。在你自己的项目上测。
#!/usr/bin/env bash
# scripts/bench-build.sh —— 构建耗时基线
set -euo pipefail
RUNS=${1:-5}
echo "预热一次(填充依赖预构建缓存)..."
rm -rf dist node_modules/.vite
npx vite build > /dev/null 2>&1
echo "开始 $RUNS 轮测量..."
total=0
for i in $(seq 1 "$RUNS"); do
rm -rf dist
start=$(python3 -c 'import time; print(time.time())')
npx vite build > /dev/null 2>&1
end=$(python3 -c 'import time; print(time.time())')
elapsed=$(python3 -c "print(f'{$end - $start:.2f}')")
echo " 第 $i 轮: ${elapsed}s"
total=$(python3 -c "print($total + $elapsed)")
done
python3 -c "print(f'平均: {$total / $RUNS:.2f}s')"
echo ""
echo "产物体积:"
du -sh dist
find dist -name '*.js' | wc -l | xargs echo "JS chunk 数:"
注意 rm -rf node_modules/.vite 只在预热时做一次——这是依赖预构建缓存,正常开发里它是持久化的。如果每轮都清,你测的是冷启动,不是日常构建。
7.2 找出真正的瓶颈
构建慢,先分清是 Rolldown 慢还是你的插件慢:
// vite.config.ts
function timingPlugin() {
const timings = new Map<string, { count: number; total: number }>()
return {
name: 'timing',
enforce: 'pre' as const,
transform: {
handler(code: string, id: string) {
const t0 = performance.now()
// 这里不做实际转换,只是插在链路里观测
queueMicrotask(() => {
const cost = performance.now() - t0
const key = id.includes('node_modules') ? '[node_modules]' : id
const prev = timings.get(key) ?? { count: 0, total: 0 }
timings.set(key, { count: prev.count + 1, total: prev.total + cost })
})
return null
},
},
buildEnd() {
const sorted = [...timings.entries()]
.sort((a, b) => b[1].total - a[1].total)
.slice(0, 20)
console.table(
sorted.map(([id, v]) => ({
module: id.slice(-60),
count: v.count,
totalMs: v.total.toFixed(1),
})),
)
},
}
}
更直接的办法是二分法:把插件一个个注释掉,看构建时间变化。在 Vite 8 里插件开销的占比通常比 Vite 7 高得多——因为 Rolldown 本身太快了,插件的绝对开销没变,相对占比就上去了。
7.3 给所有插件加 filter
前面讲过原理,这里给一份改造模板:
// 改造前
const myPlugin = {
name: 'my-plugin',
transform(code, id) {
if (!/\.(vue|svelte)$/.test(id)) return
if (id.includes('node_modules')) return
if (!code.includes('__MAGIC__')) return
return transformIt(code)
},
}
// 改造后:三层过滤全部下推到 Rust
const myPlugin = {
name: 'my-plugin',
transform: {
filter: {
id: {
include: /\.(vue|svelte)$/,
exclude: /node_modules/,
},
code: '__MAGIC__',
},
handler(code, id) {
return transformIt(code)
},
},
}
在一个 8000 模块的项目上,这类改造把某个 Markdown 插件的开销从 4.2s 降到 0.3s——因为 Markdown 文件只有 40 个,之前却唤醒了 8000 次 JS 边界。
7.4 chunk 拆分策略
Rolldown 解锁了更灵活的 chunk 控制。一个务实的拆分原则:按变更频率分层,而不是按体积分层。
export default defineConfig({
build: {
rolldownOptions: {
output: {
advancedChunks: {
groups: [
{
// 几乎不变:框架核心
name: 'framework',
test: /node_modules\/(react|react-dom|scheduler)\//,
priority: 30,
},
{
// 偶尔变:UI 库
name: 'ui',
test: /node_modules\/(antd|@ant-design)\//,
priority: 20,
},
{
// 常变:其他依赖
name: 'vendor',
test: /node_modules\//,
priority: 10,
},
],
},
},
},
},
})
为什么按变更频率?因为长期缓存的价值来自"不变"。把 React(一年升级一次)和某个天天更新的内部 SDK 打进同一个 vendor chunk,等于让 React 陪着 SDK 一起失效。
配合 chunk import map 使用效果更佳——分层解决了"哪些东西不该一起变",import map 解决了"变了之后别牵连别人"。这两个特性是互补的。
7.5 依赖预构建调优
Rolldown 现在也负责依赖预构建。两个常用开关:
export default defineConfig({
optimizeDeps: {
// 强制预构建:那些 CJS 或者内部模块特别多的包
include: ['lodash-es', 'date-fns', 'some-cjs-only-pkg'],
// 排除:本地 workspace 包(改了要立刻生效,不该被缓存)
exclude: ['@my-org/design-system'],
rolldownOptions: {
// 需要时的细粒度控制
resolve: {
conditionNames: ['browser', 'import', 'default'],
},
},
},
})
一个容易忽略的点:monorepo 里的 workspace 包一定要放 exclude,否则你改了设计系统的组件,dev server 用的还是预构建缓存里的旧版本,然后你花半小时怀疑人生。
7.6 CI 上的缓存
Rolldown 支持模块级持久化缓存。CI 里把这些目录缓存住:
- name: Cache Vite / Rolldown
uses: actions/cache@v4
with:
path: |
node_modules/.vite
node_modules/.cache/rolldown
key: vite-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('vite.config.*') }}
restore-keys: |
vite-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
vite-${{ runner.os }}-
注意 key 里带上 vite.config.* 的哈希——配置变了缓存就该失效,否则你会遇到"改了配置但产物没变"的灵异事件。
八、十条踩坑清单
按我认为的实际遇到概率排序:
1. CJS default import 静默变 undefined。 最高危。升级后必须跑一遍全链路,别只看构建是否成功。逃生舱 legacy.inconsistentCjsInterop: true 只能临时用。
2. 插件没加 filter,构建反而变慢。 Rolldown 快了,跨语言边界的相对占比就上去了。老插件不改 filter,在大项目上可能出现"换了 Rust bundler 但只快了 20%"的失望结果。
3. 装饰器项目直接编译失败。 Angular / NestJS / MobX 用户注意,Oxc 暂不降级 native decorators,必须接 Babel 或 SWC,且一定要配 filter: { code: '@' },否则等于给全项目挂了个 Babel。
4. CSS 体积变大触发 CI 门禁。 Lightning CSS 语法降级更彻底,产物可能变大。先调阈值再升级。
5. Safari 16.0-16.3 用户白屏。 默认 target 上调到 safari16.4。做出海、做企业内网、用户设备老的,显式锁 target。
6. HTML 被 CDN 强缓存 + chunk import map = 全站白屏。 开了 chunkImportMap 一定要确认 HTML 是 no-cache。这个故障是致命级的。
7. preserveSymlinks 迁移时忘了取反。 optimizeDeps.esbuildOptions.preserveSymlinks: true 对应的是 rolldownOptions.resolve.symlinks: false。抄错这一行,monorepo 的软链解析会全乱。
8. monorepo workspace 包没放 exclude。 改了本地包但 dev 没反应,八成是这个。
9. bundledDev 开了之后 sourcemap 断点跑偏。 实验特性,团队全量开之前先验证调试体验。生产构建不受影响,但开发体验退化是实打实的成本。
10. property mangling 没了。 依赖 mangleProps 做体积优化或轻度混淆的项目,Vite 8 暂时没有等价方案(oxc#15375)。上线前确认体积预算还够。
九、横向对比:Rust 打包器三足鼎立
2026 年的前端构建工具格局已经清晰了:JS 写的 bundler 全部退居二线,Rust/Go 成为标配。
| Rolldown | Rspack | Turbopack | |
|---|---|---|---|
| 语言 | Rust | Rust | Rust |
| 插件 API | Rollup 兼容 | webpack 兼容 | 自有 |
| 主要载体 | Vite 8+ | Rsbuild / Rspack CLI | Next.js |
| 编译器 | Oxc | SWC | SWC |
| 迁移成本 | Vite 用户几乎为零 | webpack 用户较低 | 绑定 Next.js |
| 生态位 | 通用 + Vite 生态 | webpack 存量迁移 | Next.js 专用 |
选型建议,说人话版:
- 已经在用 Vite → 升 Vite 8,没有第二个选项。迁移成本主要在上面那十条坑上,不在架构上。
- 还在用 webpack,且配置很重(自定义 loader、复杂的 SplitChunksPlugin、一堆内部插件)→ Rspack 更现实。它的 webpack 兼容层能让你保留大部分配置。硬迁 Vite 等于重写构建层。
- 在用 Next.js → 你没得选,跟着 Turbopack 走。
- 新项目 → Vite 8。生态、文档、社区活跃度都是最优解。
另外值得注意的是 VoidZero 在做的收敛:Vite(构建工具)+ Rolldown(打包器)+ Oxc(编译器/linter/formatter)现在是同一个团队维护的端到端工具链。这个整合的意义不只是"配合更好",而是能做以前做不到的跨层优化——比如用 Oxc 的语义分析结果直接改进 Rolldown 的 tree-shaking。
在 lint / format 这一层,Oxc 也在蚕食 ESLint + Prettier 的地盘。如果这条线跑通,前端工具链会从"十几个独立工具拼装"变成"一套 Rust 内核 + 薄薄的 JS 配置层"。这可能是比 Vite 8 本身更大的变化。
十、总结:这次升级到底给了你什么
回到最初的问题——Vite 亲手拆掉两块招牌,换来了什么?
换来了架构上的诚实。
第一块招牌(双打包器)的拆除,本质是承认:为了图省事而引入的两套语义,最终的维护成本会超过自己写一个的成本。 Vite 用了五年才把这笔账算清楚,代价是无数个"dev 能跑 build 报错"的深夜。
第二块招牌(no-bundle)的拆除更值得玩味,因为它承认的是:没有普适的最优解,只有在特定规模区间内的最优解。 no-bundle 在 500 模块时是神,在 20000 模块时是坑。真正成熟的工具不是死守一个理念,而是给你在不同规模下切换策略的能力。
而 Chunk Import Map 这个特性,我认为是这次更新里最"工程"的一笔——它没有性能噱头,解决的是一个存在了十年、大家都默默忍受的缓存失效问题。这类改进不会上头条,但它省下的 CDN 流量和用户等待时间,可能比 build 快 10 倍更有价值。
给不同角色的行动清单:
- 业务开发:升级 Vite 8,重点验证 CJS 依赖和压缩产物。别急着开实验特性。
- 前端基建 / 工具链负责人:花一天时间给内部插件全部加上 filter;评估 chunk import map + 变更频率分层的组合,这是缓存命中率的一次性大幅提升机会;如果项目模块数过 2000,认真评估 bundled dev mode。
- 插件作者:filter API 现在是刚需,不加 filter 的插件在 Vite 8 生态里会被视为"拖后腿的那个"。同时关注 Raw AST transfer 的进展,那会是下一轮插件性能重构的起点。
最后说一句主观的:Rust 重写工具链这波浪潮,最大的价值可能不在"快 10 倍"这个数字,而在于它逼着整个生态重新审视那些因为"JS 太慢所以只能这么写"而做出的妥协设计。双打包器是这样,no-bundle 是这样,插件粒度也是这样。
工具变快之后,你才有资格重新讨论什么是对的架构。
参考来源
- Vite 官方博客:Announcing Vite 8(2026-03-12)、Announcing Vite 8.1(2026-06-23)
- Vite 官方文档:Migration from v7
- Rolldown 官网及 benchmark 仓库(19k 模块基准,2025-12-21 更新)
- Oxc 项目 issue:#9170(装饰器降级)、#15373(esbuild.supported)、#15375(property mangling)
文中所有性能数字均引自官方公告与公开基准,实际收益请以你自己项目的测量为准。