介绍
Airbnb
的前端最近达到了一个重要的里程碑:我们所有的网页面都已经从 React 16
升级到了当前的主要版本 React 18
。这是一个涉及到多个页面的大项目,包括客人和房东页面以及许多内部工具。为了安全地进行这次升级,我们创建了React
升级系统:可重用的基础设施,允许我们在单一代码仓库中逐步推出React
的新版本并测量升级的结果。在这篇博客文章中,我们将讨论我们的升级理念、我们创建的系统以及从这次升级中学到的经验。
虽然这篇文章主要集中在 React 上,但系统和经验同样适用于许多需要定期升级的网页框架和库。
升级的挑战
在任何长期项目中,升级依赖项是一个常见任务。升级可以修复错误、提高性能并解锁新的 API
。有些升级很简单,但当大量产品代码依赖于更改的API
或想当然的行为,升级变得更加困难。在 Airbnb
的网页单一代码仓库中,我们只允许每个顶级依赖项的一个版本(少数例外情况),在仓库根目录中有一个 package.json
。这确保了代码库内部的兼容性和一致性,并避免了向用户发布重复的包。在升级系统之前,只有一个依赖项版本意味着要进行原子更新,这需要大量的前期迁移工作、一个长期运行的升级分支以及最终部署给用户时的一个单一里程碑。这样的做法容易出错且风险很大,因此需要“英雄式”的工程努力来完成干净的升级。
理想情况下,我们应该发布没有问题的小型增量升级。没有某种方式测试和逐步推出这个系统到大型单一代码仓库中,我们常常需要多次尝试升级,每次发现问题时进行降级。使用这种升级策略特别难以捕捉性能回归问题。因为在发布之前没有办法收集性能数据,我们在部署时直接从 0% 推出到 100%。
我们的目标是通过 React
升级系统使升级过程更加顺畅,从而使之不再需要“英雄式”的努力,而是变得更加常规。具体而言,我们的目标是能够:
- 增量升级,以便尽快获得反馈和经验教训。
- 经常升级,以便我们版本与升级版本之间的差距尽可能小。
在该系统的最简单实现中,我们需要解决几个问题:需要选择一个React
版本进行渲染,并且在运行时动态切换两个版本具有挑战性。以下是使用这种简单方法渲染一个基本应用的代码示例:
1 | import React18 from 'react'; |
这里有两个问题:
- 我们不希望在应用中捆绑两个版本的
React
,否则会使框架包的大小翻倍。此外,我们可能需要更改构建时使用的 JSX 转换,使得<App />
与一个版本不兼容。
- 我们不希望在应用中捆绑两个版本的
- 不清楚这些导入应该来自哪里。
react
依赖项将指向React 16
或React 18
,但不会同时指向两个版本。
- 不清楚这些导入应该来自哪里。
为了解决这些问题,我们使用模块别名来分割版本,并使用环境定位来构建和运行这两个分割版本的 React
。
模块别名
我们使用模块别名解决了这些导入来自哪里的难题。使用 yarn
,我们在 package.json
中添加了另一个 react 依赖项,例如:
1 | "react-18": "npm:react@18" |
这使我们能够从 react-18
包中导入 React
。这样做解决了部分问题。许多工具(如自定义解析器和构建系统)需要知道使用哪个版本。为了集中逻辑,我们将所有自定义工具连接到一个中心的“全局别名”配置中。这个全局别名配置允许我们在一个地方为所有不同的工具设置别名。Babel
、Jest™
、Webpack™
和其他自定义解析逻辑都需要知道在什么条件下我们希望将导入从 react
重定向到 react-18
。通过我们的“全局别名”配置对模块进行别名意味着用户代码无需更改,我们可以在幕后处理这些重定向。
TypeScript 差异
鉴于任何组件都可能在 React 16
或 18 中运行,我们希望在升级期间使用适用于两者的组件类型。幸运的是,React
团队保持了向后兼容性,即使是在主要版本之间。
我们安装了 React 18
的类型,并为 React 18
中新增的 API
创建了在 React 16
和 18 中都能工作的模拟层(例如,useTransition
在 16 中不执行任何操作)。对于无法模拟的 API
(例如 useId
),我们通过类型扩展表明该钩子在运行时可能未定义。
对于 React 18
中 TypeScript
仅有的破坏性更改,我们等到 React 18 升级完成后再逐步修复这些问题。我们通过类型扩展来修补差异,以便在单一代码仓库中逐步修复这些新的 TypeScript
错误。
环境定位
为了解决重复导入的问题,我们需要生成两个不同的构建产物:一个包含 React 16
,另一个包含 React 18
。我们分别称这些为“控制”和“处理”产物。由于 Airbnb
使用服务器端渲染(SSR)
,我们还需要在服务器上以不同的节点进程运行这两个不同的产物。通过 Kubernetes®
,我们设置了两个不同的 Kubernetes
环境来运行这些控制和处理产物。我们称这种设置为环境定位。
模块别名和环境定位结合使用,以在生产环境中部署框架的不同版本
我们还在构建时将环境变量(REACT_UPGRADE
)写入资产,并在运行时在我们的 Node SSR
服务中设置该变量。这使我们能够执行可能仅在升级系统的一侧或另一侧需要的条件逻辑。
这种设置也适用于本地开发。我们的“本地”开发环境也被部署,因此我们能够像生产环境一样配置本地开发的 React
版本。随着每个SSR
服务升级到 React 18
,我们还将该服务的开发环境切换到 React 18
,以保持生产和本地开发版本的同步。
测试升级
Airbnb
拥有全面的测试流程,这有助于在将升级暴露给用户之前建立对升级安全性的信心。我们的测试流程包括视觉回归测试、集成测试和单元测试。在向用户推出之前,我们修复了这些套件中的所有新故障。
单元测试是最难从框架内部抽象出来的。因为我们使用 Enzyme
和React Testing Library
的组合,所以我们需要修复单元测试、模拟和适配器中关于 API
和框架内部的假设。为此,我们在 React 16
和18
下运行所有单元测试,在逐步修复现有故障的同时允许 React 18
测试套件中的现有故障。我们使用这个“允许的故障”列表来逐步减少测试失败的数量,从而防止倒退,因为不允许在列表中出现新的故障。这种方法使我们能够逐步修复组件和测试环境中的问题。
我们通过仪表板跟踪解决数百个测试失败的问题,使用升级系统逐步合并修复,并将工作分配给少数开发人员。这使得迁移工作对更广泛的前端团队来说基本透明,并帮助我们在推出前建立了对升级的信心。
逐步上线
一旦我们有了模块别名和环境定位,我们就有能力从同一个代码库编写和交付两个不同版本的 React
代码。为了确保安全性和可测试性,我们还需要一种逐步推出这种新环境的方法。为了减少一次性发生的变更量,我们希望控制跨流量和产品界面的推出。我们的实验基础设施允许我们随意将流量引导到我们的两个生产环境(控制和处理)。这种设置还允许我们首先在内部测试升级,如果发现问题可以完全关闭升级。
控制不同界面的推出更加困难。在单页应用中,管理多个React
版本意味着卸载和加载React
根。这会导致性能下降并降低用户体验。
因此,我们在应用级别管理界面推出升级。Airbnb
的单一代码库包含许多单页应用,因此拥有React
升级系统来为这些应用中的每一个打开和关闭升级是很有用的。使用我们的React
升级系统,我们能够首先在内部向单个应用推出,允许开发人员在开发和我们的预发布站点中选择加入和退出升级。这种方法使我们避免了长时间运行的功能分支,帮助我们实现了增量升级的目标。
功能正式通过和未来工作
使用该系统,我们将 React 18
完全推出到Airbnb
的所有网页面,且无需回滚。升级后,我们开始测试新的 API
,如新的根API
和并发渲染功能。在采纳这些功能之前,我们故意等待了几周,以确保升级已经稳定。这样我们可以确信不需要降级和撤销代码更改。
看到采用这些新功能后性能的提升令人兴奋,我们正在继续试验将它们扩展到可能受益的关键UI
界面。
为了确保我们的频繁升级目标得以实现,我们将使用React
升级系统测试 React
的金丝雀频道。我们可以指向金丝雀标签,而不是 React 18
,以预览现在需要进行的迁移工作,以便为 React 19
做准备。为了使升级不需要“英雄式”的努力,保持最新应该是一项持续的工作,而不是一次性的大变更。
结论
我们的 React
升级系统目标是使我们能够逐步升级、测试升级和频繁升级。结合环境定位和我们的别名系统,使我们能够逐步升级和测试升级。我们已经开始针对 React 19 beta
运行我们的前端,为 React 19
提前做好准备。
我们要感谢 React
团队为保持React
版本之间,甚至是主要版本之间的向后兼容性所付出的努力。没有这种努力,这种升级方法将不可能实现。
使用React
升级系统,我们对 React 18
的推出充满信心,并将使用这种方法进行未来的升级。我们相信投资于升级系统是值得的,因为随着时间的推移,升级将继续需要。React
升级系统使我们能够逐步测试和推出升级,确保我们为用户提供最佳的用户体验和性能。