不知道各位朋友是否还记得,上一次正打算点击网站上的按钮、结果页面突然变化导致你点上了错误的位置是什么时候。或者说,上一次你因为实在忍受不了缓慢的加载速度而愤然点叉又是在什么时候?
这些问题在如今内容愈发丰富、交互度越来越高的应用场景中被无限放大。为了支持更复杂的功能,我们不得不编写出更多的前端代码,导致浏览器端需要接收、解析和执行的字节更多,最终性能自然变得更差。
在 Dropbox,我们深深了解此等糟糕体验是多么令人崩溃。所以过去一年来,我们的 Web 性能工程团队抽丝剥茧、将性能问题溯源到了一个常常被忽视的元素身上:模块捆绑器。
米勒定律认为,人脑在任何给定的时间内只能容纳一定量的信息,所以大部分现代代码库(包括我们 Dropbox 的代码库)才会被拆分成一个个更小的模块。模块捆绑器负责把应用程序中的各类组件(例如 JavaScript 和 CSS)合并成捆绑包,并在页面加载时由浏览器下载这些捆绑包。最常见的处理方式就是将捆绑包保存为最小 JavaScript 文件的形式,用以存放 Web 应用程序中的大部分逻辑。
Dropbox 模块捆绑器的首次迭代设计于 2014 年,当时以性能为先的模块捆绑方法才刚刚兴起(分别在 2012 年和 2015 年由 Webpack 和 Rollup 率先提出)。但毕竟年代久远,那时候的方案跟现代设计比起来还是太过简陋。我们的模块捆绑器并没多少性能优化,使用起来比较繁琐,既影响用户体验又会拖慢开发速度。
随着捆绑器逐渐显露老态,我们决定面向未来做好性能优化、全面替换掉这位应当功成身退的老将。当前也是替换的最佳时机,因为我们正好在着手将页面迁移至 Edison(我们的全新 Web 服务栈),统筹规划有望一箭双雕。替换之后,我们的静态资产管线也将迎来更现代的捆绑器,在架构层面让集成更为简单。
现有架构
虽然我们原本的捆绑器拥有相对较快的构建速度,但也存在着不少短板,包括捆绑包太过臃肿、工程师们感到难以维护等等。工程师们只能手动定义把哪些脚本跟包捆绑在一起,而且我们之前只简单提供页面渲染所需要的包,但几乎未做任何性能优化。随着时间推荐,这种粗糙的方案也带来了以下几大显著问题。
问题一:捆绑代码有好几个版本
直到不久前,我们还在使用名为 Dropbox Web Server(DWS)的自定义 Web 架构。简单来讲,每个页面都由多个小页(pagelet,即页面中的子部分)组成,因此导致每个页面都有多个 JS 入口点,而各 servlet 也由后端处对应的控制器提供服务。虽然这种部署在多个团队同时处理同一页面时速度更快,但也往往导致 pagelet 指向不同的后端代码版本。这就要求 DWS 能支持在同一页面上交付不同版本的打包代码,而这经常会引发一致性问题(例如在同一页面上加载相同单例的多个实例)。我们向 Edison 的迁移将消除这种 pagelet 架构,从而更灵活地采取更符合行业标准的捆绑方案。
问题二:需要手动分割代码
所谓代码分割,就是把 JS 包分割成更小的块的过程,这样浏览器就能只加载当前页面所需要的代码库部分。例如,假设用户先访问 dropbox.com/home,而后访问 dropbox.com/recent,那么如果不进行代码分割,则浏览器会下载整个 bundle.js,这无疑将显著减慢页面的初始导航速度。
所有页面的全部代码均通过单一文件提供
但在代码分割之后,浏览器只需要下载页面所需要的各个代码块。由于浏览器下载的代码量更少,所以 dropbox.omc/home 的初始导航速度将大大提升。此外,代码分割可以保证先加载关键脚本,而后再异步加载、解析和执行非关键脚本。共享代码片段也将被浏览器缓存下来,进一步减少用户在不同页面间移动时所需下载的 JS 代码量。所有这些,都将大大减少 Web 应用程序的加载时间。
仅下载页面所需的新代码块
由于我们现在的捆绑器没有任何内置的代码分割工具,所以工程师只能手动对包做定义。具体来讲,我们的打包 map 是个 6000 多行的庞大字典,具体指定了哪些模块该放进哪个包中。
可以想见,随着时间推移这样一套架构的维护工作将变得异常复杂。为了避免非优打包,我们强制执行了一套严格的测试(打包测试),但因为每次变更都可能打乱原本的模块排列,所以工程师们变得神经紧张、苦不堪言。
这也导致我们的实际代码量比页面所需要多得多。例如,假定我们有以下包 map:
如果页面依赖于模块 a、b 和 c,则浏览器只须进行两次 HTTP 调用(分别获取 pkg-a 和 pkg-b),而非对各模块各进行一次(共三次)调用。这虽然会减少 HTTP 调用的开销,但同时也会加载不必要的模块——在本示例中就是模块 d。由于缺乏摇树优化,我们不但加载了不必要的代码,还加载了页面不需要的整个模块,因此会拖慢整体用户体验。
问题三:缺少摇树优化
摇树是一种包优化技术,能够消除未使用的代码来帮助捆绑包瘦身。假设我们的应用程序需要导入包含多个模块的第三方库,如果没有摇树优化,则实际加载的大部分捆绑代码其实都毫无用处。
无论是否实际使用,所有代码都会被捆绑进来
通过摇树优化,我们可以分析代码的静态结构,并删除一切未被其他代码直接引用的代码。这样最终的捆绑包就能更加精简小巧。
只捆绑要使用的代码
因为我们之前的捆绑器不太完善,所以其中没有任何摇树功能。生成的包往往包含大量未使用代码,特别是来自第三方库的代码,这会导致页面加载无用内容、延长等待时间。此外,因为我们使用 protobuf 定义来实现从前端到后端的高效数据传输,所以在检测某些可观察性指标时往往要引入高达几 MB 的未使用代码!
为何选择 Rollup
多年来我们其实考虑过不少解决方案,并最终把核心需求梳理了出来:我们真正需要的,就只有自动代码分割、摇树优化,以及可以进一步优化捆绑管线的可选插件。Rollup 就是当前最成熟、也能灵活融入到我们现有构建管线的工具,于是最终成为我们的首选解决方案。
另一个原因是:有助于降低工程开销。因为我们已经在使用 Rollup 捆绑我们的 NPM 模块,所以继续扩大 Rollup 的使用范畴肯定比再引入新工具要划算得多。此外,这也意味着跟其他捆绑器相比,我们已经在之前的运营中掌握了更多关于 Rollup 特性的工程专业知识,能有效降低用不下去的可能性。最后,我们还算了一笔账,发现跟深入集成 Rollup 相比,在原有模块捆绑器中重现 Rollup 的功能需要投入更多工程资源。
不负众望的 Rollup
我们都知道,安全、分步推出模块捆绑器绝非易事,毕竟我们在期间需要同时可靠支持两种模块捆绑器(并生成两种对应的捆绑包)。我们主要关心的问题包括如何保证捆绑代码稳定、无 bug,如何增加构建系统和 CI 的负载,还有怎样激励团队接受在其页面中使用 Rollup 捆绑包。
考虑到可靠性和可扩展性等问题,我们把发布过程分成了四个阶段。
开发者预览阶段:允许工程师在开发环境中选择加入 Rollup 捆绑包。这样我们就能让开发者尽早发现 Rollup 捆绑包引发的任何意外,借此推动行之有效的众包 QA 测试。认真收集相关信息后,我们将有充足的时间解决 bug、适应范围变更。
面向 Dropbox 员工的内部预览阶段将全面推广 Rollup 捆绑包,借此收集早期性能数据并进一步获取关于应用程序行为变化的实践反馈。
通用阶段,即逐步向所有 Dropbox 用户(包括内部和外部用户)推出 Rollup 捆绑包。在此之前,我们已经对 Rollup 包做过彻底测试并确定其稳定性已经达到较高水平。
维护阶段,强调解决项目中遗留的所有技术债,再通过迭代让 Rollup 进一步优化性能和开发者体验。我们意识到,如此规模的大体量项目将不可避免地积累下一些技术债,我们应计划在某个阶段将其解决,而不能假装债务不存在。
为了有效支持各个阶段,我们混合使用了基于 cookie 的门控和内部功能门控系统。以往,Dropbox 的大多数部署都纯粹借助我们的内部功能门控系统得以完成。但这一次,我们决定允许基于 cookie 的门控在 Rollup 和旧捆绑包间快速切换,从而加快调试速度。每个发布阶段都以交替形式分步推出,包括从 1%、10%、25%,到 50%乃至最终的 100%。这让我们能够灵活地收集早期性能与稳定性结果,当发现问题时进行无缝回滚,同时尽可能降低对内、外部用户造成的影响。
因为我们需要迁移大量页面,所以除了建立安全可靠的 Rollup 切换策略之外,还得激励页面所有者主动执行切换。由于我们的 Web 栈将配合 Edison 进行一波重大改造,所以这应该是个可以一箭双雕的绝佳时机。如果把 Rollup 塑造成 Edison 所支持的独特功能,那开发团队应该会更愿意同时接受 Rollup 和 Edison,我们也能借此将 Rollup 的迁移策略跟 Edison 升级紧密绑定起来。
Edison 也有望借此提高自己的性能和开发速度。我们认为,将 Edison 与 Rollup 相结合,会在整个公司内产生强烈的转型协同效应。
挑战与障碍
我们早就做好了迎接意外挑战的准备,但事实证明将一种构建系统(Rollup)跟另一种构建系统(基于 Bazel 的原有基础设施)进行复杂对接,其挑战性要远远大于我们的任何想象。
首先,我们发现同时运行两种不同模块捆绑器,所消耗的资源要远超我们的估计。Rollup 的摇树算法虽然相当成熟,但仍需要将所有模块都先加载到内存中,之后生成分析关系并摇出代码所需的抽象语法树。此外,我们将 Rollup 集成到 Bazel 中的作法,限制了我们缓存中间构建结果的能力。也就是说,我们需要持续集成以重建并重新缩小每个构建上的全部 Rollup 块。这导致我们的持续集成构建因内存耗尽而超时,显著拖慢了部署节奏。
我们还发现了 Rollup 摇树算法中的几个 bug,这会导致摇树优化过于激进。值得庆幸的是问题不大,我们在开发者预览阶段就将其修复,所以最终用户并未受到影响。此外,我们发现旧版捆绑程序会提供来自第三方库的某些代码,而这些代码与 JS 严格模式并不兼容。一旦将这些代码提交给采用严格模式的新捆绑器,则会在浏览器中引发极为严重的运行时错误。这就要求我们对整个代码库、特别是与严格模式不兼容的补丁代码,开展一轮全面审计。
最后,在 Dropbox 内部员工预览阶段,我们发现 Rollup 和旧版捆绑器之间的 A/B 遥测指标并未体现出符合预期的 TTVC 性能提升。我们最终意识到,这是因为 Rollup 生成的代码块比旧版捆绑器生成的代码块要多得多。尽管我们最初假设 HTTP2 的多路复用能消除大量代码块引发的性能下降,但事实证明代码块过多还是会导致浏览器耗费更长的时间来获取页面所需的各模块。再有,模块数量的增加也会拉低压缩效率,因为 Zlib 等压缩算法使用的是滑动窗口方法执行压缩,就是说单一大文件的压缩效率要明显好于多个小文件。
最终结果
在向全体 Dropbox 用户推出 Rollup 之后,我们发现新项目将 JavaScript 包缩小了约三分之一,JS 脚本总量减少了 15%,TTVC 也实现了适度改进。我们还通过自动代码分割显著提高了前端开发速度,开发人员现在不必在每次变量时都手动调整捆绑包定义。最后,也可能是最重要的一点在于,我们完成了捆绑基础设施的现代化改造,削减了自 2014 年以来积累的大量技术债,显著减轻了未来的项目维护负担。
除了令人眼前一亮的实践表现之外,Rollup 项目还帮助我们发现了现有架构中的几个瓶颈:例如多个渲染会阻塞 RPC,对第三方库的函数调用过多,以及浏览器加载模块依赖性 map 效率太低等。凭借 Rollup 丰富的插件生态系统,解决原有代码库中此类瓶颈正变得越来越简单。
总而言之,全面采用 Rollup 作为模块捆绑器不仅给性能和生产力带来立竿见影的提升,也将在未来帮助 Dropbox 实现更为显著的性能改进。
评论