Vite 用上 Rolldown 之后:一次构建提速的真实体感和注意点
我们这个后台管理系统大概有 1800 多个模块,是个典型的 Vite + Vue 3 + TypeScript 项目。上线两年多,依赖越堆越多,最近半年 CI 上的 vite build 稳定在 3 分钟以上,本地全量构建也要将近 2 分钟。最难受的不是慢,而是慢得没道理——dev 环境秒开,生产构建却像在另一个世界。这件事的根子,其实是 Vite 一直以来的"两套引擎"。
先说清楚 Vite 一直背着的那个包袱
用过 Vite 的人都知道它快,但很少有人留意它 dev 和 build 用的根本不是同一个东西。
dev 阶段,Vite 靠浏览器原生 ESM,预构建依赖用的是 esbuild;源码的 TS/JSX 转译也是 esbuild。esbuild 是 Go 写的,快得离谱,但它只做转译和很轻量的 bundle,不做完整的打包优化。
build 阶段就换人了,走的是 Rollup。Rollup 是 JS 写的,tree-shaking 和产物质量都很好,但速度跟 esbuild 不是一个量级。
于是你就有了一个尴尬的局面:
- 两套代码路径,行为不可能完全一致。我就踩过一次 dev 下好好的代码,build 出来运行时报错,最后定位是 esbuild 和 Rollup 对某个 CommonJS 模块的 interop 处理不一样。
- dev 体验越好,build 的反差越明显。模块多了之后,dev 还是秒级,build 却线性增长。
- 调一个构建相关的 bug,你得同时理解 esbuild 的行为和 Rollup 的行为。
Vite 团队心里很清楚这事不优雅。所以才有了 Rolldown。
Rolldown 是什么,以及它想解决什么
Rolldown 是用 Rust 写的打包器,API 层面对齐 Rollup(插件接口、配置基本兼容),底层做了完整的 bundle 能力和 tree-shaking。它的定位很明确:用一个 Rust 引擎,把 Vite 里 esbuild 干的转译、依赖预构建,和 Rollup 干的生产打包,全部统一掉。
也就是说,Vite 的最终形态是:dev 和 build 都跑在 Rolldown 上,再也没有"两套引擎不一致"这回事。转译这块,Rolldown 内部用的是 oxc(同样是 Rust 写的 JS/TS 工具链),把原来 esbuild 负责的解析、转译都接了过来。
对我这种项目来说,最直接的诱惑就是构建速度。Rust 的并行打包,对着 1800 个模块这种量级,理论上能有明显改善。所以我决定在一个分支上试一下。
接入:其实就是把 vite 换成 rolldown-vite
现在还没到 Vite 默认用 Rolldown 的阶段,过渡方案是一个叫 rolldown-vite 的包。它是 Vite 的一个分发版本,API 和普通 Vite 完全一致,只是底层换成了 Rolldown。接入方式不是改 import,而是用包管理器的 overrides 把 vite 这个依赖整个替换掉。
pnpm 的话,在 package.json 里加:
1{ 2 "pnpm": { 3 "overrides": { 4 "vite": "npm:rolldown-vite@latest" 5 } 6 } 7}
npm 是:
1{ 2 "overrides": { 3 "vite": "npm:rolldown-vite@latest" 4 } 5}
yarn 用 resolutions:
1{ 2 "resolutions": { 3 "vite": "npm:rolldown-vite@latest" 4 } 5}
这么做的好处是:你的代码里 import { defineConfig } from 'vite' 一行都不用改,所有依赖 vite 的插件拿到的也是 rolldown-vite。装完跑一下:
1pnpm install 2pnpm build
如果 build 跑通了,恭喜,你已经在用 Rolldown 了。想确认的话,构建日志里会打出 rolldown-vite 的版本号。
我第一次切的时候,install 之后直接 build,结果在一个插件那里炸了——后面会讲。先说速度。
构建速度:体感和数字
我在同一台 M2 Pro 的开发机上,对同一个 commit 分别用原版 Vite 和 rolldown-vite 跑了几次全量 build,关掉 sourcemap 之外的配置都一样。
原版 Vite(Rollup)的 build,大概在 105 到 115 秒之间。rolldown-vite 稳定在 30 到 38 秒。差不多是三倍多的差距。
CI 上因为机器弱,绝对值更大,原版接近 3 分半,Rolldown 压到了 1 分出头。对一个一天要跑十几次 CI 的团队来说,这个体感是实打实的——以前提了 PR 去倒杯水回来流水线还在转,现在基本是刷新一下就好了。
需要泼一点冷水的地方:
- 这是全量 build 的数字。如果你的瓶颈在别的环节(比如类型检查
vue-tsc、ESLint),换 Rolldown 不会救你。我们的 CI 里vue-tsc单独占了 40 秒,那部分纹丝不动。 - 小项目(几百个模块以内)的提升没那么戏剧化,因为原本 Rollup 也没慢到哪去。模块越多、依赖越复杂,Rolldown 的优势越明显。
- 第一次构建因为要建缓存,会比后续慢一些,别拿冷启动那次的数字说事。
为什么慢的总是 build,先理解一下瓶颈
在动手换引擎之前,我先花了点时间搞清楚我们的 build 到底慢在哪,免得换了个工具却没对症。
我用 vite build --debug 配合在 CI 里打时间戳,把整个构建拆成几段看:依赖解析、转译、打包、tree-shaking、压缩。结论是大头落在打包和 tree-shaking 这两段,也就是 Rollup 负责的部分,随着模块数量近乎线性增长。转译那部分(esbuild 干的)其实很快,压缩(之前用 esbuild minify)也不慢。
这正好印证了"换 Rolldown 有用"的前提——我的瓶颈恰好是 Rollup 那一段。如果你测下来瓶颈在压缩、在 sourcemap 生成、在某个慢插件的 transform 上,那换引擎收益就有限。所以我特别想强调:先量再换。 别看到"Rust 三倍提速"就无脑上,先确认你的时间花在哪。
顺便说一句,量化这件事本身也有附带价值——我在测的过程中发现一个内部插件在每个模块上都跑了一次很重的正则,单这一项就吃掉了十几秒。这个问题跟 Rolldown 无关,但不量根本发现不了。
为什么不是直接用 esbuild 打生产包
有人会问:esbuild 那么快,Vite 为什么不干脆 build 也用 esbuild,非要等 Rolldown?我自己一开始也这么想,查了一圈才明白。
esbuild 的设计目标是"快",它确实可以打生产包(esbuild --bundle),但在几个对大型应用很关键的点上不够:
- 代码分割(code splitting)能力有限。esbuild 的 chunk 拆分策略比较粗,做不到 Rollup 那种细粒度、可配置的
manualChunks。大型 SPA 高度依赖精细的分包来控制首屏体积和缓存命中率,这点 esbuild 不够用。 - 插件生态不兼容 Rollup。Vite 整个生态——从官方插件到社区插件——都是围绕 Rollup 的插件接口建起来的。如果 build 换成 esbuild,意味着这套插件全得重写,迁移成本是灾难性的。
- tree-shaking 和产物优化的精细度不如 Rollup。
Rolldown 的聪明之处就在这:它选择对齐 Rollup 的接口和能力,而不是另起炉灶。这样既保住了整个插件生态,又用 Rust 拿到了接近 esbuild 的速度。换句话说,Rolldown 想做的是"快得像 esbuild、兼容得像 Rollup"。理解了这个定位,就明白为什么过渡方案是把 vite 替换成 rolldown-vite 而不是改一堆配置——它的目标就是让你几乎无感地继承现有生态。
dev 阶段的 full bundle 模式
build 提速是预期内的,让我意外的是 dev 的变化。
传统 Vite dev 是"按需"的:浏览器请求哪个模块,Vite 才转译哪个,依赖则提前用 esbuild 预构建好。这套机制在中小项目里非常爽,冷启动快。但模块一多就会暴露问题——首屏要请求成百上千个模块,每个都是一个 HTTP 请求,浏览器的网络面板能给你刷出一屏瀑布流,页面交互前的白屏时间被拉长。
Rolldown 给 dev 带来了一个 full bundle 模式的方向:不再让浏览器逐个请求散落的模块,而是在 dev 阶段也用 Rolldown 做一次足够快的打包,减少请求数量。对我们这种大项目,首屏请求数从一千多降到几十个,刷新页面明显更跟手。
这块目前还在演进,不同版本默认行为会变,所以我没在团队里大张旗鼓地推 dev 模式的改动,主要还是吃 build 的红利。如果你的项目 dev 首屏请求数没那么夸张,这部分收益感知不强。
依赖预构建(optimizeDeps)的变化
dev 启动时 Vite 会做依赖预构建,把 node_modules 里的 CommonJS、UMD 依赖转成 ESM 并合并请求。原版用 esbuild 干这件事,Rolldown 下这块也由它接管。
实际体感是首次冷启动的预构建变快了一点,但更要留意缓存行为。预构建结果缓存在 node_modules/.vite 下,切换 Rolldown 后这个缓存的格式和原版不兼容,所以第一次 dev 会强制重新预构建一次,慢一点是正常的,别误以为是 Rolldown 变慢了。
我还遇到一个小坑:之前手动配过 optimizeDeps.include 把几个有问题的依赖强制预构建。切 Rolldown 后其中一个依赖其实已经不需要强制 include 了,留着反而触发了一次不必要的处理。迁移时顺手把 optimizeDeps 的配置重新审视一遍,能删的删,是个好习惯。如果你遇到某个依赖在 dev 下报 ESM/CJS 相关的错,优先试试把它加进 optimizeDeps.include,这个调试手段在 Rolldown 下依然有效。
兼容性:大部分插件没事,但确实有坑
Rolldown 的卖点之一是兼容 Rollup 插件接口和 Vite 插件接口。实际用下来,绝大多数常见插件——@vitejs/plugin-vue、@vitejs/plugin-vue-jsx、vite-plugin-svg-icons、unplugin-auto-import、unplugin-vue-components 这些——都能直接跑。
但"接口兼容"不等于"行为完全一致"。我遇到的几个具体问题:
1. 一个老 CommonJS 依赖的 interop 差异
我们用了一个比较老的图表库,发布的是 CommonJS 格式,内部还有 module.exports = ... 加上挂属性的那种混合写法。原版 Vite 下靠 @rollup/plugin-commonjs 处理得好好的,切到 Rolldown 后,import Chart from 'xxx' 拿到的 default 变成了一个带 default 嵌套的对象,运行时 Chart is not a constructor。
Rolldown 内置了 CommonJS 处理,逻辑和 @rollup/plugin-commonjs 不完全一样,对这种非标准导出的猜测策略有差异。我的临时解法是改成 import * as ChartNs 再取,长期方案是把这个老库换掉。
2. 插件 hook 的执行时机/行为差异
有个团队内部写的 Vite 插件,在 transform hook 里依赖了 this.getModuleInfo() 返回的某些字段。Rolldown 的插件上下文实现了大部分 Rollup 的 hook,但个别字段在某些时机是 undefined,跟 Rollup 不一致,导致插件逻辑走偏。这种就得看插件源码具体改。
3. 依赖 esbuild 特定 API 的配置
如果你在 optimizeDeps.esbuildOptions 或者用了直接调 esbuild 的插件做特殊转译,要注意 Rolldown 下转译由 oxc 接管,esbuild 不再是必经之路。oxc 和 esbuild 在一些边界语法、装饰器、define 替换的细节上有出入。我们项目里用了 define 注入全局常量,迁移时有一处 define 的值没按预期内联,排查了一会儿才发现是写法对 oxc 不够友好(值要是合法的 JS 表达式字符串)。
总体感受:业务代码和主流插件基本无感,越是"老""偏""自己手写"的东西越容易出问题。 出问题的概率和你项目里非标准依赖的数量正相关。
4. monorepo 里 overrides 的作用范围
我们是 pnpm workspace 的 monorepo,有好几个子包都用 Vite。一开始我以为在某个子包的 package.json 里加 overrides 就行,结果发现 pnpm 的 overrides 必须写在 workspace 根的 package.json 里才生效,写在子包里被忽略。这导致我排查了半天"为什么没换上 Rolldown"。
如果你想只让其中一个子包用 Rolldown、其他保持原版,单纯 overrides 做不到(它是全局替换)。我的办法是先在根上全局切,让所有子包一起验,反正回退也方便。要做更细粒度的控制就得给子包单独锁一份自己的依赖版本,对大多数项目没必要。
oxc 取代 esbuild,对日常意味着什么
esbuild 退到幕后,转译换成 oxc,这件事对大多数人是透明的,但有几个点值得知道:
- 语法支持:oxc 对现代 TS/JSX 的支持很全,日常写代码感知不到区别。装饰器、
enum、namespace这类 TS 特性也都覆盖。 - 错误信息:解析报错的格式变了,oxc 的报错和 esbuild 风格不同。如果你的脚本里正则匹配过 esbuild 的报错文本,会失效。
- target 降级:把代码降到旧浏览器的逻辑也由 oxc 处理,行为以 oxc 为准,个别 polyfill/语法降级的边界要回归测一下。
我建议迁移后专门跑一遍 E2E,尤其覆盖那些用了较新语法或装饰器的页面,别只看构建有没有报错。
产物质量:体积和 tree-shaking 有没有变差
提速我能接受有代价,但产物如果变胖或者 tree-shaking 退化,那就是用线上性能换 CI 时间,不划算。所以我专门对比了两套构建产物。
我用 rollup-plugin-visualizer 等价的方式生成了两份 bundle 分析,逐 chunk 对比:
- 主入口和大部分业务 chunk 的体积,Rolldown 和 Rollup 基本持平,个别差几百字节到一两 KB,可以忽略。
- tree-shaking 没有明显退化。我特意挑了几个只用了一两个函数的工具库(比如只 import 了
lodash-es的debounce),检查产物里没有把整个库打进去,Rolldown 处理得正确。 - 分包策略上有些细微差别。我们手动配了
build.rollupOptions.output.manualChunks把 vendor 拆出来,Rolldown 兼容这个配置,但自动分包的边界和 Rollup 不完全一样,导致几个 chunk 的哈希变了。这对部署没影响,但要注意 CDN 缓存的失效范围会比平时大一次。
我的做法是迁移上线那一次,主动把所有静态资源缓存当成会全量失效来处理,提前跟运维打了招呼。之后就稳定了。
换句话说:产物质量没有付出代价,提速是净赚。 但第一次切换会带来一波 chunk 哈希变化,这是一次性的,心里有数就行。
sourcemap、CSS 和一些边角
除了主流程,还有几个边角值得提一下,都是我迁移时一项项验过的:
- sourcemap:开
build.sourcemap: true后,Rolldown 生成的 sourcemap 在 Chrome DevTools 里能正常映射回源码,断点、错误堆栈都对得上。线上错误监控(我们用的是 Sentry)上传 sourcemap 之后定位也正常。 - CSS 处理:CSS 的提取、压缩、
@import内联这些还是走 Vite 原有的 CSS 流程(PostCSS、Lightning CSS 之类),Rolldown 主要管 JS 那一层,CSS 行为基本没变。我们用了 Tailwind 和一堆 PostCSS 插件,迁移后样式产物一致。 - 环境变量与
define:前面提过define在 oxc 下要写成合法 JS 表达式字符串,import.meta.env这套 Vite 的环境变量注入照常工作。 - 动态 import 与异步 chunk:
import()的代码分割正常,异步 chunk 的预加载(modulepreload)注入也在。SPA 路由懒加载没受影响。
这些点单看都不起眼,但任何一个出问题都可能是线上事故,所以我不建议"build 通过就直接上",该验的还是要验一遍。
要不要现在切,以及怎么回退
我的结论分情况:
可以现在试的: 中到大型项目、构建时间已经是痛点、依赖相对现代、出问题能被 E2E 测试拦住。这种项目收益最大,风险可控。建议先在一个分支跑通 build + 全套测试,灰度几天再合主干。
先别急的: 项目里有大量上古 CommonJS 依赖、有很多自己手写的构建插件、没有自动化测试。这些场景排查兼容问题的成本可能超过省下来的构建时间。
最让人安心的一点是:回退的成本几乎为零。 因为接入只是 package.json 里的一条 overrides,回退就是把它删掉:
1{ 2 "pnpm": { 3 "overrides": {} 4 } 5}
然后重新 pnpm install,你就回到了原版 Vite,代码一行不用动。这意味着你完全可以"试错式"地切——出了搞不定的问题,分分钟切回去,不会把团队卡死。
我自己的做法是:在 CI 里加了一个并行 job,用 rolldown-vite 跑一遍 build 和测试,和主 job 用原版 Vite 并行。观察了一周,确认产物体积、运行时行为都没问题之后,才把主 job 也切过去。这样既享受了提速,又没赌上一把。
团队协作上的几个实际问题
技术选型之外,把 Rolldown 推给整个团队还有一些"人"的层面的事,这些往往比技术本身更容易翻车。
新人上手的认知成本。 切到 rolldown-vite 之后,项目表面上还是"Vite 项目",新人 pnpm install && pnpm dev 一切照旧,基本无感。这是 overrides 方案的一大好处——不需要在新人文档里加一堆"我们用了个特殊打包器"的说明。我只在 README 的"已知差异"里写了一段,说明如果遇到某些老依赖的 interop 问题可以参考某个 issue,平时没人会碰到。
锁版本,不要 latest。 前面 overrides 我图省事写了 @latest,团队不同人 install 时间不同,装到的 Rolldown 版本就可能不一样,构建产物出现差异,CI 和本地对不上。后来我把它锁成了具体版本号:
1{ 2 "pnpm": { 3 "overrides": { 4 "vite": "npm:[email protected]" 5 } 6 } 7}
升级走单独的 PR,跑完全套测试再合,和升级任何核心依赖一个流程。这是过渡期软件的常识,但很容易因为最初的"试一下"心态而忘记收尾。
和编辑器/工具链的配合。 Vitest 在我们项目里也用 Vite 的配置和转译,换 Rolldown 后跑了一遍测试套件确认没问题。如果你用 Storybook 或者别的吃 Vite 配置的工具,同样要单独验一遍——它们可能内部固定依赖某个 Vite 版本,被 overrides 影响后行为变化。我就遇到过 Storybook 的某个 addon 对新转译结果有点小不适应,所幸不影响正常构建。
我的迁移检查清单
把上面踩过的坑整理成一个清单,下次再有项目要切我会照着走一遍:
- 新建分支,加 overrides 锁定具体版本,
install。 - 跑
build,看是否报错;报错优先怀疑老 CommonJS 依赖和自写插件。 - 对比迁移前后的 bundle 体积和 chunk 结构,确认产物没退化。
- 跑全套单元测试(Vitest)和 E2E,重点覆盖用了新语法、装饰器、
define的地方。 - 验 sourcemap、CSS 产物、动态 import 懒加载、环境变量注入。
- 检查 Storybook / Vitest / 其他吃 Vite 配置的工具是否正常。
- CI 里加并行 job 跑 Rolldown,和主 job 对照观察几天。
- 确认无误后切主 job,部署时按"静态资源缓存会全量失效一次"来处理。
整套走下来大概一两天,对一个动辄省下几分钟构建、且每天跑十几次 CI 的项目,这点投入很快就回本了。
一点收尾的想法
回到最开头那个落差——dev 秒开、build 却像在另一个世界。现在这个落差基本消失了,因为两边终于跑在同一个引擎上,调构建问题不用再脑子里来回切换 esbuild 和 Rollup 两套模型去猜到底哪层出的问题。数字也摆在这:CI 从 3 分半压到 1 分出头,本地全量构建从近两分钟压到半分钟多,这是能落到日常工作里的省时间。
当然现在还是过渡期,rolldown-vite 会持续迭代,默认行为也还在变。如果你决定上,盯紧版本更新日志,别用 latest 之后就不管了。我的项目已经在生产用了一个多月,目前稳定,省下的 CI 时间是真金白银——具体值不值得现在切,还是回到前面"先量再换"那条办法:先确认你的构建瓶颈是不是真的卡在 Rollup 那一段。