Airbnb如何从Webpack迁移到Metro,并使开发反馈回路几乎瞬间完成,最大的生产构建速度提高了50%,终端用户的运行时间也有了边际改善。

By: 刘蕾
简介
2018年,Airbnb的前端基础设施依靠Webpack进行JavaScript捆绑,这在之前一直为我们提供良好的服务;然而,随着我们的代码库在上一年几乎翻了两番,前端团队注意到开发体验受到了很大影响。不仅构建性能缓慢,而且根据项目的大小,一个微不足道的单行代码修改的平均页面刷新时间在30秒到2分钟之间。为了缓解这种情况,团队决定迁移到Metro。
由于切换到Metro,我们的构建性能得到了改善。在开发中,一个简单的UI变化被反映和加载的时间(互动时间TTI指标)快了80%。最慢的生产构建编译~49k模块(JavaScript文件)的速度提高了55%(从30.5分钟下降到13.8分钟)。作为额外的奖励,我们观察到使用Metro构建的页面在Airbnb页面性能得分方面有**1%**左右的改进。

JavaScript捆绑器的扩展问题当然不是Airbnb的独特问题。在这篇博文中,我们想强调Webpack和Metro之间的关键架构差异,以及我们在开发和生产构建中面临的一些迁移挑战。如果你预计你自己的一个项目在未来会有很大的扩展,我们希望这篇文章可以为解决这个问题提供有用的见解。
什么是Metro?
Metro是React Native的开源JavaScript捆绑器。虽然Airbnb不再使用React Native,但我们相信该基础设施也可以在网络上使用。经过与Meta的Metro人员的多次磋商,以及我们自己的一些修改,我们成功地建立了一个Metro的风味,现在为所有Airbnb网站的开发和生产捆绑提供动力。
从概念上讲,Metro将捆绑分解为三个步骤,顺序如下:解析、转换和序列化。
- 解析处理如何解决导入/需求语句。
- 转换负责转译代码(源到源的编译器,将现代TypeScript/JavaScript源代码转换为功能相当的JavaScript代码,更加优化并向后兼容旧的浏览器),一个例子是babel工具。
- 序列化将转换后的文件组合成JavaScript捆绑文件。
这三个概念是理解Metro工作原理的基本构件。在下面的章节中,我们将强调Metro和Webpack之间的关键架构差异,以便为Metro的优势提供更深入的背景。
Metro和Webpack之间的关键架构差异
在开发中按需处理JS捆绑物
当我们谈论捆绑时,JavaScript捆绑在技术上只是一个序列化的依赖图,其中一个入口点是该图的根。在Airbnb,一个网页映射到一个入口点。在开发中,Webpack(即使是最新的v5版本)需要知道所有页面的入口点,才能开始捆绑。另一方面,Metro开发服务器在飞行中处理请求的JavaScript捆绑。
更具体地说,在Airbnb,每个前端项目都有一个Node服务器,它与一个特定的入口点的路由相匹配。当一个网页被请求时,DOM包括带有开发JavaScript URL的脚本标签。浏览器加载页面,并向Metro开发服务器发出请求,以获取JavaScript捆绑。在图1中,我们说明了我们的Metro和Webpack开发设置之间的区别。
图1:Metro和Webpack的JS捆绑开发设置之间的差异
在这个例子中,有一个Web项目,有三个入口点:entryPageA.js、entryPageB.js和entryPageC.js。一个开发者对页面A进行了修改,其中只包括 entryPageA.js 捆绑。正如你在图1中看到的,在这两种情况下,浏览器都是先加载页面A(1),然后从捆绑器中请求 entryPageA.js 文件(2),最后捆绑器以适当的捆绑文件回应浏览器(4)。使用Webpack捆绑器(1a),即使浏览器只请求 entryPageA.js,Webpack也会在启动时编译所有的入口点,然后才能响应浏览器的 entryPageA.js 请求。另一方面,使用Metro捆绑器(1b),我们看到开发服务器没有花费任何时间来编译 entryPageB.js 或 entryPageC.js,而只是在响应浏览器请求之前编译 entryPageA.js。
Airbnb最大的一个前端项目有26k个独特的模块,每个页面的模块数量中位数是7.2k个模块。因为我们也做服务器端的渲染,所以我们最终要处理的模块数量翻了一倍,大约是4800个。在Metro的开发模式下,我们通过按需编译JavaScript节省了约70%的工作。
这个关键的架构差异改善了开发者的体验,因为Metro只编译需要的东西(请求的页面上的JavaScript捆绑),而Webpack在启动时预编译整个项目。
多层缓存
我们利用的另一个强大的Metro功能是它的多层缓存功能,这使得设置持久性和非持久性缓存变得简单明了。虽然Webpack 5也有一个磁盘持久性缓存,但它没有Metro的多层缓存那么灵活。Webpack提供了两种不同的缓存类型。"文件系统 "或 "内存",只限于内存或磁盘缓存,不可能有远程缓存功能。相比之下,Metro提供了更多的灵活性,允许我们定义缓存实现,包括混合不同类型的缓存层。如果一个层出现了缓存缺失,Metro会尝试从下一个层检索缓存,以此类推。
图2:Airbnb如何用Metro配置多缓存层
缓存的排序决定了缓存的优先级。当检索一个缓存时,将使用第一个有结果的缓存层。在图2所示的设置中,最快的内存缓存层被排在最前面,其次是文件/磁盘缓存,最后是远程只读缓存。与没有缓存的默认Metro实现相比,在一个编译22k文件的项目中,打上远程只读缓存后,服务器构建速度提高了56%。
导致Metro性能的一个因素是其内置的worker支持,它放大了多层缓存的效果。虽然Webpack需要仔细配置才能通过第三方插件利用工作者,但Metro默认情况下会启动工作者来卸载昂贵的转换,无需配置就能提高并行化。
但是,为什么使用远程只读缓存而不是普通的远程缓存(读写)?我们发现,对于有22000个文件的同一个项目来说,不写到远程缓存可以在开发中额外节省17%的 构建时间。写入远程缓存会产生网络调用,成本很高,尤其是在较慢的网络上。为了填充缓存,我们引入了一个CI工作,在默认的分支提交上定期运行,而不是远程缓存写入。
序列化
在捆绑器的上下文中,序列化意味着将转换后的源文件组合成一个或多个捆绑文件。在Webpack中,序列化的概念被封装在编译钩子(Webpack的公共API)中。在Metro中,一个序列化函数负责将源文件组合成捆绑文件。
关于序列化的重要性的一个例子,让我们来看看国际化支持。我们目前支持约70个地区的Airbnb网站,在2020年,我们的 国际化平台为超过100万件内容提供服务。为了支持JS捆绑的国际化,我们需要在序列化步骤中实现特定的逻辑。虽然我们在序列化Metro和Webpack的捆绑包时必须实现类似的国际化逻辑,但Webpack需要大量的源代码阅读,以找到适当的编译钩,让我们实现支持。除此之外,还需要了解复杂的概念,如什么是依赖模板以及如何编写我们自己的模板。相对而言,用Metro实现同样的国际化支持是一种新鲜空气。我们只需要关注如何序列化带有翻译内容的JS捆绑包,所有的任务都在单个序列化函数中完成。Metro的捆绑概念的简单性使得实现任何定制的功能都很直接。
在Airbnb采用Metro所面临的挑战
尽管Metro具有上述的架构优势,但它也带来了一些挑战,要想在网络上充分地利用它,就必须克服。因为Metro是为React Native环境设计的,我们需要写更多的代码来实现与Webpack的功能对等,所以决定切换到Metro是以重新发明一些轮子和学习通常被抽象出来的JavaScript捆绑器的内部工作为代价。
在开发中,我们必须创建一个Metro服务器,用自定义的端点来处理建立依赖关系图、翻译、捆绑JS和CSS文件以及建立源码地图。对于生产构建,我们将Metro作为一个Node API运行,以处理解析、转换和序列化。
全面迁移的表面积很大,所以我们把它分成了两个阶段。因为我们的Webpack设置的迭代速度很慢,在开发人员的生产力方面产生了很大的成本,所以我们把解决Webpack开发速度慢和Metro开发服务器的问题作为我们的首要任务。在第二阶段,我们将Metro的功能与Webpack相提并论,并在生产中对Metro和Webpack进行了A/B测试。我们在这一过程中面临的两个最大挑战概述如下。
捆绑拆分
用于开发的开箱即用的Metro设置为每个入口点产生了巨大的~5MB的包,因为单个包是React Native的预期用例。对于网络来说,这个包的大小对浏览器资源和网络延迟都是一种负担。每一个代码的改变都会导致一个5MB的包被处理和下载,这是低效的,而且不能被HTTP缓存。即使改变后的代码立即重新编译,我们仍然需要减少其大小并提高浏览器的缓存能力。
为了提高Metro在网络环境中的性能,我们通过动态导入边界来分割捆绑程序,这种技术也被称为代码分割。代码拆分的边界使我们能够有效地利用HTTP缓存。
在图3中,import('./file')代表动态导入边界。左边的包(3a)被分解为右边的三个小包(3b)。当import('./file')语句被执行时,这些额外的包被请求。
在图3a中,假设fileA.js发生了变化,整个bundle需要重新下载,以便浏览器能够接收fileA.js的变化。如图3b所示,通过动态导入分割的包,fileA.js的变化只导致重新下载fileA.js包。其余的包可以重新使用浏览器的缓存。
图3:通过动态导入边界分割捆绑程序。一个bundle由粉红色背景的矩形框来表示。
当我们开始考虑生产用的bundle时,我们希望优化的方式与开发时有些不同。运行数据包分割算法需要时间,我们不想把时间浪费在优化开发中的数据包大小上。相反,我们优先考虑的是页面加载性能,而不是最小化包的大小。
在生产中,我们希望向最终用户提供更少、更小的JavaScript包,这样页面的加载速度会更快,用户体验也会更好。在生产中没有Metro开发服务器,所以所有的bundle都是预先建立的。这使得bundle拆分成为使我们的Metro构建为生产做好准备所需的最大阻断功能。从Webpack的bundle拆分算法中得到一些启发,我们实施了一个类似的机制来拆分Metro的依赖图。与动态导入边界的开发分割相比,airbnb.com上产生的包大小减少了20%(1549 KB -> 1226 KB)。
在比较Metro和Webpack的实现之间的数据包拆分结果时,我们发现两者提供的数据包大小相当,但Metro的几个页面运送的Javascript数据包数量略高。尽管页面重量稍重,但Metro和Webpack之间的TTFCP、最大的contentful paint和总阻塞时间等指标是相当的。
树形震荡
仅仅拆分捆绑包就能显著减少捆绑包的大小,然而我们能够通过删除死代码使捆绑包更小。然而,识别一个项目中的死代码并不总是那么明显,因为一个项目中的一些 "死代码 "可能是其他项目中的 "使用代码"。这就是树形摇晃发挥作用的地方。它依赖于代码库中ECMAScript模块(ESM)导入/导出语句的一致使用。根据项目中的导入/导出使用情况,我们分析了项目中的任何文件都没有导入哪些特定的导出语句。最后,捆绑器删除了未使用的导出语句,使整个捆绑器的大小变小。
在为Metro生产构建实现树形摇动算法时,我们面临的一个挑战是错误地删除在运行时执行的代码的风险。例如,我们遇到了多个与再出口语句有关的bug。由于Webpack以不同的方式处理ESM导入/导出语句,因此没有可比的现有技术可供参考。经过树形摇动算法的多次迭代实现,下表记录了在项目规模下,我们最终能够放弃多少死代码。

