原文地址:dropbox.tech/frontend/ho… 本文你可以学习到:如何思考一个技术项目、如何推广一个技术项目和代码体积优化思路。
本文没有很多体积压缩细节方面的干货,更多地是从一个实际项目中去发现、思考、落地和推广问题。不过这个方面也是一个通用能力,具备可迁移性,按需阅读。
还记得上次你在网页中点击一个按钮,结果因为页面发生变化导致你点击错了按钮是什么时候吗?或者你上次因为页面加载时间过长而不得不退出是什么时候呢?
这些问题会在我们使用的丰富交互的应用中被放大,越多的复杂功能被支持就要写越多的前端代码,进而越多地字节被发送到浏览器,最终带来越多地解析和执行,也就会带来更差的性能体验。
在Dropbox,我们深知这样的体验是多么的让人苦恼,经过过去几年的努力,我们的web性能功能团队将一些性能问题归结为一个经常被忽视的罪魁祸首:模块包(module bundler)。
米勒法则表明人类大脑在任何时候所能接收到的信息是有限的——这也就是为什么大多数现代代码库(包括我们的代码库)被分解成更小的模块的部分原因。一个模块包囊括了一个应用的许多组件——像是JS和css——最终被合并成包,这些包最终在浏览器加载页面的时候进行加载。更为常见的是,被加载的都是一种压缩后的文件,这些文件包含了应用的大部分逻辑。
我们的模块包的第一次迭代早在2014年就已经构思好了,当时模块包的性能优先方法正变得越来越流行(最引人注目的是Webpack和Rollup分别在2012年和2015年)。出于这个原因,与更现代的选择相比,它是相当简单的;我们的模块bundler没有包含太多的性能优化,而且使用起来很麻烦,这阻碍了我们的用户体验,降低了开发速度。
随着我们现有的bundler越来越明显,我们决定未来优化性能的最佳方法是更换它。这也是一个完美的时机,因为我们正在将页面迁移到Edison-我们的新web服务堆栈,这提供了一个利用现有迁移计划的机会,也提供了一种架构,使将现代包集成到我们的静态资源中变得更简单。
1、当前架构
当前的构建包是聚焦在构建效率方面,因此这个包的体积会变得很大,同时也给开发人员带来了维护成本。我们需要依赖工程师手动定义哪些脚本与包构建在一起,然后我们只需要提供当前页面渲染所需要的所有包,进而不需要进行任何优化。随着时间,这种方式带来的问题越发明显:
1.1、问题一:多个版本的包代码
直到最近我们都在使用一种称为Dropbox Web Server(DWS)的自定义web架构。简而言之就是,每个页面都有多个小页面组成,最终导致每个页面都有多个Js入口文件,每个文件都在各自的服务的控制器里面提供。虽然这在多个团队维护页面的情况下加快了部署,但有时会导致小页面位于不同的后端代码版本。这要求DWS支持在同一页面上交付单独版本的打包代码,这可能会导致一致性问题(例如,在同一页上加载单个实例的多个实例)。我们迁移到Edison将消除这种小页面架构,使我们能够灵活地采用更符合行业标准的打包方案。
1.2、问题二:手动代码分割
代码分割是将JS包分割成更小代码块的一个过程,进而浏览器只会加载当前页面必要的那一部分代码。例如:假设一个用户访问了dropbox.com/home ,然后访问dropbox.com/recents 。如果没有代码分割,一整个文件包会被下载,这会明显地降低首次访问页面的速度。
所有页面的代码通过一个单文件来提供
通过代码分割以后,浏览器只会加载当前页面所需要的代码块。这当然加速了初次访问dropbox.com/home 的速度,因为浏览器加载了更少的代码,同时也有一些额外的好处。关键的脚本会首先被加载,然后异步地加载非关键脚本、解析脚本和执行脚本。共享的代码片段也会被浏览器缓存,进一步减少了浏览器切换页面的时候下载js的体积。以上这些会显著地减少web应用的加载时间。
只有当前页面需要的新代码块会被下载
由于我们当前模块包没有任何内置的代码分割能力,工程师需要手动定义包。更具体地说,我们的打包图是一个6000多行的大字典,它指定了哪些模块包含在哪个包中。
你可以想象,随着时间这里面的维护愈发变得难以置信的复杂。为了避免次优封装,我们强制执行了一组严格的测试——封装器测试——这让工程师们感到害怕,因为每次更改都需要手动重新排列模块。
这同样也会导致相较于实际页面需要的更多地代码,例如,假设我们有如下的打包图:
{
"pkg-a": ["a", "b"],
"pkg-b": ["c", "d"]
}
如果页面需要模块a,b和c,浏览器只需要执行两次Http请求(fetch pkg-a和pkg-b)而不是三次独立的请求。虽然这会减少Http的请求次数,但是也会导致加载了非必要的模块——在这个案例中,则是模块d。由于缺少tree shaking,浏览器会加载非必要的代码,同时也会加载某个页面不需要的所有模块,这很显然会影响用户体验。
1.3、问题三:没有tree shaking
Tree shaking是一种通过减少无用代码来减少包体积的包优化技术。假设你的应用引用了一个包含多个模块的三方库,如果没有tree shaking,很多的代码都是无用的。
所有的代码都会被打包,不管你用不用
通过tree shaking,会分析代码的静态结构,任何不被其他代码直接引用的代码会被删除掉,这会导致最终的代码体积更小。
只有被使用到的代码会被打包引用
由于我们现有的bundler是最基本的,所以也没有任何tree shaking的功能。生成的包通常会包含大量未使用的代码,尤其是来自第三方库的代码,这会导致页面加载的等待时间不必要地延长。此外,由于我们使用Protobuf定义来实现从前端到后端的高效数据传输,因此检测某些可观察性指标通常会导致额外的几兆字节未使用代码!
2、为何是Rollup
尽管多年来我们考虑了许多解决方案,但我们意识到,我们的主要需求是具有某些功能:如自动代码分割、tree-shaking,以及一些用于进一步优化和打包的插件。Rollup是当时最成熟的,也最灵活地融入我们现有的构建流程中,这也是我们决定采用它的主要原因。
另一个原因是:工程开销减少。由于我们已经在使用Rollup来打包我们的NPM模块(尽管没有许多有用的功能),因此与在构建过程中集成一个完全陌生的工具相比,扩展我们对Rollup的使用将需要更少的工程开销。此外,与其他打包工具相比,我们在代码库中有更多的知识和经验来处理rollup的异常,从而降低了所谓未知的可能性。此外,在我们现有的模块打包中复制Rollup的功能将需要比我们在构建过程中更深入地集成Rollup多得多的工程时间。
3、Rollup推出
我们知道,安全、逐步地推出模块打包器绝非易事,尤其是因为我们需要同时可靠地支持两个模块打包器(因此,需要两组不同的生成打包器)。我们主要关心的问题包括确保稳定且无错误的打包代码,增加我们构建系统和CI的负载,以及我们如何激励团队为他们拥有的页面选择使用Rollup模块包。
考虑到可靠性和可扩展性,我们将推出过程分为四个阶段:
开发人员预览阶段允许工程师在他们的开发环境中选择使用Rollup打包。这使我们能够有效地打包QA测试,让开发人员在早期发现Rollup打包引入的任何意外应用程序行为,给我们足够的时间来解决错误和范围更改。Dropboxer预览阶段包括向所有Dropbox内部员工提供Rollup打包,这使我们能够收集早期性能数据,并进一步收集任何应用程序行为变化的反馈。总体可用性阶段包括逐步向所有Dropbox用户推出,包括内部和外部用户。只有当我们的Rollup包装经过彻底测试并被认为对用户来说足够稳定时,才会发生这种情况。维护阶段包括解决项目中遗留的任何技术债务,并反复使用Rollup来进一步优化性能和开发人员体验。我们意识到,如此大规模的项目最终将不可避免地积累一些技术债务,我们应该积极计划在某个阶段解决这一问题,而不是将其掩盖起来。
为了支持每一个阶段,我们使用了基于cookie的门控和我们内部的功能门控系统。从历史上看,Dropbox的大多数推广都是使用我们内部的功能门控系统完成的;然而,我们决定允许基于cookie的门控在Rollup和遗留包之间快速切换,这加快了调试速度。嵌套在每个推出阶段中的是逐步推出,包括从1%、10%、25%、50%上升到100%。这使我们能够灵活地收集早期性能和稳定性结果,并在发生任何突破性更改时无缝回滚,同时最大限度地减少对内部和外部用户的影响。
由于我们必须迁移大量页面,我们不仅需要一种策略来安全地将页面切换到Rollup,而且还需要从一开始就激励页面所有者进行切换。由于我们的网络堆栈即将与爱迪生一起进行重大翻新,我们意识到依靠爱迪生的推出可以解决我们的两个问题。如果Rollup是Edison独有的功能,那么开发团队将更有动力迁移到Rollup和Edison,我们也可以将我们的迁移策略与Edison的紧密结合起来。
爱迪生也被期望有自己的性能和发展速度的提高。我们认为,将Edison和Rollup结合在一起,将在整个公司产生强烈的变革协同效应。
4、挑战和阻碍
虽然我们确实预计会遇到一些意想不到的挑战,但我们意识到,将一个构建系统(Rollup)替换另一个(我们现有的基于Bazel的基础设施)比预期的更具挑战性。
首先,事实证明,同时运行两个不同的模块打包器比我们估计的更耗费资源。Rollup的tree shaking算法虽然相当成熟,但仍然需要将所有模块加载到内存中,并生成分析关系和shaking代码所需的抽象语法树。此外,我们将Rollup集成到Bazel中,限制了我们缓存中间构建结果的能力,要求我们的CI在每个构建中重建并重新缩小所有Rollup块。这导致我们的CI构建由于内存耗尽而超时,并显著延迟了推出。
我们还发现Rollup的tree shaking算法存在一些错误,导致树抖动过于激进。值得庆幸的是,这只导致了在开发人员预览阶段发现并修复的小错误,而从未影响过我们的用户。此外,我们发现我们的遗留bundler提供了一些来自第三方库的代码,这些代码与JavaScript的严格模式不兼容。在启用严格模式的情况下,通过新的bundler提供相同的代码会导致浏览器中出现故障硬运行时错误。这要求我们对与严格模式不兼容的整个代码库和补丁代码进行一次性审计。
最后,在Dropboxer预览阶段,我们发现Rollup和传统bundler之间的A/B遥测指标并没有显示出我们预期的那么多TTVC改进。我们最终将其缩小到Rollup产生的块比我们的传统打包器产生的块多得多。尽管我们最初假设HTTP2的多路复用会抵消更多块带来的任何性能下降,但我们发现,过多的块会导致浏览器花费更多的时间来发现页面所需的所有模块。增加块的数量也会导致压缩效率降低,因为像Zlib这样的压缩算法使用滑动窗口方法进行压缩,这会导致一个大文件比许多小文件的压缩效率更高。
5、结论
在向所有Dropbox用户推出Rollup后,我们发现该项目将我们的JavaScript打包大小减少了33%,JavaScript脚本总数减少了15%,并对TTVC进行了适度的改进。我们还通过自动代码分割显著提高了前端开发速度,这消除了开发人员在每次更改时手动打乱包定义的需要。最后,也许也是最重要的一点,我们将bundler基础设施带入了现代化,并削减了自2014年以来积累的多年技术债务,减轻了我们未来的维护负担。
除了具有高度影响力的推出外,Rollup项目还揭示了我们现有体系结构中的几个瓶颈——例如,几个渲染阻塞RPC、对第三方库的过多函数调用,以及浏览器加载模块依赖关系图的效率低下。考虑到Rollup丰富的插件生态系统,在我们的代码库中解决这些瓶颈从未如此容易。
总的来说,完全采用Rollup作为我们的模块打包器不仅立即提高了性能和生产力,而且还将在未来实现显著的性能改进。