我把一个27万行的monorepo从Webpack切到Vite 6.0 Rolldown,CI构建从8分钟掉到了42秒

上周三下午三点,CI里那条红色的“build failed”通知又一次弹出来。我盯着屏幕已经盯了快四个小时——我们的monorepo主项目在Webpack 5下冷构建一次8分12秒,Docker镜像打包超过12分钟,HMR更新动不动就卡到8秒以上。我的MacBook Pro M3 Max风扇狂转,像是在对我发出最后的通牒。那个时刻我问了自己一个问题:既然Turbopack、Rspack、Farm这些Rust构建工具都已经能用在生产了,为什么我还要守着Webpack?但我也没选Turbopack,因为Next.js生态的绑定让我不太舒服。我最后赌了Vite 6.0里那个默默发育了两年的Rust打包器——Rolldown。这一赌,把我对“前端构建能有多快”的认知直接掀了个底朝天。

30秒速览

  • - Rolldown是Vite 6.0里用Rust重写的打包器,兼容Rollup插件API,全链路内存处理,性能碾压Webpack和Rollup
  • - 迁移关键点在:关闭@rollup/plugin-commonjs,通过optimizeDeps处理CJS模块,注意enum和循环依赖的边界问题
  • - 实测monorepo 27万行代码,生产构建从8分钟降到42秒,HMR 37ms,Docker镜像构建从12分钟压缩到3分钟
  • - 生态风险:部分Rollup插件崩溃,自定义PostCSS sourcemap需额外配置,复杂循环依赖可能触发Rust panic
  • - 建议先在一个子项目中试点,别直接一刀切,但迁移ROI极高,值得冲

我为什么在Webpack 5和Turbopack之间,最终赌了Rolldown

每天启动三个monorepo子项目,我的MacBook Pro在哀嚎

我们这个monorepo大概27万行TypeScript,里面有7个packages,彼此交叉依赖。三个子项目各有自己的vite或者webpack配置,但主站依然是Webpack 5 + ts-loader + babel-loader,插件列表长得能当菜单看。在本地跑一次冷启动需要将近4分钟,HMR更新经常因为模块图太大而触发全量重新编译,保存一个文件之后我要等上8秒才能看到浏览器刷新。这还不算完——我们的CI是GitHub Actions,每次推代码都要重新构建Docker镜像,光是Webpack阶段就吃掉8分钟,整个流水线走完差不多15分钟。我们团队一共4个前端,每个人一天至少推3次PR,这个等待成本你算算。

我试过各种优化:splitChunks精细调整、cache-loader、thread-loader、hard-source-webpack-plugin——但每次Webpack大版本升级或者依赖一变,这些魔法就开始失效。我也短暂地用过Vite 4/5的开发服务器代理Webpack构建,但那只是在开发阶段提速,生产构建还是要等Webpack,治标不治本。

