Dropbox 如何将 JavaScript 包体积缩小 33%

原文作者:Umair Nadeem、Rich Hong;发表于 2023 年 8 月 16 日。本文为经授权的中文翻译整理,保留原文的经验与适用边界。阅读 Dropbox Tech 原文。

你可能遇到过这种情况:正准备点击网页上的按钮,页面突然移动,结果点中了别的按钮;或者页面加载太慢,让你干脆关掉了它。对于 Dropbox 这样功能丰富、交互密集的应用,这些问题尤其突出。前端功能越复杂,需要发送到浏览器、解析并执行的代码往往越多,性能也越容易下降。

Dropbox 的 Web 性能工程团队在此前一年追查性能问题时,把一部分原因定位到了经常被忽略的模块打包器。现代代码库会拆成较小的模块,打包器再把 JavaScript、CSS 等组成部分整合为浏览器加载的资源包。常见的产物是一份经过压缩、包含大量应用逻辑的 JavaScript 文件。

Dropbox 第一代打包器诞生于 2014 年。当时,围绕性能设计打包流程的工具正在兴起,Webpack 和 Rollup 分别于 2012 年、2015 年出现。与后来的工具相比,内部打包器的功能十分基础,缺少多项性能优化,维护也不方便,既影响用户体验,也拖慢开发。团队决定更换它时,恰好正在把页面迁移到新的 Web 服务架构 Edison:这既提供了现成的迁移计划,也让现代打包器更容易接入静态资源流水线。

旧架构的问题

旧打包器构建时的效率尚可,生成的包却很大,而且需要工程师手工维护。工程师必须指定每个包包含哪些脚本;页面所涉及的包则几乎未经优化就全部交付给浏览器。随着代码库扩大,三个问题变得越来越明显。

同一页面可能加载多个代码版本

Dropbox 原先使用自研的 Dropbox Web Server(DWS)。一个页面由多个 pagelet,也就是页面子区域组成,因此同一页有多个 JavaScript 入口,每个 servlet 由后端各自的控制器提供服务。这种方式有利于多个团队独立部署,但不同 pagelet 也可能运行在不同后端代码版本上。

DWS 因而必须支持在同一页交付不同版本的资源包,带来一致性风险,例如让一个本应只有一个实例的单例对象出现多个实例。迁移到 Edison 后,pagelet 架构将被移除,团队才有空间采用更常见的打包方式。

代码拆分依靠手工维护

代码拆分会把一个 JavaScript 包分成较小的块,让浏览器仅加载当前页面需要的代码。假设用户先访问 dropbox.com/home,再访问 dropbox.com/recents:不做拆分时,浏览器一开始就要下载包含全部页面代码的 bundle.js,首次导航可能因此变慢。

拆分之后,首页只下载它需要的块。关键脚本优先加载,非关键脚本随后异步加载、解析和执行;多个页面共用的代码可以缓存在浏览器里,从首页切换到最近文件页时只需再获取新增的部分。

未拆分时首页加载全部模块;拆分后首页加载共享模块和首页模块,切换页面时只新增最近文件模块。
原创技术示意图,依据原文说明绘制;展示代码组织关系,不代表实测流量或时延。

旧打包器没有内建的自动拆分能力,因此工程师得手工定义包。描述“哪个模块放在哪个包里”的映射表是一个超过 6,000 行的大字典。为了避免不合理的打包,团队还建立了严格的打包测试;但每次改动都可能要求开发者重新挪动模块,这套测试也逐渐成了负担。

手工打包还会让页面加载根本用不到的完整模块。例如,包的映射如下:

{
  "pkg-a": ["a", "b"],
  "pkg-c": ["c", "d"]
}

如果页面依赖 a、b、c,浏览器只需请求 pkg-a 和 pkg-c 两个包,而不用为三个模块分别发请求;代价是它也下载了不需要的 d。这里不仅有模块内部未用代码的问题,还有整个模块被多余加载的问题。编辑说明:原文后续说明将第二个包写成了 pkg-b,与映射表不符;此处统一为 pkg-c,并把代码整理为合法 JSON。

没有 tree shaking

Tree shaking 会分析代码的静态结构,移除没有被使用的代码,从而减小产物。假设应用只用了某个第三方库的部分功能,没有 tree shaking 时,该库许多未使用的代码仍可能进入资源包;正确处理依赖和副作用后,打包器才能保留实际需要的部分。

旧打包器不具备这项能力,因此产物里经常包含大段无用代码,第三方库尤其明显。Dropbox 还使用 Protobuf 定义支持前后端高效传输数据;当时有时仅仅增加一些可观测性指标,就会引入数兆字节的额外无用代码,延长页面加载等待时间。

为什么选择 Rollup

