搜索文章

输入关键词开始搜索

Rollup升级Rolldown-范式组件库实战

程序设计#JavaScript#工程化

最近在优化一个图表组件库的构建速度。这个项目之前的正式 build 特别慢,慢到每次验证都让人下意识想先去做别的事。

这里做个记录。这个问题一开始看起来像是“Rollup 慢”,但真正做下来以后发现,换构建器只是其中一部分,麻烦的是怎么证明新的产物真的可以替代旧的发布产物。

Rollup 到 Rolldown 的构建迁移流水线

背景

这个项目是一个基于 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,那是不是把命令换掉就能解决?后来才发现,这个判断太粗了。

真正的问题有几个:

  1. Rollup 的老构建链路里有很多串行步骤,构建器本身慢是一部分,流水线调度慢也是一部分。
  2. 旧项目里有 UMD、split ESM、public entries 等多个产物形态,不是一个 bundle 能覆盖。
  3. tsdown 看起来也很快,但它更适合继续评估,不能直接替代当前正式 build。
  4. 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-contractstest:report-contractstest:smoke-contractstest:rolldown-contracts: 用来定位不同类型的慢测试。

最终复测里,test-fast 大概是 14.51stest-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-fastverify:deltabuild:no-test
  • GitLab tag 发布:跑 verify:releaseverify:build-performance
  • 修改构建相关脚本时,允许在 GitLab 手动提前跑 build performance gate。

这里的判断很简单:GitHub 侧帮开发者尽快发现明显问题,GitLab 侧负责发布前的严格证明。

第四,给 release gate 加了规划入口。

不是每个改动都需要完整跑一遍发布级验证。改文档、改构建脚本、改渲染相关代码,风险不一样。

所以后面加了一个 verify:release:plan,根据改动文件判断推荐 gate:

  • 纯文档改动,走 verify:docsverify: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-paritysplit-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。

经验教训

这次最大的教训是:构建优化不能只盯“哪个工具快”。

工具快当然重要,但对一个真实组件库来说,至少还有三件事要一起看:

  1. 产物形态是否一致;
  2. 运行时是否一致;
  3. 渲染行为是否一致。

如果只看第一点,很容易把问题埋到发布后。尤其是可视化组件,产物能加载,不代表图能正确渲染。

还有一个经验是,fallback 不要急着删。

这次 Rollup 已经不是默认正式构建了,但它仍然保留为 parity baseline 和紧急回退。这一步看起来不够“干净”,但对迁移期是必要的。否则一旦 Rolldown 的某个插件兼容性踩坑,就没有可对比的基准。

后续如果继续优化,我会优先看这些插件:

  • virtual
  • babel
  • strip
  • replace
  • rollup-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 快,但它们解决的是另一个问题:后面的人怎么不把这次优化改坏。

下次做类似构建迁移,我会先定一个规则:

不先证明产物能替代,就不要急着宣布构建器替换完成。

现在我会再加一句:

不把验证入口固化下来,也不要急着宣布优化完成。

速度优化最后要落到可重复验证上。否则今天省下来的几十秒,后面可能会用一次线上回归还回去。