将30K行Flow代码移植到TypeScript - davidgom

19-01-15 banq
                   

我们最近移植了MemSQL Studio的3万行JavaScript,从使用Flow到TypeScript。在本文中,我描述了为什么我们移植了代码库,它是如何发生的以及它是如何为我们工作的。

免责声明:我在这篇博文中的目标不是谴责Flow或Flow的使用。我非常欣赏这个项目,我认为JavaScript社区中有足够的空间用于两种类型的检查器。在一天结束时,每个团队都应该研究他们所有的选择,并选择最适合他们的选择。我真诚地希望这篇文章能帮助你做出这样的选择。

让我们从一些背景开始。在MemSQL,我们是静态和强烈类型的忠实粉丝,这样避免动态和弱类型的常见问题,例如:

  1. 由于代码的不同部分不同的隐式类型合同而导致的运行时类型错误。
  2. 花费太多时间来处理诸如参数类型检查之类的微不足道的事情(运行时类型检查也增加了包大小)。
  3. 缺乏编辑器/ IDE集成,因为在没有静态类型的情况下,获取跳转到定义,机械重构要困难得多。
  4. 能够围绕数据模型编写代码,这意味着我们可以先设计我们的数据类型,然后我们的代码基本上只是“每个人自己编写”。

这些只是静态类型的一些优点,我在最近关于Flow的博客文章中描述了一些。

在2016年初,我们开始使用tcomb来确保我们的一个内部JavaScript项目中的运行时类型安全性(免责声明:我不是该项目的一部分)。虽然运行时类型检查有时很有用,它甚至不会开始破坏静态类型的功能。考虑到这一点,我们决定开始将Flow用于我们在2016年开始的另一个项目。当时,Flow是一个很好的选择,因为:

  • 以Facebook为后盾,在发展React和React社区方面做得非常出色(他们还使用 Flow 开发了React )。
  • 我们没有必要购买一个全新的JavaScript开发生态系统。删除tsc(TypeScript编译器)的Babel是可怕的,因为它不会给我们在将来切换到Flow或其他类型检查器的灵活性(显然这已经改变了)。
  • 我们没有必要输入我们的整个代码库(我们想要在全押之前感受静态类型的JavaScript),而是我们可以只键入文件的子集。请注意,Flow和TypeScript现在允许您执行此操作。
  • TypeScript(当时)缺少Flow已经支持的一些基本功能,例如查找类型通用参数默认值等。

当我们在2017年末开始使用MemSQL Studio时,我们开始在整个应用程序中实现完整的类型覆盖(所有这些都是用JavaScript编写的,前端和后端都在浏览器中运行)。我们决定使用Flow作为我们过去成功使用的东西。

但是,使用TypeScript支持发布的Babel 7肯定引起了我的注意。此版本意味着采用TypeScript不再意味着购买整个TypeScript生态系统,我们可以继续使用Babel发布JavaScript。更重要的是,这意味着我们实际上可以使用TypeScript作为类型检查器,而不仅仅是“语言”本身。

就个人而言,我认为将类型检查器与发射器emitter分离是在JavaScript中实现静态(和强)类型的更优雅方式,因为:

  1. 在发布ES5和类型检查的内容之间进行一些关注点分离是一个好主意。这样可以减少类型检查器周围的锁定,并加快开发速度(如果类型检查器因任何原因而变慢,则代码将以正确的方式发射)。
  2. Babel拥有令人惊叹的插件和TypeScript的发射器所没有的强大功能。例如,Babel允许您指定要支持的浏览器,它将自动发出在这些浏览器上有效的代码。这实现起来非常复杂,只有让Babel实现它而不是在两个不同的项目中在社区中重复这种努力更有意义。
  3. 我喜欢JavaScript作为一种编程语言(除了缺少静态类型),我不知道TypeScript会持续多长时间,而我相信ECMAScript会存在很长一段时间。出于这个原因,我更喜欢在JavaScript中继续写作和思考。(请注意,我总是说“使用Flow”或“使用TypeScript”而不是“in Flow”或“in TypeScript”,因为我总是将它们视为工具而不是编程语言)。

当然,这种方法有一些缺点:

  1. 理论上,TypeScript编译器可以根据类型执行包优化,并且通过使用单独的发射器和类型检查器而缺少它。
  2. 当您拥有更多工具和开发依赖项时,项目配置会变得有点复杂。我认为这是一个比大多数人所做的更弱的论点,因为Babel + Flow在我们的项目中从来都不是配置问题的来源。

研究TypeScript作为Flow的替代方案