团队多年间考虑过不少方案,最终明确的核心需求是自动代码拆分、tree shaking,以及可选的打包流水线优化插件。在当时的候选方案中,Rollup 足够成熟,也最容易灵活地接入既有构建流程。

另一项优势是工程成本。Dropbox 已经在用 Rollup 打包 NPM 模块,虽然尚未充分利用它的功能,但团队对它在自家代码库中的行为和特殊情况已有经验。扩大现有工具的使用范围,比引入一个完全陌生的工具更省力,也减少了不可预见的问题。若在旧打包器中重新实现 Rollup 的这些功能,投入还会更高。

分阶段上线,并随时保留回退能力

替换打包器不能一次性切换。团队需要让两套打包器和两套构建产物同时可靠运行,既要保证产物稳定,也要控制构建系统与 CI 的额外负担,还要让页面所属团队愿意迁移。

  1. 开发者预览:工程师在开发环境中主动启用 Rollup 产物,尽早发现应用行为变化,为修复缺陷和调整范围留出时间。
  2. 员工预览:向 Dropbox 内部员工交付 Rollup 产物,采集初步性能数据,并继续收集行为变化的反馈。
  3. 全面可用:在打包方式经过充分检查、稳定性达到要求后,逐步扩大到全部内外部用户。
  4. 维护:处理迁移留下的技术债,持续改进性能与开发体验。大型项目很难完全不产生技术债,因此应提前给清理工作安排阶段。

团队同时使用基于 cookie 的开关和内部功能开关系统。Dropbox 过去通常只使用后者,此次增加 cookie 开关,是为了在调试时快速切换新旧资源包。每个阶段内部仍分批放量,从 1%、10%、25%、50% 逐步达到 100%。这样既能收集早期性能与稳定性数据,也能在出现破坏性变化时迅速回退,控制受影响人数。

需要迁移的页面很多,安全切换之外,还要解决迁移意愿。团队把 Rollup 定为 Edison 专属能力,并把两个项目的迁移计划绑定起来。Edison 本身也预期会改善性能和开发效率,二者结合可以让页面团队更有动力完成迁移。

构建、兼容性和分块数量带来的阻碍

把 Rollup 串接到既有的 Bazel 构建体系,比预计困难。两套打包器并行运行,资源消耗超出了预估。Rollup 做 tree shaking 时需要把模块载入内存、生成抽象语法树,再分析关系并删除未使用的代码;当时的 Bazel 集成方式又限制了中间结果缓存,CI 每次构建都必须重新生成和压缩所有 Rollup 块。内存耗尽导致构建超时,明显拖慢了上线进度。

团队还遇到了 Rollup 过度移除代码的缺陷。幸运的是,相关轻微问题在开发者预览时就被发现并修复,没有影响用户。另一个兼容性问题来自第三方库:旧打包器曾经交付一些不兼容 JavaScript 严格模式的代码,新打包器启用严格模式后,同样的代码会在浏览器里直接触发运行时错误。团队因此对代码库进行了一次审计,修补不兼容部分。

员工预览阶段,A/B 遥测显示 TTVC(页面视觉完成时间)的改善没有预期明显。最终发现,Rollup 生成的块比旧系统多得多。团队起初认为 HTTP/2 多路复用足以抵消请求数量增加的成本,但块过多仍会让浏览器花更多时间发现页面所需的全部模块。

分块增加也降低了压缩效率。Zlib 之类的压缩算法使用滑动窗口,在这次场景里,对一个较大文件进行压缩,通常比对许多较小文件分别压缩更有效。自动拆分因此并不等于把块切得越小越好,还需要结合依赖发现、请求和压缩的实际成本调整。

最终结果

Rollup 向全部 Dropbox 用户上线后,团队测得 JavaScript 包体积减少 33%,脚本总数减少 15%,TTVC 有小幅改善。自动代码拆分还省去了开发者每次修改后手工调整包定义的工作,提高了前端开发效率。

此次迁移更新了自 2014 年以来的打包基础设施,清理多年技术债,降低了后续维护负担。它也暴露出更多架构瓶颈,包括若干阻塞渲染的 RPC、过多的第三方库函数调用,以及浏览器加载模块依赖图的低效之处。Rollup 的插件生态为进一步处理这些问题提供了便利。

这些数字描述的是 Dropbox 在当时架构、代码与迁移条件下的结果,不是对其他项目的收益承诺。这个案例更值得复用的是方法:先识别打包结构的问题,再让迁移可观察、可逐步放量、可回退,并同时测量浏览器端与构建端的成本。

原文末尾附 Dropbox 招聘信息与社交账号入口,本文保留其招聘链接。网页中的产品推广横幅、导航和页脚不属于技术正文,未纳入译文。本文代码仅作静态审查,未执行或验证 Dropbox 的构建与性能结果。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容