Airbnb 是如何从 JavaScript 迁移到 TypeScript 的

2021-07-12 12:16 前端之巅
图片
作者 | Sergii Rudenko
译者 | 张健欣
策划 | 晓旭

TypeScript 是 Airbnb 前端开发的官方语言。但是,采用 TypeScript 的过程和迁移一个包含成千上万个 JavaScript 文件的成熟代码库不是一夕发生的。TypeScript 的采用经过了最初提案、多数团队采用、测试阶段,最后落地为 Airbnb 前端开发的官方语言。

迁移策略

大规模迁移是一项复杂的任务,我们探讨了从 JavaScript 迁移到 TypeScript 的几种策略:

1) 混合迁移策略。一份文件一份文件地逐步部分迁移,修复类型错误,不断重复直到整个项目迁移完成。其 allowJS 配置选项允许我们在项目中同时拥有 TypeScript 和 JavaScript 文件,这使得这种方案变得可行!

在混合迁移策略中,我们不必暂停开发,可以一份文件一份文件地逐步迁移。不过,规模很大时,这可能花费很长时间。另外,还需要对来自组织的不同部门的工程师进行培训。

2) 一次性全部迁移!将一个 JavaScript 项目或含有部分 TypeScript 的项目完全迁移到 TypeScript。我们需要增加一些 any 类型和 @ts-ignore 注释,这样项目编译就不会报错,但随着时间的推移,我们可以用更具描述性的类型替换它们。

选择一次性全部迁移策略有几个显著的优点:

  • 跨项目的一致性:一次性全部迁移将保证每个文件的状态相同,工程师不必记住他们可以在哪里使用 TypeScript 特性,以及编译器在哪些地方会报错。

  • 只修复一种类型比修复文件容易地多:修复整个文件可能非常复杂,因为文件可能有许多依赖。使用混合迁移,更难追踪迁移的实际进度和文件的状态。

看起来,一次性全部迁移明显更好!但是,对一个大而成熟的代码库执行整体迁移的过程是一个重要且复杂的问题。为了解决这个问题,我们决定使用代码修改脚本——codemods!通过我们最初手动迁移到 TypeScript 的过程,我们认识到可以自动化的重复操作。我们为每个步骤制作了 codemods,并将它们组合到总体迁移管线中。

根据我们的经历,并不能 100% 保证自动化迁移会产生一个完全没有错误的项目,但是我们发现下面列出的步骤的组合为我们最终迁移到一个没有错误的 TypeScript 项目提供了最好的结果。使用 codemods,我们能够在一天内将包含 50,000 行代码和 1,000+ 文件的项目从 JavaScript 转换为 TypeScript!

基于这个管线,我们创建了一个称为“ts-migrate”的工具:


图片


在 Airbnb,我们在前端代码库的很多重要部分使用了 React。这就是 codemods 的一些部分与基于 React 的概念相关的原因。ts-migrate 可以通过一些额外的配置和测试,与其它框架或库一起使用。

迁移过程的步骤

让我们了解一下将项目从 JavaScript 迁移到 TypeScript 所需的主要步骤,以及这些步骤是如何实现的:

1) 每个 TypeScript 项目的第一步是创建一个 tsconfig.json 文件,如果需要,ts-migrate 可以生成这个文件。有一个默认的配置文件模板和一个校验检查,可以帮助我们确保所有项目的配置是一致的。

下面是一个基本配置的示例:


{
"extends": "../typescript/tsconfig.base.json",
"include": [".", "../typescript/types"]
}

2) 一旦 tsconfig.json 文件就位,下一步就是将源文件的文件后缀从.js/.jsx 改为.ts/.tsx 。将这一步自动化非常简单,能够避免大量人工工作。3)下一步是运行 codemods!我们称它们为“插件”。ts-migrate 插件是可以通过 TypeScript 语言服务器访问其他信息的 codemods。这些插件以字符串作为输入,产生一个更新后的字符串作为输出。可以使用 jscodeshift、TypeScript API、字符串替换或其它 AST 修改工具来进行代码转换。

