作者 | Andre Wiggins
译者 | Sambodhi
策划 | Tina
导读:想象一下,在不中断服务的情况下,将一个庞大的在线平台的核心技术框架升级到最新版本,听起来是不是像是一场技术界的“心脏移植手术”?爱彼迎就成功地完成了这项任务,而且没有引发任何服务降级。在这篇文章中,我们将一窥爱彼迎如何利用他们独创的 React 升级系统,逐步推出 React 的新版本,同时收集反馈和学习经验。文章不仅分享了升级过程中的哲学思考、系统设计,还总结了从这一升级中学到的宝贵教训。无论你是前端开发者、技术团队的领导者,还是对技术升级过程充满好奇的读者,这篇文章都将为你提供深刻的见解和实用的策略,帮助你在自己的项目中实现平滑的技术升级。
如果你对如何在保持服务稳定性的同时引入新技术感兴趣,或者想知道大型技术团队如何应对升级挑战,那么这篇文章绝对值得一读。让我们一起探索爱彼迎在 React 升级之旅中的策略和实践吧!
引 言
爱彼迎的前端团队近期迎来了一项重要成就:我们成功地将所有网页界面从 React 16 升级至最新的 React 18 版本。这一壮举对于一个涵盖用户页面、房东页面及众多内部工具等多元界面的产品而言,无疑是一项浩大的工程。为了确保升级过程的安全与顺畅,我们精心打造了一套 React 升级系统 —— 这是一项可复用的基础设施,它助力我们在 monorepo 环境中逐步部署新版 React,并实时监测与评估升级效果。本文中,我们将深入探讨我们的升级策略、所构建的升级系统,以及在这次升级旅程中所积累的宝贵经验。
尽管本文聚焦于 React 的升级实践,但我们所构建的系统及所汲取的经验教训,同样适用于那些需要定期进行更新的众多 Web 框架与库。
升级的挑战
在长期运行的项目中,升级依赖项是一项常见且至关重要的任务。它不仅能修复安全漏洞、提升系统性能,还能解锁新的 API 功能。然而,尽管部分升级过程相对直接简单,但当项目中的大量代码深度依赖于更新后的 API 或是对某些行为有细致入微的预设时,升级的难度便会骤然上升。在爱彼迎的 Web monorepo 环境中,我们坚持一个原则:除了极少数特殊情况外,每个顶级依赖项只允许保留一个版本,且整个代码库根目录下仅设有一个package.json
文件。这一做法确保了 monorepo 内部代码的兼容性和一致性,有效避免了向用户推送重复包包的问题。但在引入升级系统之前,维护这一单一版本依赖的策略意味着每次升级都需进行大规模的原子性更新,这不仅要求大量的前期迁移工作,还需长时间维护一个专门的升级分支,直至最终一次性部署给用户,实现一个关键的里程碑。这样的过程不仅风险高、易出错,还需要巨大的工程投入来保障其顺利进行。
理想状态下,我们期望能够实施小规模、无障碍的增量升级。但在没有一套能够在大型 monorepo 中逐步测试并推广的系统支持下,我们往往不得不经历多次升级尝试,并在遇到问题时回退版本。尤其是面对性能回归这类问题,现有的升级策略在监测和预防上显得力不从心。由于缺乏在全面部署前收集性能数据的有效手段,我们往往只能采取“一刀切”的方式,直接从 0% 部署到 100%,这一过程中充满了不确定性和潜在风险。
上图直观地展示了我们在 React 主版本和次版本升级过程中的理想与现实差距。
因此,我们设计 React 升级系统的核心目标,就是将这一复杂且充满挑战的升级过程变得更加顺畅,将其从一项“英雄式”的任务转变为日常工作的常态。具体而言,我们的目标包括:
逐步升级:通过分阶段升级,我们可以更早地获得反馈,及时调整策略并总结经验教训。
高频迭代:保持升级的频繁性,确保当前版本与目标版本之间的差距始终保持在可控范围内。
升级测试:通过精确测量升级对性能的具体影响,依托数据指导我们做出更加明智的升级决策。
React 升级系统的设计
基于上述目标,我们逆向思考,逐步勾勒出理想的系统架构蓝图。我们力求避免采用长期运行的升级分支,转而追求逐步升级的策略;同时,我们寄望于通过 A/B 测试,从生产环境中直接收集用户反馈,以此为依据来指导最终的发布决策。
理想升级系统的简化示意图
然而,在系统初步构建的过程中,我们遭遇了几个亟待解决的关键问题:首先,需要选定一个 React 版本来负责应用的渲染工作;其次,如何在运行时动态地在两个版本间进行无缝切换,也构成了不小的挑战。下面是一个采用这种简化方法渲染基础应用程序的代码示例:
import React18 from 'react';
import React16 from 'react'; // duplicated import?
if (shouldEnableReact18()) {
const root = React18.createRoot(container);
root.render(<App />);
} else {
React16.render(<App />, container);
}
这里涉及两个核心问题:
避免框架包体积膨胀:我们极力避免在应用程序中同时打包两个版本的 React,因为这会导致框架包的体积翻倍,增加用户的下载负担。此外,构建过程中可能需要调整 JSX 的转换规则,这有可能导致我们的
<App />
组件与某个版本的 React 不兼容,进一步复杂化开发和维护过程。导入路径的混淆:另一个挑战在于如何明确指定 React 的导入路径。通常,“react”依赖项会指向特定版本的 React,但无法同时指向两个不同版本。这会造成版本冲突和不确定性。
为了有效解决上述问题,我们采用了模块别名(Module Aliases)的策略来区分不同版本的 React,并结合环境变量来控制构建和运行这两个独立的 React 版本。
模块别名(Module Aliases)
我们利用模块别名技术解决了导入路径的混淆问题。在使用 yarn 作为包管理器的项目中,我们在package.json
文件中添加了一个额外的 React 依赖项,例如:
"react-18": "npm:react@18"
通过这种方式,我们能够从react-18
包中导入 React,这在一定程度上缓解了问题。由于许多工具(如自定义解析器和构建系统)需要明确知道使用的 React 版本,我们将所有自定义工具接入到一个集中的“全局别名”配置系统中。这一配置允许我们在一个中心点为所有工具设置别名,从而使 Babel、Jest™、Webpack™及其他自定义解析逻辑在需要时能够将导入路径从react
自动重定向到react-18
。这一“全局别名”配置确保用户代码无需做任何修改,所有重定向操作均在幕后自动完成。
TypeScript 差异处理
鉴于我们的组件可能会在 React 16 或 18 环境中运行,我们希望在升级过程中使用能兼容这两个版本的类型定义。幸运的是,React 团队在主要版本之间保持了良好的向后兼容性。
我们已安装 React 18 的类型定义,并为 React 18 新增的 API(如useTransition
)创建了一个兼容层(shim),以确保这些 API 在 React 16 和 18 中都能正常工作。在 React 16 中,useTransition
会被实现为一个空操作。对于像useId
这样无法直接创建 shim 的 API,我们通过类型增强来标注这个钩子在 React 16 环境中可能不可用。
针对 React 18 中涉及 TypeScript 的重大变更,我们计划在 React 18 升级全面完成后,逐步解决这些问题。通过强化类型定义,我们能够在 monorepo 中稳步修复这些新出现的 TypeScript 错误。
环境目标
为了有效解决 React 版本重复导入的难题,我们精心设计了两种构建产物:一种集成 React 16,另一种则搭载 React 18。我们将这两种产物分别命名为“控制”构建和“处理”构建。鉴于爱彼迎广泛采用服务器端渲染(SSR)技术,我们进一步在服务器上部署了不同的 Node.js 进程来分别运行这两个构建。借助 Kubernetes 的强大功能,我们成功构建了两个独立的 Kubernetes 环境,专门用于运行这些控制与处理构建。这一策略,我们称之为环境目标,它为实现不同版本框架的并行部署提供了坚实基础。
模块别名与环境目标策略相辅相成,共同助力我们在生产环境中灵活部署不同版本的 React 框架。
在构建流程中,我们巧妙地引入了一个名为REACT_UPGRADE
的环境变量,并在 Node SSR 服务运行时加以设置。这一举措让我们能够灵活地在系统升级的不同阶段执行条件逻辑,确保升级过程的平稳过渡。
值得一提的是,这一设置同样在我们的本地开发环境中大放异彩。通过将“本地”开发环境也纳入部署范畴,我们能够确保 React 版本的配置与生产环境保持高度一致。随着每个 SSR 服务逐步升级到 React 18,我们相应地将对应服务的开发环境也切换至 React 18,从而在生产与本地开发之间建立起无缝的版本同步机制。
升级测试
为了确保升级过程的安全无误,爱彼迎构建了一套全面的测试体系,涵盖视觉回归测试、集成测试及单元测试等多个维度。在正式将升级推向用户之前,我们逐一解决了测试套件中出现的新问题,确保系统稳定运行。
在单元测试环节,面对框架内部的抽象复杂性,我们遭遇了不小的挑战。由于我们采用了 Enzyme 与 React Testing Library 的组合,我们不得不深入修复单元测试、shim 及适配器中对 API 和框架内部的错误假设。为此,我们在 React 16 和 18 两个版本中并行运行所有单元测试,对 React 18 测试套件中的既有失败采取容忍态度,并逐步进行修复。通过维护一个“允许失败”的列表,我们有效减少了测试失败的数量,同时避免了新失败的产生。这一策略使我们能够有条不紊地在组件与测试环境中逐一攻克难题。
此外,我们还利用仪表板实时追踪数百个测试失败的修复进度,将修复任务逐一合并并分配给团队成员。这一举措不仅让迁移过程对前端团队而言几乎透明无感知,还极大地增强了我们在升级上线前的信心。
渐进发布策略
在成功部署模块别名与环境目标技术后,我们能够在统一的代码库中并行管理 React 的两个不同版本。为了确保升级过程既安全又可验证,我们采取了一种渐进式发布新环境的策略。此策略旨在精细调控流量与产品界面的变更,以最小化单次更新带来的冲击。通过我们的实验基础设施,我们灵活地引导流量流向两个生产环境(控制组与实验组),这样便能在内部先行测试升级效果,并在发现任何问题时迅速回滚,保障服务的稳定性。
然而,将发布控制细化到不同界面层面则更为复杂。在单页应用架构下,同时管理多个 React 版本意味着需要频繁地卸载并重新挂载 React 根组件,这不仅可能影响应用性能,还可能损害用户体验。
为此,我们将发布升级的管理层级提升至应用级别。鉴于爱彼迎的单一代码库内嵌多个单页应用,我们利用 React 升级系统对这些应用逐一进行渐进式升级。这一方法使我们能够先在内部对单个应用实施升级,并在开发和预发布环境中进行测试,从而避免了长期维护功能分支的繁琐,有效实现了平滑过渡的升级目标。
功能采纳与未来规划
依托上述系统,我们已顺利完成 React 18 在爱彼迎所有 Web 界面的全面推广,且无需进行任何回滚操作。升级完成后,我们随即开始对新引入的 API 进行测试,包括 新根 API 和 并发渲染特性。为确保升级后的稳定性,我们特意推迟了几周才正式启用这些新功能,有效规避了因需降级或恢复代码变更而带来的风险。
新特性的性能优势让我们倍感振奋,目前我们正积极探索如何将这些优势进一步拓展至关键 UI 界面,以期获得更显著的效益。
展望未来,为确保升级工作的持续性与高效性,我们计划利用 React 升级系统测试 React 金丝雀通道。通过直接指向金丝雀版本,我们能够提前洞悉即将面临的迁移任务,为迎接 React 19 做好充分准备。我们坚信,将版本更新视为一项持续进行的任务而非一次性的重大变革,将使得未来的升级之路更加顺畅无阻。
结 论
我们构建的 React 升级系统,其核心目标在于实现逐步升级、升级测试以及高频迭代的升级流程。通过巧妙融合环境目标与别名系统,我们能够有条不紊地推进逐步升级策略,并确保每一步都经过全面的测试。目前,我们已着手在 React 19 的测试版上进行前端试验,以期提前适应并充分利用其新特性。
在此,我们特别向 React 团队致以诚挚的谢意,感谢他们在维护不同版本间,尤其是主版本间向后兼容性方面所做出的不懈努力。正是这些努力,为我们的顺利升级策略奠定了坚实的基础。
依托 React 升级系统,我们对成功推广 React 18 充满信心,并计划将这一高效方法应用于未来的每一次升级。我们坚信,投资于升级系统的构建与维护是极具价值的,因为技术升级本身就是一个持续不断的过程。React 升级系统不仅帮助我们逐步测试并推出新版本,更确保了我们的应用能够持续为用户提供最佳的体验与性能。
背景信息补充:
React 17 于 2020 年发布,作为一次“平稳过渡”的版本,它未引入重大新功能,但为后续的更新奠定了重要基础,其破坏性更改被严格控制在极小的范围内。
当我们开始着手升级时,React 18 已正式问世,因此,我们决定直接跨越至这一版本进行升级。
目前,React 19 正处于积极的测试版开发阶段,而我们正利用我们的 React 升级系统紧锣密鼓地为其到来做好充分准备。
作者简介
Andre Wiggins,爱彼迎工程师
原文链接:
https://medium.com/airbnb-engineering/how-airbnb-smoothly-upgrades-react-b1d772a565fd
送人玫瑰,手留余香,觉得有收获的朋友可以点赞,关注一波 ,我们组建了高级前端交流群,如果您热爱技术,想一起讨论技术,交流进步,不管是面试题,工作中的问题,难点热点都可以在交流群交流,为了拿到大Offer,邀请您进群,入群就送前端精选100本电子书以及 阿里面试前端精选资料 添加 下方小助手二维码或者扫描二维码 就可以进群。让我们一起学习进步.
推荐阅读
(点击标题可跳转阅读)
[面试必问]-你不知道的 React Hooks 那些糟心事
[面试必问]-全网最全 React16.0-16.8 特性总结
[架构分享]- 微前端qiankun+docker+nginx自动化部署
觉得本文对你有帮助?请分享给更多人
关注「React中文社区」加星标,每天进步