Rollup升级Rolldown-范式组件库实战
最近在优化一个图表组件库的构建速度。这个项目之前的正式 build 特别慢,慢到每次验证都让人下意识想先去做别的事。
这里做个记录。这个问题一开始看起来像是“Rollup 慢”,但真正做下来以后发现,换构建器只是其中一部分,麻烦的是怎么证明新的产物真的可以替代旧的发布产物。

背景
这个项目是一个基于 ECharts 的图表组件库,发布构建要产出很多东西:
- 主入口 bundle;
- UMD / ESM;
- split ESM;
- public subpath entries;
- 类型声明;
- sourcemap;
- 一堆 demo 和 smoke 验证需要能跑起来。
这类组件库的 build 不是“打一个包就结束”。它更像是一个发布流水线:每个产物都有消费者,每个消费者又有不同的加载方式。
所以这次不能只看命令有没有跑通。尤其是图表组件,构建产物能 import 不代表能在浏览器里正常渲染。worker、UMD、动态帧、tooltip formatter,这些都可能在打包后出问题。
问题现象
我们实测了一下,旧的 Rollup no-test 发布构建大概是:
73.16s
后来迁移到 Rolldown release assembly 后,同样 no-test 构建降到:
23.80s
大概是 3.07x 的提升。
后面做最终收口时,又跑了一次更完整的 benchmark gate。那次数据是 Rollup 69.88s,Rolldown
33.85s,约 2.06x。
这个数字比前面的 3x 低不少。一开始看到这个结果,我也会下意识觉得是不是优化效果缩水了。后来想清楚,
这正好说明 benchmark 不能只看一次最漂亮的数据。机器负载、冷热状态、前面跑过哪些命令,都会影响单次结果。
所以最后没有把门槛设成“必须 3 倍”,而是设成:
- Rolldown 至少比 Rollup 快
2x; - Rolldown no-test release assembly 不超过
45s。
这个门槛的目的不是追求最好看的数字,而是防止明显回退。
这个数字当然很诱人,但中间并不是直接把 rollup 换成 rolldown 就完事。
一开始我也容易这样想:既然 Rolldown 是 Rust 写的,又兼容 Rollup API,那是不是把命令换掉就能解决?后来才发现,这个判断太粗了。
真正的问题有几个:
- Rollup 的老构建链路里有很多串行步骤,构建器本身慢是一部分,流水线调度慢也是一部分。
- 旧项目里有 UMD、split ESM、public entries 等多个产物形态,不是一个 bundle 能覆盖。
tsdown看起来也很快,但它更适合继续评估,不能直接替代当前正式 build。- worker loader 这类插件不是普通 transform,少了以后 DynamicVoronoi 这种图表会直接挂。
所以这次优化不是单纯追求“快”,而是先把“能替代”的证据补齐。
排查过程
第一步是做 PoC。
先用 Rolldown 构建最小入口,比如 core,确认它不是环境层面跑不起来。这个阶段速度非常快,但这只是最低层的证明。
后面继续扩大范围:
- public entries 的 ESM / CJS;
- 主 bundle 的 ESM / IIFE / minified ESM / minified IIFE;
- split ESM;
- UMD wrapper;
- browser runtime parity;
- browser render parity。
这里有一个弯路:一开始很容易把“文件都生成了”当成成功。但对这个项目来说,这个判断不够。
比如 UMD 就是一个典型问题。Rolldown 原生 IIFE 可以在 browser global 里跑,但是不等于它天然支持老 Rollup UMD 的 CommonJS 和 AMD 形态。最后我们加了一个 UMD wrapper,把 Rolldown IIFE 包成 Rollup 风格的 UMD,并且单独验证 browser global、CommonJS、AMD 三种模式。
worker 也是类似的问题。split ESM 里如果过于激进地跳过旧链路,DynamicVoronoi 会出现类似:
DataWorker is not a constructor
这说明 worker loader 不能靠猜。它必须用真实浏览器场景验证。
最终保留下来的验证链路大概是:
- 先生成 Rollup baseline;
- 再生成 Rolldown 候选产物;
- 做文件和静态信号 parity;
- 做 runtime parity;
- 做 render parity;
- 把 Rolldown 候选复制成正式
build/; - 跑 RankLine、DynamicSankey、DynamicVoronoi、SankeyTree、dvScatter 等 smoke;
- 最后跑 release verifier。
这一步比较啰嗦,但它解决了一个关键问题:不是“我觉得 Rolldown 可以替代”,而是每次发布前都能机械验证。
解决方案
最后的方案是:
正式 build 默认切到 Rolldown release assembly:
npm run build
npm run build:no-test
Rollup 保留为 fallback:
npm run build:rollup
npm run build:rollup:no-test
Rolldown 固定成本地依赖:
"rolldown": "1.1.3"
这里有个小但很实际的点:不要在正式构建里用 npx -p rolldown@... 临时解析构建器。前面有一轮数据里,直接构建还在 40 多秒,后来把 Rolldown 固定成本地 devDependency,并直接调用 node_modules/.bin/rolldown 后,直接 release assembly 到了约 23.91s。
这说明构建性能不只看 bundler 内部耗时,启动方式、包解析、流水线调度都会影响最终体感。
这次还做了几件事:
- public entries 并行构建;
- 主 bundle 四个目标并行构建;
- release pipeline 支持 parallel step;
- split ESM 默认跳过旧主代码 Babel / strip 链路;
- 保留 worker loader,避免 DynamicVoronoi 回归;
- 增加 benchmark 脚本,把 Rollup 和 Rolldown 的耗时写入 artifact;
- 把
verify:release扩展成完整的发布门禁。
最终验证命令是:
BUILD_PROGRESS=0 npm run verify:release -- --json
这次是通过的。
后面补上的工程化收尾
前面的工作解决了“Rolldown 能不能替代 Rollup”。但做完以后我发现,这还不是最终状态。
如果只是本地跑通一次,过几天别人改了 package.json、改了构建脚本、或者某个测试又被塞回默认 build,
这个优化很快就会退回去。所以后面又补了一轮,目的不是继续追求更快,而是把这次优化固化成团队可以长期维护的机制。
这轮主要做了几件事。
第一,把测试分层。
之前 npm run build 慢,不只是打包慢,测试也很慢。有一类测试其实是在测验证脚本本身,比如
verify-delta、结构检查、报告脚本、smoke harness、Rolldown parity verifier。这些测试很重要,
但它们不应该每次都挡在“生成发布产物”之前。
所以后面把入口拆成了:
test-fast:默认 build 走这条,主要覆盖业务和普通单测;test-integration:显式跑验证脚本类自测;test:gate-contracts、test:report-contracts、test:smoke-contracts、test:rolldown-contracts: 用来定位不同类型的慢测试。
最终复测里,test-fast 大概是 14.51s,test-integration 大概是 39.68s。这不是说
integration 不跑了,而是把它从“每次默认 build 都跑”调整成“需要验证这些脚本契约时显式跑”。
第二,把构建性能变成门禁,而不是口头结论。
新增了:
npm run verify:build-performance
它会跑 Rollup fallback 和 Rolldown release assembly,对比两边 no-test 构建耗时,并输出 JSON 和 artifact。平时不把它塞进每一次本地 build,因为这个 gate 本身也要付出 Rollup baseline 的成本; 但它应该出现在发布前、CI 手动检查、或者构建脚本变更的时候。
这里还加了一个趋势记录入口。普通 verify:build-performance 不默认写 history,避免每次验证都污染趋势;
只有显式加 --append-history 的时候,才把这次 benchmark 追加到 history.jsonl。这个细节很小,
但挺重要:性能数据如果没有使用边界,很容易变成一堆无法解释的数字。
第三,把 CI 分成两层。
这个项目的代码流转有两个地方:日常开发在 GitHub 私仓,正式发布走公司 GitLab,再进 PaaS / Docker 发布链路。所以 CI 也不能混成一套。
后面补的策略是:
- GitHub Actions:跑轻量开发自检,比如
test-fast、verify:delta、build:no-test; - GitLab tag 发布:跑
verify:release和verify:build-performance; - 修改构建相关脚本时,允许在 GitLab 手动提前跑 build performance gate。
这里的判断很简单:GitHub 侧帮开发者尽快发现明显问题,GitLab 侧负责发布前的严格证明。
第四,给 release gate 加了规划入口。
不是每个改动都需要完整跑一遍发布级验证。改文档、改构建脚本、改渲染相关代码,风险不一样。
所以后面加了一个 verify:release:plan,根据改动文件判断推荐 gate:
- 纯文档改动,走
verify:docs和verify:delta; - 构建脚本改动,要求 Rolldown parity、runtime parity、render parity 和 release gate;
- 渲染敏感改动,再路由到对应 smoke。
这一步的目的不是偷懒,而是让验证成本和风险匹配。否则大家会因为全量验证太贵,最后选择不验证。
插件优化这件事,最后没有强上
前面提到后续可以看插件链,尤其是 worker loader。后来确实做了一轮实验。
我们加了一个显式开关:
ROLLDOWN_WORKER_LOADER_EXPERIMENT=skip-worker-loader npm run build:rolldown-split-poc
数据看起来挺诱人:
- 默认 split PoC:
5.89s; - 跳过 worker loader:
2.76s; - 单步快了大概
3.13s。
如果只看这个数字,很容易想直接合进去。但后面的验证把这个想法拦住了。
在 skip 实验下:
verify:rolldown-runtime-parity能过,但它只证明 import/export 形态;verify:rolldown-parity报split-worker-loader-not-proven;verify:rolldown-render-parity失败,复现DataWorker is not a constructor;- 默认路径下的
verify:dynamic-voronoi-smoke仍然能通过,并且 worker callback 能触发。
所以结论很明确:这 3 秒不值得拿真实渲染风险去换。
这一步反而是这次优化里很有价值的地方。因为它证明了一件事:性能优化不是看到更快就合并,而是要问这个快是不是破坏了真实行为。尤其是图表组件,render parity 比命令成功更重要。
后续如果还要优化 worker loader,应该做项目内专用 worker factory plugin,而不是直接删除 loader。
经验教训
这次最大的教训是:构建优化不能只盯“哪个工具快”。
工具快当然重要,但对一个真实组件库来说,至少还有三件事要一起看:
- 产物形态是否一致;
- 运行时是否一致;
- 渲染行为是否一致。
如果只看第一点,很容易把问题埋到发布后。尤其是可视化组件,产物能加载,不代表图能正确渲染。
还有一个经验是,fallback 不要急着删。
这次 Rollup 已经不是默认正式构建了,但它仍然保留为 parity baseline 和紧急回退。这一步看起来不够“干净”,但对迁移期是必要的。否则一旦 Rolldown 的某个插件兼容性踩坑,就没有可对比的基准。
后续如果继续优化,我会优先看这些插件:
virtualbabelstripreplacerollup-plugin-web-worker-loader
Rolldown 已经把大头打下来了,下一步要抠的是插件链。但这一步要更谨慎,尤其是 worker loader。 这次已经验证过,直接跳过它会导致真实渲染失败。动它之前必须先有最小复现场景、worker 输出契约测试和浏览器 smoke。
还有一个后续补上的经验:优化要变成流程,不要停在一次成功。
这次最后留下来的不是一句“Rolldown 更快”,而是一组可以复跑的入口:
- 默认构建:
npm run build; - Rollup 回退:
npm run build:rollup; - 发布验证:
npm run verify:release -- --json; - 性能门禁:
npm run verify:build-performance; - 发布 gate 规划:
npm run verify:release:plan; - 慢测试分层:
test-fast/test-integration/ 各类 contract tests。
这些东西看起来没有直接改 bundle 快,但它们解决的是另一个问题:后面的人怎么不把这次优化改坏。
下次做类似构建迁移,我会先定一个规则:
不先证明产物能替代,就不要急着宣布构建器替换完成。
现在我会再加一句:
不把验证入口固化下来,也不要急着宣布优化完成。
速度优化最后要落到可重复验证上。否则今天省下来的几十秒,后面可能会用一次线上回归还回去。