在每一个步骤之后,我们会检查 Git 历史中是否有任何更改并提交它们。这有助于将迁移拉取请求拆分为更易于理解的提交,并跟踪文件重命名。

ts-migrate 包概览

我们将 ts-migrate 拆分为 3 个包:

  • ts-migrate

  • ts-migrate-server

  • ts-migrate-plugins

这样做,我们将转换逻辑从核心运行程序中分离出来,并为不同的目的创建多个配置。目前,我们有两个主要配置:migration 和 reignore。

虽然 migration 配置的目标是从 JavaScript 迁移到 TypeScript,reignore 的目标是通过忽略所有的错误来使得项目可以编译。当一个人有一个非常大的代码库并且正在执行以下任务时,reignore 是非常有用的:

  • 升级 TypeScript 版本

  • 对代码库进行重大更改或重构

  • 改进一些常用库的类型

这样,即使存在一些我们不想立即处理的错误,我们也可以迁移项目。这使得 TypeScript 或库的更新变得容易许多。

这两个配置都运行在 ts-migrate-server 上,这个 ts-migrate-server 包括两部分:

  • TSServer: 这部分与 VSCode 编辑器在编辑器与语言服务器之间进行通信时所做的非常相似。TypeScript 语言服务器的一个新实例作为一个单独的进程运行,开发工具使用语言协议与服务器通信。

  • Migration runner: 这部分运行并协调迁移过程。它需要以下参数:

interface MigrateParams {
rootDir: string; // path to the root directory
config: MigrateConfig; // migration config, including list of
// plugins it contains
server: TSServer; // an instance of the TSServer fork
}

它执行以下动作:

  1. 解析 tsconfig.json。

  2. 创建.ts 源文件。

  3. 将每个文件发送到 TypeScript 语言服务器进行诊断。编译器为我们提供了三种类型的诊断:语义诊断(semanticDiagnostics )、语法诊断(syntacticDiagnostics )和推理诊断(suggestionDiagnostics )。我们使用这些诊断来发现源代码中有问题的地方。根据唯一的诊断编号和行号,我们可以确定潜在的问题类型并进行必要的代码修改。

  4. 在每个文件上运行所有插件。如果文本由于插件的执行而改变,我们就更新原始文件的内容,并通知 TypeScript 语言服务器该文件已经改变。

你可以在 examples package 或 main package 中找到 ts-migrate-server 用法的示例。ts-migrate-example 还包括插件的基本示例。它们可分为 3 大类:

  • 基于 jscodeshift 的插件

  • 基于 TypeScript 抽象语法树的插件

  • 基于文本的插件

在代码库中有一组示例演示如何构建各种插件,并将它们与 ts-migrate-server 结合使用。下面是一个转换如下代码的迁移管线的示例:

function mult(first, second) {
return first * second;
}

转换为:
function tlum(tsrif: number, dnoces: number): number {
console.log(`args: ${arguments}`);
return tsrif * dnoces;
}

ts-migrate 在上面的示例中做了 3 个转换:

  • 反转了所有标识符 first -> tsrif

  • 向函数声明添加了类型 function tlum(tsrif, dnoces) -> function tlum(tsrif: number, dnoces: number): number

  • 插入 console.log(‘args:${arguments}’);

通用插件

实际的插件位于单独的包中——ts-migrate-plugins。我们来看看其中一些插件。我们有两个基于 jscodeshift 的插件:explicitAnyPlugin 和 declareMissingClassPropertiesPlugin。jscodeshift 是一个使用 recast 包将抽象语法树(AST)转换回字符串的工具。通过使用 toSource() 函数,我们可以直接更新文件的源代码。

explicitAnyPlugin 背后的主要思想是从 TypeScript 语言服务器中提取所有语义诊断错误以及行号。然后,我们需要在诊断中指定的行上添加 any 类型。这种方法允许我们解决错误,因为添加 any 类型可以修复编译错误。

转换前:

const fn2 = function(p3, p4) {}
const var1 = [];

转换后:

const fn2 = function(p3: any, p4: any) {}
const var1: any = [];

declareMissingClassPropertiesPlugin 接受所有代码为 2339(你能猜出这个代码是什么意思吗?)的诊断,如果它能找到缺失标识符的类声明,这个插件会使用 any 类型注解将它们添加到类主体中。从名字可以看出,这个 codemod 只适用于 ES6 类。

下一类插件是基于 TypeScript AST 的插件。通过解析 AST,我们可以在源文件中生成具有如下类型的更新数组:

type Insert = { kind: 'insert'; index: number; text: string };
type Replace = { kind: 'replace'; index: number; length: number; text: string };
type Delete = { kind: 'delete'; index: number; length: number };

在生成更新后,剩下的唯一事情就是以相反的顺序应用这些更改。如果通过这些操作的结果,我们接收到新的文本,我们就更新源文件。让我们来看看这些基于 AST 的插件:stripTSIgnorePlugin 和 hoistClassStaticsPlugin。

stripTSIgnorePlugin 是迁移管线中的第一个插件。它从文件中删除所有 @ts-ignore(@ts-ignore 注释允许我们告诉编译器忽略下一行中的错误)实例。如果我们正在将一个 JavaScript 项目转换成 TypeScript,这个插件不会做任何事情。但是,如果这是一个有一部分 TypeScript 的项目(在 Airbnb,我们有一些处于这种状态的项目),那么这是必不可少的第一步。只有在删除 @ts-ignore 注释后,TypeScript 编译器才会发出所有需要解决的诊断错误。

const str3 = foo
? // @ts-ignore
// @ts-ignore comment
bar
: baz;

转换为:

const str3 = foo
? bar
: baz;

在删除 @ts-ignore 注释后,我们运行 hoistClassStaticsPlugin。这个插件遍历文件中的所有类声明。它决定我们是否可以提升标识符或表达式,并确定是否已经将赋值提升到类。

为了能够快速迭代并防止回归,我们为每个插件和 ts-migrate 增加了一系列单元测试。

React 相关插件

reactPropsPlugin 将类型信息从 PropTypes 转换为一个 TypeScript 属性类型定义。这个插件是基于 Mohsen Azimi 编写的非常棒的工具。我们只需要在包含至少一个 React 组件的.tsx 文件上运行这个插件。reactPropsPlugin 查找所有 PropTypes 声明,并尝试用 AST 和简单正则表达式(如 /number/)或更复杂的正则表达式(如 /objectOf$/)来解析它们。当检测到一个 React 组件(无论是函数式组件还是类组件),它将被转换为一个具有新的 type Props = {…}; 属性类型的组件。

reactDefaultPropsPlugin 覆盖了 React 组件的 defaultProps 模式。我们使用一种特殊类型来表示具有默认值的 props:

type Defined<T> = T extends undefined ? never : T;
type WithDefaultProps<P, DP extends Partial<P>> = Omit<P, keyof DP> & {
[K in Extract<keyof DP, keyof P>]:
DP[K] extends Defined<P[K]>
? Defined<P[K]>
: Defined<P[K]> | DP[K];
};

我们试图找到默认的 props 声明,并将它们与上一步生成的组件 props 类型合并。

状态和生命周期的概念在 React 生态系统中很常见。我们在两个插件中解决了它们。如果一个组件是有状态的,reactClassStatePlugin 生成一个新的 type State = any; ,reactClassLifecycleMethodsPlugin 用适当的类型注解组件的生命周期方法。这些插件的功能可以扩展,包括用更具描述性的类型替换 any 的能力。

对状态和 props 的类型支持有更多改进的空间。然而,作为一个起点,这个功能被证明是足够的。我们还不涉及 hooks,因为一开始迁移的时候,我们的代码库使用的是比较老的 React 版本。

确保项目编译成功

我们的目标是获得一个可编译的 TypeScript 项目,它的基本类型覆盖不会导致应用程序运行时行为的改变。

