【译】我们如何将 JavaScript 捆绑程序的大小减少 33%

上一次你准备点击一个网页上的按钮,结果页面突然移动了,导致你误点击了另一个按钮是什么时候?或者上一次你因为页面加载时间太长而愤怒地退出了网页是什么时候?

在像我们的应用程序这样富有交互性的应用中,这些问题只会变得更加严重。为了支持更复杂的功能,前端代码编写得越复杂,就会发送更多字节到浏览器进行解析和执行,性能问题就会变得越严重。

在Dropbox,我们理解这种体验有多么令人恼火。在过去的一年里,我们的Web性能工程团队将一些性能问题追溯到一个经常被忽视的罪魁祸首:模块打包工具。

米勒定律发现,人脑在任何给定时间内只能容纳有限的信息量,这也部分解释了为什么大多数现代代码库(包括我们自己的)都被分成了较小的模块。 模块打包工具将应用程序的各种组件(如JavaScript和CSS)合并为包,然后在页面加载时由浏览器下载。

最常见的形式是一个包含大部分Web应用程序逻辑的缩小的JavaScript文件。

我们的模块打包工具的第一版最早诞生于2014年,当时以性能为先的模块打包方法开始变得更加流行(最著名的是Webpack和Rollup分别于2012年和2015年)。 因此,相对于更现代的选项,它相对基础;我们的模块打包工具没有整合许多性能优化,使用起来相对繁琐,妨碍了用户体验并减缓了开发速度。

随着现有的打包工具明显显露出时效性问题,我们发现最佳的性能优化方式是进行替换。那也正是最合适的时机,因为我们当时正在将我们的页面迁移到Edison——我们的新网页服务堆栈——这提供了一个机会来借助现有的迁移计划,并提供了一个更容易将现代打包工具整合到我们的静态资源流程中的架构。

现有架构

尽管我们现有的打包工具在构建时相对高效,但导致了庞大的捆绑包,并且对工程师来说维护工作繁重。

我们依赖工程师手动定义要与包一起捆绑的脚本,简单地将涉及页面渲染的所有包一起提供,几乎没有进行优化。随着时间的推移,这种方法的问题变得明显:

问题1:捆绑代码的多个版本

直到最近,我们使用了一种名为Dropbox Web Server(DWS)的自定义Web架构。简而言之,每个页面由多个页面片段(即页面的子部分)组成,因此每个页面都有多个JS入口点,每个Servlet由后端自己的控制器提供服务。

虽然这在多个团队同时开发同一页面时加快了部署速度,但有时会导致页面片段使用不同的后端代码版本。

这要求DWS支持在同一页面上交付打包代码的不同版本,这可能会导致一致性问题(例如,在同一页面上加载多个单例的实例)。

我们迁移到Edison将取消这种页面片段架构,我们能够采用更符合行业标准的捆绑方案。

问题2:手动代码拆分

代码拆分是将JavaScript捆绑包拆分成较小的块的过程,以便浏览器仅加载当前页面所需的代码库部分。例如,假设用户访问 dropbox.com/home,然后访问 dropbox.com/recents。 如果没有进行代码拆分,将下载整个bundle.js,这可能会显著减慢导航到页面的初始速度。

【译】我们如何将 JavaScript 捆绑程序的大小减少 33%

然而,在进行了代码拆分之后,只有页面需要的代码块会被下载。这加快了导航到 dropbox.com/home 的初始速度,因为浏览器只下载了较少的代码,并且还带来了几个额外的好处。

关键脚本会首先加载,然后非关键脚本会被异步加载、解析和执行。浏览器还会缓存共享的代码片段,进一步减少了在页面之间切换时下载的JavaScript量。

上述所有操作可以极大地减少Web应用程序的加载时间。

【译】我们如何将 JavaScript 捆绑程序的大小减少 33%

由于我们现有的打包工具没有内置的代码拆分功能,工程师不得不手动定义包。更具体地说,我们的打包映射是一个庞大的6000多行字典,指定了哪些模块包含在哪个包中。

正如你可以想象的那样,随着时间的推移,这变得难以维护。为了避免不合理的打包设置,我们实施了一套严格的测试——打包测试,这些测试令工程师感到害怕,因为它们经常需要在每次更改时手动重新排列模块