总结
Metro的迁移带来了一些非常显著的改进。最大的Airbnb前端项目编译了约48000个模块(包括服务器和浏览器的编译),平均构建时间减少了55%,从30.5分钟减少到13.8分钟。此外,我们看到由Metro构建的页面在Airbnb页面性能得分上有所提高,范围在1%左右。终端用户性能的提高是一个不错的惊喜,因为我们最初的目标是实现中性的实验结果。
Metro架构的简单性使我们在很多方面受益。来自其他团队的工程师们已经迅速提升,为Airbnb的Metro实现做出贡献,这意味着为捆绑系统做出贡献的门槛较低。多层缓存系统很容易操作,使缓存的实验成为可能。定制的捆绑器功能集成是显而易见的,而且更容易实现。
我们承认,自从我们在2018年评估了Parcel、Webpack 4和Metro之后,情况已经发生了变化。还有其他工具,如rollup.js和esbuild,我们没有过多的探索,而且我们知道,与Webpack相比,Metro并不是一个通用的JavaScript捆绑器。然而,经过几年在Metro功能平价方面的努力,我们看到的结果向我们证明,追求Metro是一个不错的决定。Metro解决了我们最迫切的扩展问题,降低了开发和生产构建时间。通过即时的开发反馈循环和更快的生产构建,我们的生产力比以往任何时候都高。如果你想帮助我们继续改善我们的JavaScript工具和构建优化,或解决其他网络基础设施的挑战,请查看Airbnb的这些开放职位。
鸣谢
感谢所有为这个多年项目做出贡献的人。没有你们任何一个人,我们不可能做到这一点!特别感谢我可爱的团队Michael James和Noah Sugarman,他们推动了Metro生产迁移到终点线。感谢Brie Bunge、Dan Beam、Ian Myers、Ian Remmel、Joe Lencioni、Madison Capps、Michael James、Noah Sugarman对这篇博文的审核和反馈。
所有产品名称、标识和品牌都是其各自所有者的财产。本网站中使用的所有公司、产品和服务名称仅用于识别目的。使用这些名称、标识和品牌并不意味着认可。
The Airbnb Tech Blog最初发表在Medium上,人们通过强调和回应这个故事来继续对话。