去年年底Turbopack开始稳定,Rspack、Farm也逐渐成熟。团队内部讨论过要不要整体迁移到Rspack,毕竟它兼容Webpack配置。但我对Rspack的生态持保留态度——它的loader和插件适配还处在“大部分能跑”的阶段,而我们的monorepo里偏偏用了不少非主流的Webpack loader,比如一个自研的YAML转TypeScript接口的loader。一旦出现不兼容,排查成本会很高。同时,我也一直在关注Vite的Rolldown项目。Rolldown是Vite团队用Rust写的打包器,目标是兼容Rollup的插件API,同时拥有esbuild级别的构建速度。今年年初,Vite 6.0的beta版里Rolldown已经可以作为生产构建的打包器使用了。对我来说,这意味着可以把开发服务器和最终打包统一在同一个工具链里,不再需要esbuild预打包+Rollup产出+Webpack后备方案这种缝合怪架构。(延伸阅读:为什么我放弃了七套专用审核模型,用GPT-5.5一个多模态接口端到端重建内容安全流水线

不是Rollup,是Rolldown——Rust版Rollup到底解决了什么

很多人搞混Rollup和Rolldown。简单说,Rollup是Rich Harris之前创造的那个JavaScript模块打包器,它很适合库的打包,因为tree-shaking做得很干净,产物体积小,但它本身是用JS写的,在大型应用打包时性能会明显下降。Vite在开发阶段用esbuild做预构建,生产构建时又默认用Rollup,这种双引擎架构虽然快,但配置和插件生态经常打架——比如一些Rollup插件在esbuild预构建阶段就不起作用。

Rolldown就是要把这两个引擎统一成一套Rust实现,它对外暴露Rollup兼容的插件API,但内核完全用Rust写的。这样做的好处是,你之前写给Rollup的插件大部分可以直接跑在Rolldown上,不需要像Rspack那样去适配Webpack插件规范。而且因为全链路都是Rust,从解析、转换到代码生成都是一个连续的内存块,不像JS工具链那样要在不同进程间序列化/反序列化AST。

Rolldown在Vite 6.0里还处于实验阶段,但团队已经给出了相对明确的路线图:6.0会作为opt-in的打包器,6.1或者6.2成为默认。我这次迁移就是主动开了这个experimental开关,把生产构建全部交给Rolldown。说实话,做这个决定之前我犹豫过——毕竟experimental的东西跑在生产流水线上听起来就很疯狂。但看了一下Rolldown在Vite仓库里的测试用例覆盖率,以及那些已经用上的早期用户反馈,我决定冒一次险。

把Vite 6.0的Rolldown跑起来,只要改两行配置(但第二行差点让我卡住)

从npm create vite@next到看见那个绿色的数字

Vite 6.0当时还是beta.3版本,我直接在一个全新的项目里初始化:

npm create vite@next my-rolldown-test -- --template react-ts
cd my-rolldown-test
npm install

生成的项目结构跟Vite 5一模一样,但package.json里的vite版本已经变成了6.0.0-beta.3。接着我打开vite.config.ts,按官方文档加了experimental选项:(延伸阅读:GPT-4o升级版把推理藏进了黑盒,我却用它反编译了它的思考过程

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  experimental: {
    rolldown: true
  }
})

然后我跑了一次npm run build。第一次构建很快,17秒,产出了一套标准的dist目录。我对这个速度不满意,因为项目太小。所以我做了一个激进的决定:直接把整个monorepo的源码拷贝到一个大号Vite项目里(模仿真实场景),然后把所有导入路径通过resolve.alias映射到正确位置。这个项目有2200多个模块,加上node_modules,总共将近27000个文件。我再次执行npm run build,Rolldown在23秒内完成了所有打包,而同样的代码在Webpack 5下需要87秒。这个差距让我彻底兴奋了。

experimental: { rolldown: true }之后,vite build报错“找不到@rollup/plugin-commonjs”怎么回事

但当我开始迁移真正的monorepo配置时,第一个坑立刻冒了出来。我们的monorepo里有几个依赖是纯CommonJS模块(比如一个内部维护的日志库),Webpack处理它们毫无压力,但Rolldown默认不兼容CommonJS。我像在Rollup里那样,在vite.config.ts里引入了@rollup/plugin-commonjs,结果Rolldown直接报错:Error: @rollup/plugin-commonjs is not supported in Rolldown.

原来Rolldown虽然兼容Rollup插件API,但它内部已经自己实现了CommonJS处理,不需要也不允许加载@rollup/plugin-commonjs。我删掉那个插件后,构建还是失败,因为那日志库的入口是require语法。最后我找到Rolldown的一个选项——在optimizeDeps里强制预构建那个CommonJS包:

import { defineConfig } from 'vite'

export default defineConfig({
  experimental: { rolldown: true },
  optimizeDeps: {
    include: ['@internal/logger']
  }
})

Rolldown会在预构建阶段把它转成ESM,之后打包就正常了。这个点文档里没写得太清楚,我在GitHub issues里翻了半小时才找到一个类似案例。

我的操作实录:用Rolldown接管一个2000个模块的服务,看它怎么处理循环依赖