这也导致了比某些页面需要的代码更多。例如,假设我们有以下的包映射:

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

如果一个页面依赖于模块a、b和c,浏览器只需要进行两次HTTP调用(即获取pkg-a和pkg-b),而不是三次单独的调用,每个模块一次。虽然这可以减少HTTP调用的开销,但通常会导致加载不必要的模块,比如在这种情况下,模块d。我们不仅因为没有进行tree shaking而加载了不必要的代码,还加载了整个对于页面不必要的模块,从而导致整体用户体验变慢。

问题3:没有tree shaking

tree shaking是一种捆绑优化技术,通过消除未使用的代码来减小捆绑包大小。假设您的应用程序导入了一个包含多个模块的第三方库。如果没有tree shaking,捆绑的代码中将有很多未使用的部分。

【译】我们如何将 JavaScript 捆绑程序的大小减少 33%
通过tree shaking技术,静态结构的代码会被分析,任何没有被其他代码直接引用的代码都会被删除。这会导致最终生成的捆绑包更加精简。
【译】我们如何将 JavaScript 捆绑程序的大小减少 33%

由于我们现有的打包工具相对简陋,也没有tree shaking功能。结果生成的包经常包含大量未使用的代码,尤其是来自第三方库的代码,这导致页面加载的等待时间不必要地变长。

而且,由于我们在前端到后端的高效数据传输中使用了Protobuf定义,添加某些可观测性指标通常会引入数兆字节的未使用代码!

为什么选择 Rollup?

尽管多年来我们考虑了许多解决方案,但我们意识到我们的主要需求是具备自动代码拆分、tree shaking等特定功能,以及可选的一些插件来进一步优化捆绑流程。 当时,Rollup是最成熟且最灵活的选择,可以很容易地整合到我们现有的构建流程中,这主要是我们选择它的原因。

另一个原因是减少工程师的工作负担。由于我们已经在捆绑我们的NPM模块时使用了Rollup(尽管没有使用其中许多有用的功能),扩大我们对Rollup的采用比在我们的构建流程中整合一个完全陌生的工具需要更少的工程师工作量。

此外,这意味着我们在代码库中对于Rollup的特殊情况有更多的工程经验,而不是其他捆绑工具,从而减少了所谓的未知因素的可能性。

此外,在我们现有的模块打包工具内复制Rollup的功能将需要比将Rollup更深入地集成到我们的构建流程中花费更多的工程时间。

Rollup的推出

我们知道,安全而逐步地推出一个模块打包工具绝非易事,特别是因为我们需要同时可靠地支持两个模块打包工具(因此也是两组不同的生成捆绑包)。

我们的主要关注点包括确保捆绑的代码稳定且无错误,对我们的构建系统和持续集成(CI)增加的负载,以及如何激励团队选择使用他们所拥有的页面的Rollup捆绑包。

考虑到可靠性和可扩展性,我们将推出过程分为四个阶段:

  • 开发者预览阶段允许工程师在其开发环境中选择使用Rollup捆绑包。 这使我们能够通过让开发人员尽早发现由Rollup捆绑包引入的任何意外应用程序行为,有效地进行QA测试,为我们提供了足够的时间来解决错误和范围变更。
  • Dropbox员工预览阶段涉及向所有内部Dropbox员工提供Rollup捆绑包,这使我们能够收集早期性能数据,并进一步收集有关应用程序行为变更的反馈意见。
  • 一般可用性阶段逐渐向所有Dropbox用户(包括内部和外部用户)推出。只有在我们的Rollup打包经过彻底测试,并被认为对用户足够稳定后,才会发生这种情况。
  • 维护阶段涉及解决项目中剩余的技术债务,并迭代我们对Rollup的使用,以进一步优化性能和开发者体验。 我们意识到,规模如此庞大的项目不可避免地会积累一些技术债务,我们应该主动计划在某个阶段解决它,而不是将其掩盖。

为支持这些阶段,我们采用了基于Cookie的控制和我们内部的功能控制系统的混合方式。 在Dropbox的历史上,大多数推出都是通过我们的内部功能控制系统自动完成的。然而,我们决定允许基于Cookie的控制,以快速切换到Rollup和传统捆绑包之间,从而加快了调试过程。在每个推出阶段内,都包含了逐渐推出的过程,从1%、10%、25%、50%到100%逐步增加比例。这使我们有了灵活性来收集早期的性能和稳定性结果,以及在出现任何破坏性变化时无缝回退,同时最小化对内部和外部用户的影响。

