Dropbox:我们如何将 JavaScript 打包程序的大小减少 33% 的
你上一次准备点击网站上的某个按钮时,页面却发生了移动,导致你点击了错误的按钮,这是什么时候的事?或者上一次你怒气冲冲地退出加载时间过长的网页是什么时候?
这些问题在像我们这样内容丰富、交互性强的应用程序中只会更加严重。为支持更复杂的功能而编写的前端代码越多,发送到浏览器进行解析和执行的字节数就越多,性能也就越差。
在 Dropbox,我们深知这种体验是多么令人讨厌。在过去的一年中,我们的网络性能工程团队将一些性能问题归结为一个经常被忽视的罪魁祸首:模块捆绑程序( the module bundler)。
米勒定律(Miller’s Law)指出,人脑在任何时候都只能容纳这么多信息,这也是为什么大多数现代代码库(包括我们的代码库)都被分割成更小的模块的部分原因。模块捆绑程序将应用程序的各种组件(如 JavaScript 和 CSS)合并成捆绑包,然后在加载页面时由浏览器下载。最常见的形式是包含网络应用程序大部分逻辑的 JavaScript 简化文件。
我们的模块捆绑程序的第一次迭代是在 2014 年构思的,当时以性能为先的模块捆绑方法正变得越来越流行(最著名的是 Webpack 和 Rollup,分别在 2012 年和 2015 年)。因此,与更现代的选项相比,我们的模块捆绑器显得非常简陋;我们的模块捆绑器没有进行太多性能优化,工作起来非常繁重,影响了我们的用户体验并降低了开发速度。
现有的捆绑程序显然已经老化,因此我们决定更换捆绑程序,这是优化性能的最佳途径。这也是一个绝佳的时机,因为我们正在将我们的页面迁移到 Edison- 我们的新网络服务栈,这为我们提供了一个利用现有迁移计划的机会,同时也提供了一个架构,使我们能够更简单地将现代捆绑程序集成到我们的静态资产管道中。
现有架构
虽然我们现有的捆绑程序在构建时效率较高,但它会导致大量的捆绑包,并给工程师的维护工作带来负担。我们依靠工程师手动定义哪些脚本要与软件包捆绑在一起,而且我们只是简单地将渲染页面所涉及的所有软件包打包发送,几乎没有进行任何优化。随着时间的推移,这种方法的问题逐渐显现出来:
问题 1:捆绑代码的多个版本
直到最近,我们还在使用一种名为 Dropbox Web Server (DWS) 的自定义 Web 架构。简而言之,每个页面都由多个小页(即页面的子部分)组成,因此每个页面都有多个 JS 入口点,每个 servlet 都由其后台的控制器提供服务。虽然在一个页面由多个团队共同完成的情况下,这加快了部署速度,但有时会导致小页处于不同的后端代码版本上。这就要求 DWS 支持在同一页面上交付不同版本的打包代码,这可能会导致一致性问题(例如,在同一页面上加载多个单例)。迁移到 Edison 后,我们将不再使用这种 pagelet 架构,从而可以灵活地采用更符合行业标准的捆绑方案。
问题 2:手动代码分割
代码分割是将 JavaScript 包分割成小块的过程,这样浏览器只加载当前页面所需的代码库部分。例如,假设用户访问了 dropbox.com/home,然后又访问了 dropbox.com/recents。如果不进行代码分割,就会下载整个 bundle.js,这会大大降低页面初始导航的速度。
所有页面的所有代码都通过一个文件提供
不过,代码分割后,只下载页面所需的代码块。由于浏览器下载的代码较少,这加快了到 dropbox.com/home 的初始导航速度,而且还有其他一些好处。关键脚本首先加载,然后加载、解析和异步执行非关键脚本。浏览器还会缓存共享的代码片段,从而进一步减少在页面之间移动时下载的 JavaScript 数量。所有这些都能大大缩短网络应用程序的加载时间。
只下载页面所需的新块
由于我们现有的捆绑程序没有任何内置的代码分割功能,工程师们不得不手动定义软件包。更具体地说,我们的打包图是一个 6000 多行的大型字典,其中规定了哪个模块包含在哪个包中。
可想而知,随着时间的推移,维护工作变得异常复杂。为了避免出现次优的打包,我们强制执行了一套严格的测试–打包测试,工程师们对这套测试深恶痛绝,因为每次更改都需要手动重新调整模块。
这也导致某些页面所需的代码量大大增加。例如,假设我们有如下的包映射:
{ "pkg-a": ["a", "b"], "pkg-c": ["c", "d"], }
如果一个页面依赖于模块 a、b 和 c,那么浏览器只需要进行两次 HTTP 调用(即获取 pkg-a 和 pkg-b),而不是三次单独的调用,每个模块一次。这样做虽然可以减少 HTTP 调用开销,但往往会导致不得不加载不必要的模块–在本例中就是模块 d。由于缺乏树形晃动,我们不仅在加载不必要的代码,而且还在加载页面不需要的整个模块,从而导致整体用户体验变慢。
问题 3:没有树形晃动(tree shaking)
摇树(tree shaking)是一种捆绑优化技术,通过消除未使用的代码来减小捆绑大小。假设您的应用程序导入了一个包含多个模块的第三方库。如果没有 “摇树”,捆绑代码中的大部分代码都是未使用的。
无论是否使用,所有代码都捆绑在一起
通过树状结构分析,可以分析代码的静态结构,并删除任何不被其他代码直接引用的代码。这样,最终的代码包就会精简很多。
由于我们现有的捆绑程序非常简陋,因此也不具备任何树状结构功能。由此产生的软件包往往包含大量未使用的代码,尤其是来自第三方库的代码,这导致页面加载的等待时间不必要地延长。此外,由于我们使用 Protobuf 定义来实现从前端到后端的高效数据传输,因此对某些可观察性指标进行检测往往会额外引入几兆字节的未使用代码!
为什么选择 Rollup
尽管多年来我们考虑过很多解决方案,但我们意识到,我们的首要需求是拥有某些功能,如自动代码分割、树状结构,以及进一步优化捆绑管道的一些可选插件。Rollup 是当时最成熟的解决方案,也是最能灵活融入我们现有构建管道的解决方案,这就是我们选择它的主要原因。
另一个原因是:工程开销更少。由于我们已经使用 Rollup 来捆绑 NPM 模块(尽管没有它的许多有用功能),因此扩大 Rollup 的使用范围所需的工程开销要比在构建流程中集成一个完全陌生的工具少。此外,这意味着与其他捆绑工具相比,我们的代码库中有更多关于 Rollup 怪癖的工程专业知识,从而降低了出现所谓未知未知因素的可能性。此外,在我们现有的模块捆绑程序中复制 Rollup 的功能需要花费更多的工程时间,而如果我们只是将 Rollup 更深入地集成到我们的构建流程中,则需要花费更多的时间。
推出 Rollup
我们知道,安全、渐进地推出模块捆绑程序并非易事,尤其是我们需要同时可靠地支持两个模块捆绑程序(以及随之产生的两套不同的捆绑程序)。我们主要考虑的问题包括:确保捆绑代码的稳定和无错误、增加构建系统和 CI 的负载,以及如何激励团队选择在他们拥有的页面上使用 Rollup 捆绑程序。
考虑到可靠性和可扩展性,我们将推出过程分为四个阶段:
- 开发人员预览阶段允许工程师选择在其开发环境中使用 Rollup 捆绑程序。这使我们能够有效地进行众包 QA 测试,让开发人员及早发现 Rollup 捆绑程序引入的任何意外应用程序行为,让我们有充足的时间来解决错误和范围变更。
- Dropboxer 预览阶段包括向所有 Dropbox 内部员工提供 Rollup 捆绑包,这使我们能够收集早期性能数据,并进一步收集有关任何应用程序行为变化的反馈。
- 一般可用性阶段包括逐步向所有 Dropbox 用户(包括内部和外部用户)推出。这只有在我们的 “Rollup “打包经过全面测试并认为足够稳定后才会向用户推出。
- 维护阶段包括处理项目中遗留的任何技术债务,并对 Rollup 的使用进行迭代,以进一步优化性能和开发人员体验。我们意识到,如此大规模的项目最终将不可避免地积累一些技术债务,我们应该在某个阶段主动计划解决这些债务,而不是一扫而过。
为了支持每个阶段,我们混合使用了基于 cookie 的门控和我们的内部功能门控系统。从历史上看,Dropbox 的大多数推出工作都是使用内部功能门禁系统完成的;但是,我们决定允许基于 cookie 的门禁系统在 Rollup 和传统软件包之间快速切换,从而加快调试速度。在每个推出阶段中都嵌套了渐进式推出,包括从 1%、10%、25%、50% 到 100%的递增。这使我们能够灵活地收集早期的性能和稳定性结果,并在发生任何破坏性更改时无缝回滚,同时最大限度地减少对内部和外部用户的影响。
由于需要迁移的页面数量庞大,我们不仅需要一个策略将页面安全地切换到 Rollup,还需要激励页面所有者首先进行切换。由于我们的网站堆栈即将与 Edison 一起进行重大改造,我们意识到借助 Edison 的推广可以解决我们的两个问题。如果 Rollup 是 Edison 独有的功能,开发团队就会有更大的动力同时迁移到 Rollup 和 Edison,我们也可以将我们的迁移策略与 Edison 的迁移策略紧密结合起来。
此外,Edison 还有望在性能和开发速度方面有所改进。我们认为,将 Edison 和 Rollup 结合在一起将在整个公司产生巨大的变革协同效应。
挑战和障碍
虽然我们预计会遇到一些意想不到的挑战,但我们意识到,将一个构建系统(Rollup)与另一个构建系统(我们现有的基于 Bazel 的基础架构)进行菊花链式连接,证明比预期更具挑战性。
首先,同时运行两个不同的模块捆绑程序所耗费的资源比我们预计的要多。Rollup 的树形抖动算法虽然已经相当成熟,但仍需将所有模块加载到内存中,并生成分析关系和抖动代码所需的抽象语法树。此外,我们将 Rollup 集成到 Bazel 中,也限制了我们缓存中间构建结果的能力,这就要求我们的 CI 在每次构建时都要重建和重新最小化所有 Rollup 块。这导致我们的 CI 构建因内存耗尽而超时,大大延迟了上线时间。
我们还发现 Rollup 的树形晃动算法存在几个错误,导致树形晃动过于剧烈。值得庆幸的是,这只是在开发人员预览阶段发现并修复的小错误,不会对我们的用户造成影响。此外,我们还发现旧版捆绑程序提供的一些第三方库代码与 JavaScript 的严格模式不兼容。在启用严格模式的情况下,通过新的捆绑程序提供同样的代码会导致浏览器出现运行时错误。这就要求我们对整个代码库进行一次性审计,并修补与严格模式不兼容的代码。
最后,在 Dropboxer 预览阶段,我们发现 Rollup 和传统捆绑程序之间的 A/B 遥测指标并没有像我们预期的那样显示出 TTVC 的改进。我们最终将原因归结为 Rollup 产生的数据块比传统打包程序产生的数据块多得多。虽然我们最初假设 HTTP2 的多路复用会抵消更多的分块带来的性能下降,但我们发现,分块过多会导致浏览器在发现页面所需的所有模块时花费更多时间。增加分块数量还会导致压缩效率降低,因为 Zlib 等压缩算法使用的是滑动窗口压缩方法,因此一个大文件的压缩效率要高于多个小文件。
成果
在向所有 Dropbox 用户推出 Rollup 后,我们发现该项目将 JavaScript 捆绑程序的大小减少了 33%,JavaScript 脚本总数减少了 15%,TTVC 也有适度改善。通过自动代码拆分,我们还大大提高了前端开发速度,开发人员无需在每次更改时手动调整捆绑定义。最后,或许也是最重要的一点,我们将捆绑基础架构带入了现代化,削减了自 2014 年以来积累的多年技术债务,减轻了我们未来的维护负担。
除了影响巨大的推广之外,Rollup 项目还揭示了我们现有架构中的几个瓶颈–例如,多个渲染阻塞 RPC、对第三方库的过多函数调用,以及浏览器加载模块依赖关系图的低效方式。考虑到 Rollup 丰富的插件生态系统,解决这些瓶颈问题在我们的代码库中从未如此简单。
总之,完全采用 Rollup 作为我们的模块捆绑程序,不仅能立即提高性能和生产率,还能在未来大幅提高性能。
本文文字及图片出自 How we reduced the size of our JavaScript bundles by 33%