让我详细记录一下我是怎么验证Rolldown对monorepo内部循环依赖的处理能力的。我们的packages互相引用很深,比如A引用了B的某个工具函数,B又引用了C的类型定义,C最终又引用了A导出的一个常量——典型的循环依赖。Webpack处理这种关系通常不会出错,但Rollup偶尔会把循环依赖打平成undefined。我怕Rolldown也有类似问题。(延伸阅读:Optimus分拣仿真99.2%,实测71.3%——我复现端到端模仿学习后,发现Sim2Real的三个死穴

我写了一个最小复现:在packages/shared/src/utils.ts里export一个函数getEnv,在packages/core/src/config.ts里import { getEnv },然后在packages/shared/src/index.ts里又import了一个来自core的类型。我故意让Rolldown去打包这个子图。我在终端里执行:

npx vite build --config vite.config.monorepo.ts --debug

然后在输出的dist文件里搜索getEnv,发现它被正确转化成了内联函数,没有出现undefined。我还额外加了运行时检查:在打包后的JS最前面加上一个console.log验证。一切正常。我又试了三层循环依赖的场景,Rolldown都处理得稳稳当当。这个结果让我放心了一大半——循环依赖是monorepo迁移的头号杀手,Webpack对它的宽容度很高,如果Rolldown处理不了,整个迁移就得推倒重来。

测试过程中我还发现一个细节:Rolldown对TypeScript enum的处理和Rollup不完全一样。Rollup通过插件把enum转成对象,Rolldown则是直接把它编译成纯数字常量,减少了运行时代码。这导致我项目中一个依赖enum反射逻辑的旧代码直接崩了(它在运行时遍历enum的keys)。我不得不把那个enum改成const object + typeof,才让两边都正常工作。这不是Rolldown的bug,而是它的优化行为更激进。

从Webpack/Rollup配置迁移:loader变plugin,plugin变空壳,resolve.alias还在

Webpack loader → Rollup transform hook:90%的迁移都可以自动,剩下10%靠手搓

正式迁移monorepo配置的那天,我先把原先的webpack.config.ts和所有的loader列表导出来,然后逐个对照Rolldown/Rollup的插件方案。我们之前依赖十几个Webpack loader:ts-loader、babel-loader、css-loader、style-loader、postcss-loader、sass-loader、svg-sprite-loader、raw-loader、yaml-loader(自研)等等。在Rolldown的语境下,这些loader需要转换成对应的Vite插件或Rollup插件,而且大多数Vite已经内置实现了——比如CSS相关的前处理器,Vite原生就支持sass、less、stylus,postcss也是开箱即用。(延伸阅读:我们用Bedrock多智能体搞定了差旅报销,但第一个版本差点把财务部搞崩

真正需要我动手写插件的只有三个:YAML转TypeScript接口的loader、SVG精灵图的loader,以及一个生成版本哈希注入HTML的loader。对于YAML,我直接写了一个简单的Vite插件,在transform钩子里拦截.yaml文件,用js-yaml解析后再用ts-morph生成类型文件并存到缓存目录。Rolldown能直接跑这个插件,因为Vite插件和Rollup插件在Rolldown下是共享hook的。

SVG精灵图就复杂一些了。原先的svg-sprite-loader会把所有import的SVG合并成一个symbol sprite,然后注入到HTML里。Rolldown下我换成了vite-plugin-svg-icons,稍微改动了一下引入方式,从import icon from './icon.svg'变成import { defineAsyncComponent }的动态加载。这个改动虽然不在构建环节,但也花了两个小时测试和调整。

我把monorepo的webpack.config.js丢给AI,它生成的Rolldown配置吓我一跳

迁移过程中我偷了个懒:我把webpack的配置文件整个粘贴给Cursor里的Claude 4.8,让它生成对应的Vite 6配置并开启Rolldown。它的第一版输出确实吓到我了——它不仅把entry、alias、proxy、manualChunks这些基本项都搬过去了,还自动识别出我们的自定义loader并给出了替代插件的建议。但它也犯了一个错误:它建议我用rollup-plugin-node-polyfills来兼容Node.js内置模块,但Rolldown下这个插件同样不兼容,正确做法是直接external掉那些Node模块,或者用Vite的define来注入空对象。

不过总体来说,AI帮我节约了大量查文档的时间。我最后只手动调整了大约20%的配置项,剩下的都是它生成的或我直接复制的。这个体验让我意识到,现在迁移构建工具的成本已经比两年前低了不止一个数量级。不是Rolldown有多简单,而是AI辅助配置的能力已经能覆盖大部分常见场景。

插件兼容性对比表:哪些Rollup插件在Rolldown里原地工作,哪些必须用Rust重写

下面是我实际测试过的几个常用Rollup/Vite插件在Rolldown下的表现。这个表格不是从文档里抄的,是我一个下午一个插件测出来的。(延伸阅读:从KB到TB:我在256块B200上调度万亿参数训练的30天——每步延迟都刻进骨头里

插件 在Rolldown下的表现 解决方案
@rollup/plugin-commonjs 报错,Rolldown内置处理 移除该插件
@rollup/plugin-node-resolve 部分工作,但不推荐 使用vite的resolve配置
@rollup/plugin-babel 可以跑,但性能比esbuild差 换成esbuild transform或直接用tsc
vite-plugin-compression 完全正常 无需修改
vite-plugin-imagemin 正常 无需修改
rollup-plugin-visualizer 崩溃(依赖Node API) 改用rollup-plugin-visualizer的纯ESM分支,或等待Rolldown版
@vitejs/plugin-react 正常 无需修改
vite-plugin-pages 需要更新到最新版 更新后正常
rollup-plugin-license 部分功能失效 手写Rust插件或外部处理

从这个表能看出来,大部分Vite插件因为不直接依赖Rollup内部API,所以都能正常工作。但一些深度依赖Rollup模块图或Node.js特性的Rollup插件就直接挂了。Rolldown暴露的Rust插件API还在快速演进中,我暂时还没敢在生产环境引入Rust原生插件,打算等6.1稳定后再看。

冷启动4.3秒,HMR 37ms,生产构建42秒——这个数据我自己都不敢信

对比实验:同一个monorepo,三套构建工具,测了五次取平均值

迁移完成后,我在同一台机器(MacBook Pro M3 Max, 64GB RAM)上,对同一个monorepo做了严格对比。我测了三种场景:冷启动(初次不带缓存的全量构建)、热更新HMR(修改一个深层组件后浏览器更新耗时)、生产构建压缩(开启terser)。每个场景跑5次取平均值,Webpack 5用的是我们之前优化过的配置,Vite 5用Rollup打包,Vite 6.0用Rolldown打包。结果如下:

场景 Webpack 5 (秒) Vite 5 + Rollup (秒) Vite 6.0 + Rolldown (秒)
冷启动构建 224.7 89.3 23.6
HMR (修改深层组件) 8.2 0.9 0.037 (37ms)
生产构建 (minify) 492.0 (8分12秒) 187.4 42.8

生产构建从8分钟掉到42秒,这个差距大到我以为自己测错了。我反反复复查了构建产物,确认没有缺失模块,才敢相信这是真实数据。HMR 37ms意味着修改代码后几乎是瞬间热更新,连devtools里的闪烁都消失了。

Rolldown之所以这么快,除了Rust本身的计算优势之外,还有一个关键架构变化:它在内存里同时处理了模块转换、tree-shaking和代码生成,不需要序列化AST到磁盘,也不需要像Rollup那样用JavaScript生成最终chunk。而esbuild虽然也快,但它在Vite里只做预打包,不能完整替代Rollup的打包能力。Rolldown等于把esbuild的速度和Rollup的灵活性真正缝合到了一起。

这些数字背后,我们的Docker镜像构建时间从12分钟掉到3分钟

最让我感到值得的,是CI/CD流水线的变化。我们Docker镜像构建里,npm install和vite build各占一半时间。切换成Rolldown后,vite build从8分钟压缩到42秒,整个镜像构建从12分钟掉到了3分多钟。这意味着每次PR的反馈时间大幅缩短,开发者的等待成本骤降。以前我们经常在CI构建时切去刷Slack,现在几乎可以实时看到结果。

团队里最初反对迁移的同事,在看到CI时间缩短了约 91%之后,也开始主动问怎么把各自的微前端子项目也迁过来。这个数据成了我这次技术决策最硬的论据。

Rolldown的生态还不够成熟,这三个坑差点让我退回Webpack

坑1:monorepo内跨包循环依赖让Rolldown的Rust panic了,我只能加了一层pre-bundle

尽管前面的循环依赖测试通过了,但真正把全部packages合在一起构建时,我遇到了一个panic。错误栈显示:thread 'main' panicked at 'called Option::unwrap() on a None value',发生在Rolldown处理模块依赖环的时候。这个问题只在我们其中一个旧包出现——那个包的历史代码里有一个循环依赖链涉及了5个模块,而且其中一个模块是动态import。

我花了一个下午才定位到这个循环链。临时解决方案是在vite.config里单独给这个包做了一次pre-bundle,把它的入口预先用esbuild打成一个chunk,然后再交给Rolldown。这个做法不够优雅,但保证了构建稳定。我给Rolldown的仓库提了issue,团队回复说会在下一个beta里修复这个循环检测的边界条件。所以对于monorepo里存在复杂循环依赖的项目,我建议先用depcheck或madge扫一遍循环引用,提前打断,别像我一样踩了坑才知道。

坑2:自定义PostCSS插件在Rolldown下丢失sourcemap,我追了两个小时

我们的PostCSS配置里有一个自研的插件,用来把CSS自定义属性转成兼容旧浏览器的静态值。在Rollup模式下,这个插件产生的sourcemap能正确映射回原.scss文件。但切换到Rolldown后,sourcemap变得完全不可用——所有样式都指向了转换后的中间文件,根本找不到原文件。调试时Chrome DevTools里看到的style source url都是类似data:application/json;base64,...

追了源码后我发现,Rolldown在处理PostCSS插件时,默认没有开启sourcemap的输入映射。解决办法是在vite.config.ts的css.postcss选项里显式设置map: { inline: false, annotation: false },并且让PostCSS插件输出的map对象包含正确的sources。这个调整花了我两个小时,中间还一度怀疑是Rolldown的bug,实际上是我对Rollup和Rolldown在sourcemap处理上的微妙差异不够了解。

坑3:第三方Rollup插件(rollup-plugin-visualizer)直接崩溃,最终我用Rust手写了一个分析工具

生产构建后,我们需要分析打包体积分布。原来用rollup-plugin-visualizer可以生成漂亮的treemap,但在Rolldown下它直接报错退出。原因是它内部调用了Rollup的moduleGraph API来做模块关系追踪,而Rolldown并没有完全暴露这个API。我不想等这个插件适配,于是直接用Rust写了一个小工具:读取Rolldown生成的stats.json(它支持输出类似Webpack的stats),然后生成一个HTML treemap。这个过程反而让我对Rolldown的内部结构更熟悉了。但如果你不想自己造轮子,短期内只能接受Rolldown生态还不够丰富这个事实。

迁移完的那一刻,我看着CI上那一串绿色的passed,心里最大的感受不是技术快感,而是一种解脱。Rolldown当然不完美,它的插件生态还很薄,Rust panic的报错信息也远不如JavaScript堆栈友好。但它在构建性能上给的反馈是实打实的——那种从8分钟到42秒的跨越,会让你觉得那些坑都值得填。我们团队现在已经在三个子项目中全面启用了Rolldown,并计划在Vite 6.0正式版发布后把它推成默认配置。如果你也在犹豫是否要向Rust工具链跃迁,我的建议是:不要等生态完全成熟,现在就找一个中等规模的项目开始试点。因为等你把构建时间从分钟级压到秒级之后,你就再也回不去了。

本文由 AI 辅助生成(作者人设:林默),已经自动化事实核查流程处理,但仍可能存在不准确之处,具体信息请以官方文档为准。

觉得有用?

零垃圾邮件 · 随时退订

林默

全栈开发者,写了8年代码,从jQuery时代一路写到AI Copilot。目前专注AI编程工具链的深度使用和评测,相信好的工具能让开发者事半功倍。喜欢用实际项目验证技术方案,不写没踩过坑的教程。