在进行所有转换和代码修改之后,我们的代码可能会有不一致的格式,并且一些 lint 检查可能会失败。我们的前端代码库依赖一个 prettier-eslint 设置——Prettier 用来自动格式化代码,ESLint 确保代码遵循最佳实践。因此,我们可以通过从我们的插件运行 eslint-prettier 来快速修复前面步骤可能引入的任何格式问题。

迁移管线的最后一部分确保所有的 TypeScript 编译冲突都得到解决。为了检测和修复潜在的错误,tsIgnorePlugin 使用行号进行语义诊断,并插入带有有用解释的 @ts-ignore 注释,例如:

// @ts-ignore ts-migrate(7053) FIXME: No index signature with a parameter of type 'string...
const { field1, field2, field3 } = DATA[prop];
// @ts-ignore ts-migrate(2532) FIXME: Object is possibly 'undefined'.
const field2 = object.some_property;

我们也增加了对 JSX 语法的支持:

{*
// @ts-ignore ts-migrate(2339) FIXME: Property 'NORMAL' does not exist on type 'typeof W... */}
<Text weight={WEIGHT.NORMAL}>
some text
</Text>
<input
id="input"
// @ts-ignore ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'string'.
name={getName()}
/>

在注释中包含有意义的错误信息可以更容易地修复问题和重新访问需要注意的代码。这些注释,结合 $TSFixMe (我们为 any 类型引入了自定义的别名 $TSFixMe 和函数类型——$TSFixMeFunction = (…args: any[]) => any; 。尽管最佳实践是避免使用 any 类型,但使用它可以帮助我们简化迁移过程,并明确哪些类型应该重新访问),使得我们可以收集有关代码质量的有用数据,并确定可能存在问题的代码区域。

最后值得一提的是,我们需要运行 eslint-fix 插件两次。一次是在 tsIgnorePlugin 之前,给定的格式可能会影响我们在哪里得到编译错误。另一次是在 tsIgnorePlugin 之后,因为插入 @ts-ignore 注释可能会引入新的格式错误。

总结

我们的迁移故事正在进行中:我们有一些遗留项目仍然在用 JavaScript,我们在代码库中仍然有大量的 $TSFixMe 和 @ts-ignore 注释。

图片

但是,使用 ts-migrate 大大加快了我们迁移的过程和效率。工程师们能够专注于类型改进,而不是手动进行逐文件的迁移。目前,我们的 600 万行前端代码库的大约 86% 已经转换为 TypeScript,到今年年底,我们有望达到 95%。

你可以检出 ts-migrate 代码,并在 GitHub 代码库的主包中找到如何安装和运行 ts-migrate 的说明。如果你发现了任何问题或者有任何改进的想法,我们欢迎你的贡献!

Brie Bunge 是 Airbnb TypeScript 的幕后推动者,也是 ts-migrate 的创建者,对其致以最大的敬意。感谢 Joe Lencioni 帮助我们在 Airbnb 采用 TypeScript,并改进我们的 TypeScript 基础设施和工具。特别感谢 Elliot Sachs 和 John Haytko 对 ts-migrate 所做的贡献。感谢所有一路提供反馈和帮助的人!

后记

我们在迁移过程中发现的一些有用的东西:

TypeScript 的 3.7 版本引入了 @ts-nocheck 注释,可以增加在 TypeScript 文件的头部来禁用语义检查。我们没有使用这个注释,因为它之前不支持.ts/.tsx 文件,但它也可以在迁移过程中成为一个很好的中间阶段助手。

TypeScript 的 3.9 版本引入了 @ts-expect-error 注释。当一行以 @ts-expect-error 注释作为前缀时,TypeScript 将禁止报告该错误。如果没有错误,TypeScript 会报告 @ts-expect-error 是不必要的。在 Airbnb 代码库,我们使用了 @ts-expect-error 而不是 @ts-ignore 。

原文链接

https://medium.com/airbnb-engineering/ts-migrate-a-tool-for-migrating-to-typescript-at-scale-cd23bfeb5cc

图片

本文章转载自公众号:frontshow

首页 - 前端 相关的更多文章: