TypeScript 是增长最快的语言之一,最近几年逐渐成为很多大厂的首选工具。最近,Stripe 将最大的 JavaScript 代码库(用于支持 Stripe Dashboard 功能)从 Flow 迁移到了 TypeScript。于是通过单一 PR 请求,转换了超过 370 万行代码。第二天,几百名工程师快速跟进,开始为自己的项目编写 TypeScript。
TypeScript 目前已经成为 JavaScript 类型检查的客观标准,Stripe 已经把这次使用的 TypeScript 转换工具分享到 GitHub(https://github.com/stripe-archive/flow-to-typescript-codemod),希望帮助更多朋友能够轻松完成类似的大规模迁移。以下是他们迁移的具体步骤:
Stripe 的 JavaScript 类型检查简史
Stripe 是一款诞生于 2012 年的大规模前端应用程序,共包含 stripe.com、Stripe JS 和 Stripe Dashboard 几大组成部分。随着业务的发展,我们开始对 JS 代码进行类型检查以提高产品质量和可靠性。2016 年,我们率先采用了 Flow——这是 Facebook 当时专门开发的 JavaScript 类型检查系统。之后几年间,Flow 一直为我们大部分前端应用程序的类型安全保驾护航。
为 API 资源和关联端点生成的 Flow 类型示例。然而,工程师们在实际使用中发现 Flow 仍有诸多不足。首先,这款类型检查器会轻松耗尽笔记本电脑的内存,而编辑器内集成也速度缓慢、可靠性低下。与此同时,微软开发的另外一种类型系统 TypeScript 却在异军突起,凭借着完善的工具组合和强大社区而广受好评。于是,越来越多的 Stripe 工程师呼吁转向 TypeScript。Stripe 拥有专门的开发者生产力团队,职责就是为工程师们提供最高效、最顺手的开发环境,所以他们的满意度就是生产力团队的使命。我们一直在努力确定开发者们最关心的紧迫问题:例如,我们在全部开发工具中都集成了上报错误/不便的功能,确保将情况快速发送给相关团队以评判优先级。对 TypeScript 的支持已经相当紧迫,于是支持团队决定在整个公司内帮助前端工程师转向 TypeScript。
选择正确的迁移策略
在所有前端代码库中,最大的那个负责为 Stripe Dashboard 和其他一些面向用户的产品提供支持。Dashboard 代码库中的不同组件保持着紧密耦合,而且没有清晰拆分的依赖图表。如果选择面向 TypeScript 开展增量迁移,就意味着开发人员在一段时间内必须同时使用两种语言来完成常见任务。此外,我们还需要一个互操作层来同步两种语言之间的类型定义,并在整个开发过程中始终保持二者一致。2020 年末,我们组建了一支新的横向 JavaScript 基础设施团队:在这里,工程师们只关注一项工作——提升 Stripe 编写 JS 代码时的体验。而团队的首要挑战之一,就是用 TypeScript 替换掉 Flow,同时回避掉漫长且充满不确定性的迁移过程。我们首先与其他开展过类似迁移的企业进行交谈,并参考了 Airtable 和 Zapier 的经历回顾文章。这两家企业都开发出自动化脚本,用于将一种语言转换成另一种语言、贯穿整个代码库运行,再把输出结果合并成单一提交。Airtable 已经把自己的转换脚本以“codemod”源到源转换工具的形式上传至 GitHub,它就完全能够解析 Flow 代码并生成相应的 TypeScript。这种迁移方式大大降低了工程师们的工作负担,也不需要为相同的产品维护两套类型系统。这么一看,从 Flow 到 TypeScript 的道路顿时平坦了起来。
规划、筹备和迭代
Airtable 工具那出色的代码转换质量给我们留下了深刻印象,于是 Stripe 决定把它作为迁移工作的基础。这里要感谢 Airtable 团队开发并分享的这份工作成果——开源社区正是在无数这类案例的支持下,才变得如此兴盛蓬勃。我们首先将 Airtable 的 codemod 复制到 Stripe 的 monorepo 当中,从而指向内部代码来运行。我们的 JS 项目中大量用到了 Sail——一个由严格类型化 React 组件构成的共享设计系统,所以我们决定在迁移之初先从 Sail 入手。我们为 Sail 生成了 TypeScript 定义,而非直接把代码转换成 TypeScript,这样就能保证它同时支持用 Flow 和 TypeScript 编写的应用程序。为了安全支持这两种类型系统,我们编写了测试来验证 TypeScript 定义对于底层 Flow 代码做出的具体更改。这种方法对于大规模代码库来说可能太过麻烦,好在 Sail 组件拥有明确且严格的接口,所以我们的测试倒是相当顺遂。还有个问题,codemod 的底子很好、但功能并不全面:对于很多文件,它在转换中可能发生崩溃,输出结果也不够完善。所以在几个月时间里,我们通过一次次迭代解决了这些较为极端的句法和语义案例。举个简单的例子,JS 箭头函数可以在没有 return 语句时直接返回单一表达式,如下所示:const linesOfCode = () => 7;JS 对象字面量会使用大括号来体现属性定义。但因为大括号也被用于描述语句块,所以要从箭头函数返回对象字面量,还需要引入一组额外的括号来消除歧义:const currencyMap = () => ({ca:’CAD’,us:’USD’});我们注意到,codemod 会错误删除掉箭头函数中这些额外的括号,但这个问题只发生在泛型函数(接受类型参数的函数)当中。可一旦删除,结果语法在标准 JS 中将不再可用:// bad!const wrapper = (arg:T) => {wrapped: T};于是我们修复了这个问题,并添加测试以防止其再次发生。整个迁移过程中,我们进行了大量类似的语法修复,才最终让之前庞大的代码库“旧月的换新颜”。在确保 Sail 能够在 TypeScript 中正常起效之后,我们又开发了几个包含数百个 JS 模块的内部应用程序。我们还向 codemod 中添加二次检查,希望进一步减少生成代码中的错误,同时使用 TypeScript 的 @ts-expect-error 注释来标记这些错误。可以看到,我们的基本思路并不是提前解决掉每个错误,而是尽快替换掉 Flow,并在过程当中跟踪实际发生的 TypeScript 错误抑制并加以解决。Dashboard 代码库的初始阶段共引发超过 97000 个错误抑制。在更新了 codemod 的迭代方法之后,这个数字被控制到了 37000 个,相当于每千行代码有 1 个错误抑制。相比之下,Flow 代码这边的错误抑制大概是 5000 个。Flow 和 TypeScript 都支持对类型覆盖率进行测量,而我们惊喜地发现虽然 TypeScript 这边的抑制数字更大,但这主要是因为其报告覆盖率要比 Flow 更高。这应该是因为 TypeScript 中的可用第三方类型定义在数量和质量上都优于 Flow,而后者则因为缺少这些定义而导致类型覆盖率不足。不过面对包含数万个模块的 Dashboard 时,我们的方法对 TypeScript 编译器产生了巨大的内存压力。而解决这个问题的主要工具,就是 TypeScript 项目引用:尽管 Dashboard 并不进行模块区分,但我们还是正确推断出了它的模块结构,并据此建立起项目引用。通过这种方式,我们得以直接在代码库之上运行 TypeScript,且无需重构大量应用程序代码。
正式上线
每周,都有数百名工程师在奋力推进 Dashboard 项目的迁移工作。但如此彻底的变动不容小觑,我们也不想在周内工作量合并这些更新。因此,团队决定选择 3 月 6 日星期天锁定 Stripe monorepo,同时上线我们的新分支。在合并前一周,我们开始通过 CI 系统将 build 传递并部署到 QA 环境当中。毕竟除了 TypeScript 对项目本体的检查之外,我们还得更新 ESLint、Jest、Webpack、Metro 等负责处理源代码的其他工具。这里出现了一个特别的痛点:Jest 快照测试。Jest 生成的快照文件中,会包含一条对快照生成文件的硬编码引用。由于 codemod 会给 TypeScript 文件生成.ts 或者.tsx 的扩展名,所以快照文件所引用的测试源将直接失效。为此,我们决定把生成文件的扩展名统一成.tsx,这样就可以批量重写快照并保证测试 100%通过。此外,我们还发现对某些 TypeScript 兼容代码的修复会带来不少工作量,甚至把日程安排推迟数周。其中的典型案例就是我们自定义的 ESLint 规则:其中一项规则会重新排序导入以强制保证各文件间的一致性,但该规则是针对 Babel 的 Flow 解析器编写的,所以生成的抽象语法树与 TypeScript 解析器会略有不同。在这种情况下,我们决定先禁用某些检查,并在转换完成后再行恢复。通过手动上传 build,我们在 Dashboard 中与面向用户功能的产品团队成功会合。尽管 Dashboard 拥有广泛的单元和功能测试,但端到端测试覆盖率却比较有限。因此,各产品相关方就必须有能力开展手动测试。测试中同样暴露出不少小 bug,我们抢在最后一周成功将其解决:例如,由于翻译加载代码中存在一个硬编码.js 扩展名,因此我们无法为非英文版 Dashboard 用户正确加载翻译内容。整个过程给了我们很大信心,但这种颠覆性的变更还是让大家有点忐忑:虽然我们牢牢掌握着开发工具和构建过程,但毕竟代码库中的每个文件都发生了变化。转换脚本中的任何一点细微错误(例如从多个组件间共享的对象中删除一个空字段)都有可能引发面向用户的错误,而任何现有自动化测试都发现不了这样的错误。另外,这类故障可能会有多种表现方式,例如引发下游开发工具报错、或者导致构建失败等。为了及时发现这些意外状况,我们只能依靠自动化与环境监控工具,同时建立了专门的协调部署 Slack 频道,保证面向用户的团队能够及时收到报告并快速着手修复。3 月 5 日星期六,团队生成了新的迁移分支并运行了我们的自动化脚本。之后,我们将该分支部署到 QA 环境并重复验证过程,包括产品团队提议的手动测试。期间没有发现任何新问题,看起来一切合并准备均已就绪。3 月 6 号星期天一大早,我们就锁定了 Stripe monorepo,又对迁移分支进行卫次 QA 测试,之后果断提交了变更。整个合并过程干净利落,我们的自动化测试也全部通过。就这样,TypeScript 顺顺当当进入了生产部署。凭借这一年来的细心调整与严谨测试,新代码在接收生产流量后没有发生任何意外。我们随后解锁了 repo,让开发者们看到现在的 Dashboard 已经运行在 TypeScript 当中了。有一天我正在面新员工,碰巧听说公司打算从 Flow 迁移到 TypeScript。
其实我是有点怀疑的,毕竟之前不少团队在小型代码库上都身陷泥潭、纠缠不清,这么大规模的迁移能顺利完成吗?但礼拜一的现实证明我想多了——一切如常。Eric Clemmons, Stripe 软件工程师
迁移一结束,公司内可以说是好评如潮。完善顺畅的迁移给工程师们留下了深刻印象,甚至有人认为这是 Stripe 多年以来最成功的一次开发者生产力提升。我们很高兴这一年的付出没有白费,Stripe 的代码库终于获得了显著、甚至可以说是颠覆性的改进。
TypeScript……两个月之后
转换当然不可能毫无瑕疵。在接下来的几周内,我们的 JS 基础设施团队又先后解决了几个意外问题。但最让人吃惊的,是有工程师报告 CI 和本地 TypeScript 运行间存在不一致。在 TypeScript 中,我们直接使用由 npm 安装的各种第三方类型定义,而如果定义被更新,工程师们就得安装新版本。而这明显跟我们的 Flow 配置不同,其中的依赖更新很少会改变具体类型,因此我们只能提醒工程师们运行 yarn install 进行调试。此外还有其他工作要做:我们知道更细粒度的项目引用可以进一步提高性能,更好的缓存设计则能加快 CI 运行速度。然而,这么点好处并不值得大费周章。工程师们喜欢使用自动依赖导入和代码补齐之类的功能,也离不开 TypeScript 社区中广泛的第三方类型定义和集成语料库。这也保证了当有新工程师加入 Stripe 编写前端代码时,他们能第一时间使用自己最熟悉的语言、把全部精力都投入到功能设计上。
随着 Dashboard 迁移工作的完成,JS 基础设施团队开始进一步提高 TypeScript 在整个公司内的采用率。我们使用相同的工具又先后转移了不少其他代码库,包括我们的全部支付 UI Stripe Checkout。Stripe 的前端工程师们很快就适应了这一切,开始用 TypeScript 编写所有开发项目。而且我们从迁移计划立项之初就在发布更新,相当于搞了个全程直播,反响同样热烈。来自整个行业的开发者纷纷给予关注,并在自己的代码库中尝试应用相同的改进。为了支持大家,我们决定在 GitHub 上分享 Stripe 的 TypeScript 转换代码(https://github.com/stripe-archive/flow-to-typescript-codemod),希望能起到些许积极作用。除了关于 JavaScript、Flow 和 TypeScript 的种种细节之外,我们还从此次迁移中总结出另一条重要经验:只要勤奋、专注、乐观,对大规模代码库做出显著改进并非不可能。我们将保持住这份热情,为 Stripe 乃至整个行业内的工程师带来更高的生产效率与更丝滑的工作体验。原文链接:https://stripe.com/blog/migrating-to-typescript