我一直注意到在线和本地JavaScript社区对TypeScript越来越感兴趣。因此,当我第一次发现Babel 7支持TypeScript时,我开始研究是否可能会远离Flow。最重要的是,我们遇到了Flow的各种挫败:

  1. 较低质量的编辑器/ IDE集成(与TypeScript相比)。不推荐使用 Nuclide(Facebook拥有最好的Flow集成的IDE)并没有帮助。
  2. 较小的社区,因此各种库包的质量类型定义较少且总体较低(稍后将详细介绍)。
  3. Facebook和社区的Flow团队之间缺乏公共路线图和很少的互动。您可以通过Facebook员工阅读此评论以获取更多详细信息。
  4. 高内存消耗和频繁的内存泄漏 - 我们团队中的各种工程师经历了Flow,它偶尔会占用近10 GB的RAM。

当然,我们还必须研究TypeScript是否适合我们。这非常复杂,但它涉及到对文档的全面阅读,这有助于我们弄清楚Flow中的每个功能在TypeScript中都具有相同的功能。然后,我研究了TypeScript公共路线图,并对前面提到的功能非常满意(例如,部分类型参数推断是我们在Flow中使用的一个特性)。

将30K +代码行从Flow移植到TypeScript

实际将所有代码从使用Flow移植到TypeScript的第一步是将Babel从6升级到7.这有点简单但是我们花了大约2个工程师时间,因为我们决定同时将Webpack 3升级到4 。由于我们的源代码中存在一些遗留的依赖项,因此对于绝大多数JavaScript项目来说,这应该比它应该更难。

完成此操作后,我们可以使用新的TypeScript预设替换Babel的Flow预设,然后针对使用Flow编写的完整源代码首次运行TypeScript编译器。它导致8245个语法错误(在您有0个语法错误之前,tsc CLI不会为整个项目提供真正的错误)。

这个数字起初吓到了我们(很多),但我们很快发现其中大部分都与TypeScript不支持.js文件有关。经过一番调查,我发现TypeScript文件必须以“.ts”或“.tsx”结尾(如果它们中有JSX)。我不想考虑我正在创建的新文件是否应该具有“.ts”或“.tsx”扩展名,我认为这是一个糟糕的开发人员体验。出于这个原因,我只是将每个单元重命名为“.tsx”(理想情况下,我们所有的文件都会像Flow一样具有“.js”扩展名,但我也可以使用“.ts”)。

在更改之后,我们有大约4000个语法错误。它们中的大多数与导入类型有关,可以使用替换为TypeScript的“import”,也可以使用Flow({||}vs {})中的密封对象表示法替换。在几个快速的RegExes之后,我们发现了414个语法错误。其余的都必须手动修复:

  • 我们用于部分泛型类型参数推断的存在类型必须替换为显式命名各种类型参数或使用未知类型告诉TypeScript我们不关心某些类型参数。
  • $Keys 型和其他先进的流量类型有不同的语法在打字稿(例如$Shape<>对应于TypeScript的Partial<>)。

修复了所有语法错误之后,tsc(TypeScript编译器)最终告诉我们代码库有多少实际类型错误 - 大概只有1300左右。这时我们不得不坐下来决定继续运行是否合理。毕竟,如果我们花费数周的开发时间,那么继续使用该端口是不值得的。但是,我们认为单个工程师停摆的时间不应超过1周,因此我们需要加快速度提前。

请注意,在转换期间,我们不得不停止此代码库上的其他工作。但是,应该可以为这样的停摆并行地贡献新的工作 - 但是你必须处理潜在的数百种类型错误,这不是一件容易的事。

这些类型的错误是什么?​​​​​​​

TypeScript和Flow对许多不同的东西做出了不同的假设,这在实践中意味着它们让你的JavaScript代码做不同的事情。Flow对某些事情更严格,TypeScript对其他事情更严格。两种类型检查器之间的全面深入比较会非常长,所以在这篇博文中我们只研究一些例子。

注意:本文中的所有TypeScript playground链接都假设所有“严格”设置都已打开,但遗憾的是,当您共享TypeScript playground链接时,这些设置不会保存在URL中。因此,您必须在打开本文中的任何TypeScript playground链接时手动设置它们。

invariant.js

我们的源代码中一个非常常见的函数是invariant函数,看文档更好,所以我在这里引用它:

var invariant = require('invariant');

invariant(someTruthyVal, 'This will not throw');
// No errors

invariant(someFalseyVal, 'This will throw an error with this message');
// Error raised: Invariant Violation: This will throw an error with this message