由于我们需要迁移的页面数量众多,我们不仅需要一种安全地将页面切换到Rollup的策略,还需要激励页面所有者首先进行切换。由于我们的Web堆栈即将在Edison的重大改造中经历一次重大变革,我们意识到可以借用Edison的推出来解决我们的两个问题。如果Rollup是Edison的专有功能,开发团队将更有动力同时迁移到Rollup和Edison,而且我们还可以将我们的迁移策略与Edison的策略紧密结合起来。这样做有助于解决页面切换和性能优化的问题。

同时,我们预计Edison将带来性能和开发速度的改进。我们认为将Edison和Rollup捆绑在一起将在整个公司内产生强烈的变革协同效应。

挑战和障碍

虽然我们确实预料到会遇到一些意外的挑战,但我们意识到将一个构建系统(Rollup)与另一个(我们现有的基于Bazel的基础设施)串联起来的难度超出了我们的预期。

首先,同时运行两个不同的模块捆绑工具比我们估计的消耗更多资源。Rollup的tree shaking算法虽然相当成熟,但仍然需要将所有模块加载到内存中,并生成用于分析关系和优化代码的抽象语法树。此外,我们将Rollup整合到Bazel中,限制了我们能够缓存中间构建结果,这需要我们的CI在每次构建时重新构建和重新压缩所有Rollup块。这导致我们的CI构建因内存不足而超时,并且大大延迟了推出时间。

此外,我们发现了一些与Rollup的tree shaking算法相关的错误,导致tree shaking过于激进。幸运的是,这只导致了一些小问题,在开发者预览阶段被捕捉到并修复,未影响到我们的用户。另外,我们发现我们的传统捆绑工具在服务于一些来自第三方库的代码时与JavaScript的严格模式不兼容。通过新的捆绑工具启用严格模式来服务相同的代码会导致浏览器中出现严重的运行时错误。这要求我们对整个代码库进行审核,并修复与严格模式不兼容的代码。

最后,在Dropbox员工预览阶段,我们发现Rollup和传统捆绑工具之间的A/B遥测指标并没有显示出我们预期的TTVC(首次可见内容时间)改进那么多。最终,我们将这个问题缩小到Rollup生成的块数量比我们的传统打包工具生成的要多得多。尽管最初我们假设HTTP2的多路复用会抵消来自更多块的性能下降,但我们发现太多的块会导致浏览器在查找页面所需的所有模块时花费更多的时间。增加块的数量也会导致压缩效率降低,因为像Zlib这样的压缩算法使用滑动窗口方法进行压缩,会导致一个大文件比许多小文件具有更高的压缩效率。

结果

在向所有Dropbox用户推出Rollup之后,我们发现这个项目将我们的JavaScript捆绑包大小减少了33%,总JavaScript脚本数量减少了15%,减少了用户等待页面内容显示的时间,从而提高了用户体验。通过自动代码拆分,我们还极大地提高了前端开发速度,消除了开发人员需要在每次更改时手动重新整理捆绑定义的需求。最后但也许最重要的是,我们将捆绑基础设施引入了现代化,并削减了自2014年以来积累的技术债务,减轻了未来的维护负担。

除了具有高度影响力的推出之外,Rollup项目还揭示了我们现有架构中的一些瓶颈,例如,一些阻塞呈现的远程过程调用(RPC)、对第三方库的大量函数调用以及浏览器加载我们的模块依赖图的低效性。鉴于Rollup拥有丰富的插件生态系统,在我们的代码库中解决这些瓶颈从未如此简单。

总的来说,完全采用Rollup作为我们的模块捆绑工具不仅带来了即时的性能和生产力提升,还将在未来释放出重大的性能改进潜力。

原文链接:https://juejin.cn/post/7347520776226471990 作者:两根头发一个中分

(0)
上一篇 2024年3月20日 下午4:33
下一篇 2024年3月20日 下午4:43

相关推荐

发表回复

登录后才能评论