ts-migrate - 大规模迁移到TypeScript的工具(译)

2,725 阅读10分钟

最近在把一个React Native项目从js和ts混编改造成完全的ts,看到爱彼迎有一个迁移到ts的工具,并且有一篇文章描述这个工具的使用以及爱彼迎迁移的过程,因此在这里翻译一下,以便理解。原文链接

Typescript是爱彼迎官方的前端开发语言。目前,一天内把包含数千个JavaScript文件的成熟代码库迁移成Typescript是不可能的。在爱彼迎,Typescript的应用从最初的提议、多个团队的试用、测试、成为爱彼迎官方的前端开发语言。你可以通过Bire Bunge的演讲来了解我们是如何大规模的推广Typescript的。

迁移策略

大规模的迁移是很复杂的任务,我们探索了几种从JavaScript转换为TypeScript的选项:

混合编译策略

一个一个文件地进行部分的迁移改造,修复类型错误直到整个项目完成迁移。allowJS这个配置选项允许我们的项目中同时存在js和ts文件,从而为这种方式提供了可能。

在混编迁移策略下,我们不必暂停开发,并且可以逐步地逐文件迁移文件。但是从全局来看,迁移可能耗费很长的时间。此外,还需要教育和引进组织内的其他开发者。

全部迁移策略

把一个js项目或者js、ts混编项目彻底的改造成纯ts的项目。我们需要一些any类型以及@ts-ignore注释来让项目运行起来不报错,但长期来看我们还是要用描述性更强的类名来代替它们。

选择全部迁移策略有几个明显的优势:

  • 项目一致性:全部迁移策略保证了每个文件的状态都是一致的,开发者不需要记住哪里可以使用Typescript的特性以及哪里的基础错误会被编译器阻止
  • 只是添加类型的工作比整个文件的适配工作要简单:一个文件有大量外部依赖时是很难单独修复的。在混编模式下,很难追踪一个文件的修复进程以及修复状态。(译者注:这里原文并没有什么太大的逻辑关系。。。但混编的情况下,一个ts文件依赖js文件,的确是会报类型缺失的错误,这个时候不得不为js文件也添加类型,的确是很烦人的)

从此来看全部迁移策略占有全面的优势!但是,全面迁移大型成熟代码库的实际执行过程是一个繁重而复杂的问题。为了解决这个问题,我们决定使用修改代码的脚本codemod!通过我们最开始手动迁移到Typescript的过程,我们发现有些重复的工作可以实现自动化。我们为每个步骤制作了codemod,并将它们集成到总体迁移流水线中。

根据我们的经验,自动化迁移中不可能100%保证项目不报错,但是我们发现下面这些步骤的结合能让项目迁移完成并不报错。我们曾经试过基于这些codemod在一天内完成了超过5W行代码,一千多个文件从JavaScript到Typescript的迁移。

基于这条流水线,我们创造了名为ts-migrate的工具!

image.png

在爱彼迎,react是前端项目中重要的一部分,这就是为何部分codemod会涉及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.下一步就是运行codemod了!我们称之为“插件”。ts-migrate的插件就是一些能够通过Typescript服务器拿到额外信息的codemod。插件以字符串作为输入并且返回一个更新后的字符串作为输出。jscodeshift,TypeScript API,字符串替换或其他AST修改工具可用于实现更强大的代码转换。

结果这些步骤,我们检查Git历史和commit列表,看有没有代码在等待合并。这有利于我们区分迁移pull request的commit,从而更容易理解和地追踪文件重命名。

ts-migrate包的概览

我们把ts-migrate区分成了三个包:

  1. ts-migrate
  2. ts-migrate-server
  3. ts-migrate-plugins

通过这样做,我们能够把转换逻辑从核心代码中分离出来,并且可以出于不同的目的添加多种配置。选择,我们主要有两种配置:migrationreignore

当迁移配置只想从js迁移到ts,reignore通过简单的忽略错误来让项目可以编译。当一个大项目并且符合以下几点的时候非常合适使用reignore配置:

  • 升级ts版本
  • 对代码库作重大修改
  • 为一些常用库增加类型

通过这个方法,即使有部分我们不想马上处理的错误,我们还是可以马上把项目迁移成ts。这让升级ts和依赖库的版本变得更简单。

运行在ts-migrate-server上的全部配置都包含了以下两部分:

  • TSServer:这部分非常像是vscode在编辑器和语言服务器之间的通信。把一个新的ts语言服务器当作一个独立的进程运行起来,并且用语言的协议与开发工具之间进行通信。

  • 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. 把每个文件发给ts语言服务器作诊断。编译器为我们提供了三种类型的诊断:semanticDiagnostics, syntacticDiagnostics, and suggestionDiagnostics。使用编译器的诊断能力从而定位源码中有问题的地方。根据唯一的诊断编码以及行号,我们可以区分问题的类型并且适当的修改代码。
  4. 为每个文件运行插件。如果插件需要修改文件中的内容,我们会修改源文件中的内容,并且通知ts语言服务器这个文件被改变了。

你可以在examples目录或主目录下找到ts-migrate-server的使用例子。ts-migrate-example也包含了使用插件的简单例子。它们主要分为三个类型:

在代码仓库中有一些例子可以说明如何编写以上三种类型的简单插件,并且结合tsmigrate-server使用。这里提供一个对以下代码进行转换的迁移流水线的简单例子:

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

变成

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

对于上面这个例子,ts-migrate做了三个转换:

  1. 转换所有标识符first -> tsrif
  2. 为函数增加类型声明function tlum(tsrif, dnoces) -> function tlum(tsrif: number, dnoces: number): number
  3. 插入 console.log(‘args:${arguments}’);

通用插件

实际的插件位于独立的npm包中 - ts-migrate-plugins。让我们来看看其中的插件。其中有两个基于jscodeshift-based的插件:explicitAnyPlugin和declareMissingClassPropertiesPlugin。jscodeshift是一个通过recast包中的toSource()函数来把ast转换回字符串的工具,从而我们可以直接更新所有文件中的源码。

explicitAnyPlugin 的原理是从ts语言服务器中把全部语义诊断的错误和行号提取出来。然后我们需要为诊断出的行中添加any类型。这个方法能让我们解决错误,因为添加any类型能够修复编译错误。

之前的代码

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

之后的代码

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

declareMissingClassPropertiesPlugin 执行了全部代码2339相关的诊断(你能猜到这个代码意味着什么吗),如果可以找到缺少标识符的类声明,该插件会给类中添加any的类型声明。正如该插件的名字所说,仅对es6的类生效。

下一类插件是基于ts 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的插件有:stripTSIgnorePluginhoistClassStaticsPlugin

stripTSIgnorePlugin 是迁移流水线中的第一个插件。它移除了全部源文件中的@ts-ignore。如果我们正在把一个纯js项目转换成ts项目,这个插件不会做任何事。然而,如果这是一个js、ts混编的项目(在爱彼迎,我们有很多项目处于这个状态),这是不可或缺的一步。只有移除掉@ts-ignore才能让ts编译器输出全部需要被追踪到的语义错误。

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

转换成

const str3 = foo
  ? bar
  : baz;

移除掉@ts-ignore注释后我们运行hoistClassStaticsPlugin。这个插件会遍历文件中的全部类声明。以便确定我们是否可以提升标识符或表达式,并确定是否已提升成一个类。

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

React相关的插件

reactPropsPlugin把类型信息从PropTy配送转换成ts props的类型定义。这基于Mohsen Azimi编写的超棒工具。我们需要在至少包含一个React组件的.tsx文件中运行这个插件。reactPropsPlugin找到全部的PropTypes声明并尝试用ast和像/number/这种简单的正则或更复杂的/objectOf$/来解析。当找到一个React组件(无论是函数组件还是类组件),它将会转换新的组件,新的组件的props会有新的类型: 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的体系中,state和生命周期的概念非常普遍。我们用两个插件来追踪它们。如果一个组件是有状态的,reactClassStatePlugin 会生成新的类型type State = any;reactClassLifecycleMethodsPlugin 结合props类型来声明组件生命周期函数。 这两个插件的功能性可以被拓展包括使用更有意义的类型来代替any

仍然有空间进行更多的提升以及对state和props更好的类型支持。然而,作为一个开端,目前的功能性已经足够。我们并没有覆盖hooks,因为在迁移之前我们的代码库使用的是更旧版本的react。

确保项目的成功编译

我们的目标是ts项目能有基本的类型覆盖但又不改变运行时的行为。在上面所有的转换和修改代码之后,代码中可能会有一些不一致的格式和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,能让我们搜集关于代码质量的数据以及区分可能存在潜在问题的代码块。

最后但并非最不重要的,我们需要运行eslint-fix插件两次。tsIgnorePlugin的格式化可能会影响编译错误的输出。在运行tsIgnorePlugin之后,插入了@ts-ignore注释后可能产生新的格式错误。

总结

我们的迁移还在继续:我们有一些仍然是js的遗留项目以及代码库中还有很多的$TSFixMe@ts-ignore注释。

image.png

使用ts-migrate大大加速了我们迁移的进程和生产率。工程师能够专注地进行类型提升而不是手动地一个一个文件地迁移。目前,6M-line上86%的前端monorepo已经转换成ts项目,并且计划年底会达到95%。

你可以在github仓库拉取ts-migrate的源码或者找到安装和在代码库中运行的指引。如果你有任何问题或者优化的想法,我们欢迎你向代码库贡献代码

衷心感谢Brie Bunge,他是ts在爱彼迎的强大推力以及ts-migrate的作者。感谢Joe Lencioni帮助我们在爱彼迎应用ts以及对ts基础设施和工具的建设。特别感谢Elliot Sachs和John Haytko对ts-migrate的贡献。感谢所有在此过程中提供反馈和帮助的人!

脚注

我们想说明一下在此过程中发现的有关迁移的几件事,这可能会有用:

  • ts的3.7版本中引入了@ts-nocheck注释,可以在文件头中加入这个注释来禁用语法检查。我们没有使用这个注释因为早期它不支持.ts/.tsx文件。但是在迁移过程中会有很大帮助。
  • ts的3.9版本引入了@ts-expect-error注释。如果一行代码前面是@ts-expect-error注释,ts不会报告该错误。如果没有错误,ts会报告说不需要该注释。在爱彼迎我们使用@ts-expect-error注释而不是@ts-ignore