这个想法很简单 - 一个简单的函数,可能会根据某些条件抛出错误。让我们看看我们如何实现它并将其与Flow一起使用

type Maybe<T> = T | void;

function invariant(condition: boolean, message: string) {
  if (!condition) {
    throw new Error(message);
  }
}

function f(x: Maybe<number>, c: number) {
  if (c > 0) {
    invariant(x !== undefined, "When c is positive, x should never be undefined");

    (x + 1); // works because x has been refined to "number"
  }
}

现在让我们通过TypeScript运行完全相同的代码片段。正如您在链接中看到的那样,我们从TypeScript中收到错误,因为最后一行它无法确定“x”实际上保证不是undefined。

这实际上是TypeScript的一个已知问题 - 它无法通过函数执行此类推理(尚未)。但是,由于它是我们代码库中非常常见的模式,我们不得不用更多的手动代码替换每个不变量的实例(超过150个),这些代码只会在现场抛出错误:

type Maybe<T> = T | void;

function f(x: Maybe<number>, c: number) {
  if (c > 0) {
    if (x === undefined) {
      throw new Error("When c is positive, x should never be undefined");
    }

    (x + 1); // works because x has been refined to "number"
  }
}

这不是很好,但invariant它也不是一个大问题。

$ExpectError vs @ts-ignore

Flow有一个非常有趣的功能@ts-ignore,如果下一行不是错误它将会出错。这对于编写“类型测试”非常有用,这些测试确保我们的类型检查器(无论是TypeScript还是Flow)找到我们希望它找到的某些类型错误。

不幸的是,TypeScript没有这个功能,这意味着我们的类型测试失去了一些价值。这是我期待 TypeScript实现的东西。

一般类型错误和类型推断​​​​​​​

通常,TypeScript可以比Flow更明确,如下例所示

type Leaf = {
  host: string;
  port: number;
  type: "LEAF";
};

type Aggregator = {
  host: string;
  port: number;
  type: "AGGREGATOR";
}

type MemsqlNode = Leaf | Aggregator;

function f(leaves: Array<Leaf>, aggregators: Array<Aggregator>): Array<MemsqlNode> {
  // The next line errors because you cannot concat aggregators to leaves.
  return leaves.concat(aggregators);
}

Flow 将leaves.concat(aggregators)的类型推断为Array<Leaf | Aggregator> ,我认为这是一个很好的例子,有时Flow有点聪明,而TypeScript有时需要一些帮助(在这种情况下我们可以使用类型断言来帮助TypeScript,但是使用类型断言是危险的,应该做得很好小心)。

即使没有我说的这一点证据,我认为Flow在类型推断方面比TypeScript更优越。我非常希望TypeScript能够达到Flow的水平,因为它是非常积极的开发,并且最近对TypeScript的许多改进都在这个确切的领域。在我们的源代码的许多部分中,我们必须通过注释或类型断言给TypeScript一些帮助(我们尽可能避免使用类型断言)。让我们再看一个例子(我们可能有超过200个这种类型错误的实例):

type Player = {
    name: string;
    age: number;
    position: "STRIKER" | "GOALKEEPER",
};

type F = () => Promise<Array<Player>>;

const f1: F = () => {
    return Promise.all([
        {
            name: "David Gomes",
            age: 23,
            position: "GOALKEEPER",
        }, {
            name: "Cristiano Ronaldo",
            age: 33,
            position: "STRIKER",
        }
    ]);
};

TypeScript不会让你写这个,因为它不能让你将{ name: "David Gomes", age: 23, type: "GOALKEEPER" }转换到作为Player类型的对象(打开Playground链接以查看确切的错误)。这是另一个我认为TypeScript不够“足够智能”的例子(至少与理解这段代码的 Flow相比)。

为了使这项工作,你有几个选择:

  • 将“STRIKER”断言为“STRIKER”,以便TypeScript理解该字符串是类型的有效枚举"STRIKER" | "GOALKEEPER"。
  • 断言整个对象as Player。
  • 或者我认为是最好的解决方案,只需通过编写帮助TypeScript而不使用任何类型的断言Promise.all<Player>(...)。

另一个例子是以下(TypeScript),其中Flow再次出现,因为它具有更好的类型推断

type Connection = { id: number };

declare function getConnection(): Connection;

function resolveConnection() {
  return new Promise(resolve => {
    return resolve(getConnection());
  })
}

resolveConnection().then(conn => {
  // TypeScript errors in the next line because it does not understand
  // that conn is of type Connection. We have to manually annotate
  // resolveConnection as Promise<Connection>.
  (conn.id);
});

一个非常小但但有趣的例子是Flow类型Array<T>.pop()作为T,而TypeScript 认为它是T|void,这是支持TypeScript的一个要点,因为它会强制您仔细检查该项是否存在(如果该数组为空,则Array.pop返回undefined)。还有一些像这样的小例子,其中TypeScript比Flow更胜一筹。

TypeScript定义用于第三方依赖项

当然,在编写任何JavaScript应用程序时,您可能至少有少数依赖项。这些都需要输入,否则你将失去静态类型分析的大部分功能(如本文开头所述)。

从npm导入的库可以附带Flow类型定义,TypeScript类型定义,包含这两者或都不包含两者。很常见的是(较小的)库没有任何含义,你必须为它们编写自己的类型定义或者从社区中获取一些。Flow和TypeScript社区都有一个标准的JavaScript包第三方类型定义存储库:flow-typedDefinitelyTyped

我不得不说我们用DefinitelyTyped度过了更好的时光。使用flow-typed,我们必须使用其CLI工具将各种依赖项的类型定义引入到项目中。DefinitelyTyped通过在npm的软件包存储库中发送@ types / package-name软件包,找到了将此功能与npm的CLI工具合并的方法。这是惊人的,它使我们更容易为我们的依赖项引入类型定义(jest,react,lodash,react-redux仅举几例)。

除此之外,我还有很多时间贡献给DefinitelyTyped(当将代码从Flow移植到TypeScript时,不要指望类型定义是等价的)。我已经发出 一些pull请求和所有这些请求都是轻而易举的。只需克隆,编辑类型定义,添加测试并发送拉取请求。

DefinitelyTyped GitHub bot将标记为您为评论编辑的类型,这样可定义那些做出贡献的人员。如果他们都没有在7天内提供评论,那么DefinitelyTyped维护者将审核PR。合并到master后,依赖项包的新版本将发送到npm。例如,当我第一次更新@ types / redux-form包时,版本7.4.14在合并到master后自动被推送到npm。这使我们可以非常轻松地更新我们的package.json文件以获取新的类型定义。如果您不能等待PR被接受,您可以随时覆盖项目中使用的类型定义,正如我在最近的博客文章中所解释的那样

总体而言,DefinitelyTyped中的类型定义的质量要好得多,因为TypeScript背后的社区更大,更繁荣。事实上,在将我们的项目从Flow移植到TypeScript后,我们的类型覆盖率从88%增加到96%,主要是因为更好的第三方依赖类型定义,其中包含更少的any类型。

Linting和测试

  1. 我们从eslint转移tslint(我们发现开始使用TypeScript的eslint比较复杂,所以我们只使用了tslint)。
  2. 我们使用ts-jest来运行使用TypeScript的测试。我们的一些测试是打字的,而其他测试是无类型的(当输入测试的工作太多时,我们将它们保存为.js文件)。

我们修复了所有类型错误后发生了什么?

经过一个工程师一周的工作后,我们得到了最后一个类型错误,我们在短期内推迟了@ts-ignore。

在解决了一些代码审查注释并修复了一些错误之后(不幸的是,我们不得不改变很少的运行时代码来修复TypeScript无法理解的逻辑),PR登陆以后,从那时起我们就一直在使用TypeScript。

除了编辑器集成之外,使用TypeScript与使用Flow非常相似。Flow的服务器的性能稍微快一点,但这并不是一个大问题,因为它们同样快速地为你正在查看的文件提供内联错误。唯一的性能差异是TypeScript需要更长的时间(约0.5到1秒)来告诉您保存文件后项目中是否有任何新错误。服务器启动时间大约相同(约2分钟),但这并不重要。到目前为止,我们没有任何内存消耗问题,并且tsc似乎一直使用大约600兆字节的RAM。

可能看起来Flow的类型推断使它比TypeScript好得多,但有两个原因可以解释为什么这不是什么大问题:

  1. 我们是从Flow转换到TypeScript的。这意味着我们显然只会发现Flow可以表达的东西但TypeScript不能(反过来呢?)。我相信我们会发现TypeScript可以在推断/表达比Flow更好的东西。
  2. 类型推断很重要,它有助于保持我们的代码更简洁。然而,在一天结束时,像强大的社区和类型定义的可用性之类的东西更重要,因为弱类型推断可以通过更多地“手持”类型检查器来解决。

代码覆盖率统计:

$ npm run type-coverage # https://github.com/plantain-00/type-coverage 43330 / 45047 96.19%