详解了将三万行代码从Flow移植到TypeScript的全过程

电子说

1.3w人已加入

描述

在内存安全中,类型安全是很重要的一个命题。为了确保JavaScript项目运行的类型安全,本文的作者介绍了2016年时使用Flow的经历:由Facebook支持的Flow方案,不仅拥有查找类型、泛型参数默认值等基本功能,还有着较为完善的JavaScript开发生态系统。但是随着项目的不断复杂,以及TypeScript功能的逐渐优化,就对项目提出了更多的要求。本文就详解了将三万行代码从Flow移植到TypeScript的全过程。

以下为译文:

最近我们将MemSQL Studio的3万行JavaScript代码从Flow移植到了TypeScript。在本文中,我将介绍我们移植代码库的原因以及移植的全过程。

事先声明,我写这篇文章的目的不在于谴责Flow或Flow的使用。我非常欣赏Flow这个项目,我认为在JavaScript社区中Flow和TypeScript这两种类型检查器都有足够的发展空间。但是,每个团队都需要仔细研究并选择最适合自己的。因此我真诚地希望这篇文章对你的选择能有所帮助。

背景

首先介绍一下背景故事。在MemSQL,我们都喜欢静态强类型的JavaScript代码,这是为了避免动态弱类型的常见问题。例如:

动态弱类型问题中不同部分的代码对于隐式类型契约的假设不一致,会引发运行时的类型错误;

而且动态弱类型在测试小问题上花费的时间太多,比如参数类型检查(运行时类型检查也会增大打包文件的尺寸);

此外动态弱类型还缺乏编辑器/ IDE集成,因为在没有静态类型的情况下,很难实现跳转定义、自动重构以及其他功能;

静态强类型还具有动态弱类型问题所缺失的围绕数据写代码的能力,这意味着我们可以先设计数据类型,然后我们的代码就会“自然成型”。

这些还只是静态类型部分的优点。

2016年初,我们开始在一个内部的JavaScript项目上使用tcomb,以确保运行时的类型安全(声明:我并没有参与那个项目)。虽然有时运行时的类型检查很有用,但它与静态类型毫不沾边。考虑到这一点,2016年的时候我们决定在另一个项目中使用Flow。当时,Flow是一个很好的选择,因为:

Flow由Facebook支持,在日益壮大的React社区中收到了相当多的好评(React也是用Flow开发的);

我们没有必要尝试一个全新的JavaScript开发生态系统,抛弃Babel转向tsc(TypeScript编译器)的风险会很大,还会失去切换到Flow或其他类型检查器的灵活性(显然后来情况发生了变化);

我们也没有必要在整个代码库上采用类型(因为我们想先尝试一下静态类型的JavaScript),我们只想在部分文件上采用类型(不过请注意,现在的Flow和TypeScript都允许开发者这么做);

当时的TypeScript缺少Flow支持的一些基本功能,例如查找类型、泛型参数默认值等。

当2017年年末开始开发MemSQL Studio时,我们准备在整个应用程序中实现完整的类型覆盖(整个应用程序都是用JavaScript编写的,前端和后端都在浏览器中运行)。因为以前成功使用的经验,所以我们决定此次也使用Flow。

然而,最新发布的Babel 7已经开始支持TypeScript了,这引起了我的注意。这个发布意味着采用TypeScript不再需要引入整个TypeScript生态系统,我们可以继续通过Babel来生成JavaScript。更重要的是,这意味着实际上我们可以将TypeScript作为类型检查器,而不是作为一种“语言”。

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

将生成ES5和类型检查从思想上进行某种程度的分离是一个好主意。如此一来可以减少类型检查锁定的范围,并加快开发速度(即使类型检查因某些原因而变慢,你的代码生成也不会受到影响)。

Babel拥有一些非常优秀的插件和了不起的功能,这些都是TypeScript的生成器所没有的。例如,Babel允许开发者指定想要支持的浏览器,它将自动生成在这些浏览器上有效的代码。不过这实现起来非常复杂,因此应当让Babel做这一切,而不是让社区在两个不同项目上重复这种努力。

我喜欢JavaScript这种编程语言(除了它缺少静态类型),我不知道TypeScript会最终存活多久,但我相信ECMAScript会长期存在。出于这个原因,我更喜欢用JavaScript思考和写代码。

注意,我一直在说“使用Flow”或“使用TypeScript”,是因为我总是把它们当成工具,而非编程语言。

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

理论上,TypeScript编译器可以根据类型执行优化,但如果将生成与类型检查分离就失去这个优势了;

如果需要依赖很多工具和开发,那么项目配置会变得稍复杂。不过我认为这个不足为虑,因为在我们的项目中Babel + Flow从来都没出现过配置的问题。

TypeScript 能替代 Flow 方案吗?

我注意到网上和本地JavaScript社区对TypeScript的兴趣越来越浓厚。因此,当发现Babel 7支持TypeScript时,我就开始调查代替Flow的可能性。最重要的是,在使用Flow的时候我们遇到了很多挫折:

编辑器/ IDE集成的质量很低(与TypeScript相比)。Nuclide(Facebook自己的IDE,拥有最好的Flow集成)已经不再维护,所以没什么用了。

社区很小。各种代码库数量较少,且总体的类型定义质量较低。

Facebook和社区的Flow团队之间缺乏公共的规划,且互动很少。

内存消耗很高且内存泄漏频繁,我们团队的工程师偶尔会经历Flow占用10GB的RAM的现象。

当然,我们还必须研究TypeScript是否合适我们。调查的过程非常复杂,但通过全面地阅读文档,我们发现Flow的每个功能在TypeScript中都有相应的支持。之后,我又研究了TypeScript的项目规划,发现上面提到的功能都有非常满意的支持(例如,我们在Flow中使用的一个部分类型参数推断的功能)。

将三万多行代码从 Flow 移植到 TypeScript

实际上,将所有代码从Flow移植到TypeScript的第一步是将Babel从6升级到7。这项工作看似简单,但由于我们决定将Webpack 3升级到4,所以最后花了两天的时间。由于我们的代码中有一些遗留的依赖,所以此次的难度要比绝大多数JavaScript项目都高。

完成这一步后,我们就可以用新的TypeScript预设替换Babel的Flow预设,然后在用Flow编写的完整源代码上运行TypeScript编译器——结果发生了8245个语法错误(只有在没有语法错误的情况下tsc的命令行工具才会报告项目中的真正的错误)。

我们被这个数字吓到了,但是很快我们就发现其中大部分是由于TypeScript不支持.js文件导致的。经过一番调查,我发现TypeScript文件必须以“.ts”或“.tsx”结尾(包含JSX的情况)。我不想在创建新文件的时候犹豫是应该使用“.ts”还是“.tsx”的扩展名,因为这是一种糟糕的开发体验。所以,我决定将所有文件都重命名为“.tsx”(理想情况下,应当像Flow一样所有的文件都具有“.js”扩展名,但我也可以接受使用“.ts”)。

经过这次修改后,我们有大约4000个语法错误。其中大多数都与导入类型有关,我们可以TypeScript的“import”替换,也可以使用Flow({||} vs {})中的密封对象表示法替换。在使用了几个正则表达式替换之后,我们的语法错误数量降到了414个。剩下的部分只能手动修复了:

部分泛型类型参数推断中使用的既存型别必须替换为显式命名的各种类型的参数,或通过unknown类型告诉TypeScript我们并不关心某些类型的参数;

$Keys类型和其他Flow高级类型在TypeScript中具有不同的语法,例如,$Shape <>与TypeScript中的Partial<>对应)。

修复了所有语法错误之后,tsc(TypeScript编译器)终于告诉我们,代码库中大约有1300个真正的类型错误。这时我们不得不坐下商量是否还应该继续,毕竟,如果要花费数周的开发时间,此次移植就得不偿失了。但是,我们发现只需花费不到1周的时间就可以完成移植,所以我们决定继续。

注意,在转换期间,我们必须停止代码库的开发工作。当然,在移植期间依然可以继续开发新代码,但是你必须在可能有数百种之多的类型错误上进行工作,这不是一件易事。

都有哪些类型错误?

在很多方面,TypeScript和Flow都做出了不同的假设,在实践中这意味着JavaScript代码的行为会有所不同。在某些方面Flow更严格,而TypeScript在其他方面又更为严格。深入比较两种类型检查会花费大量时间,所以在本文中我只举几个例子。

注意:本文中所有的TypeScript练习环境(http://www.typescriptlang.org/play/)的链接都假设所有的“严格”设置都被打开了,但遗憾的是在分享TypeScript练习环境时,这些设置都不会保存到URL中。因此,可以点击上面的连接打开TypeScript练习环境之后再手动设置。

invariant.js

我们的源代码中有一个很常用的函数invariant,这个文档(https://github.com/zertosh/invariant#invariantcondition-message)很好地解释了它的功能:

var invariant = require('invariant');invariant(someTruthyVal, 'This will not throw');// No errorsinvariant(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 | void;function invariant(condition: boolean, message: string) {  if (!condition) {    throw new Error(message);  }}function f(x: Maybe, 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的问题——它无法在函数中进行这类的推理。但是,由于这样的代码在我们代码库中很常见,所以我们就被迫手动替换每一个invariant的实例(有150多个):

type Maybe = T | void;function f(x: Maybe, 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, aggregators: Array): Array {  // The next line errors because you cannot concat aggregators to leaves.  return leaves.concat(aggregators);}

Flow推断leaves.concat(aggregators) 的类型为Array ,然后将其转换为Array。我认为这是一个很好的例子,说明有的地方Flow很聪明,而TypeScript可能需要一点帮助(在这种情况下,我们可以用类型断言来帮助TypeScript,但是类型断言的使用很危险,请小心谨慎)。

尽管没有正式的证据,但是我还是想说我认为在类型推断方面Flow比TypeScript更优越。我非常希望TypeScript能够向Flow看齐, 因为TypeScript正处于非常积极的开发中,并且最近TypeScript有了许多改进。而纵观我们的源代码,我们必须通过解释或类型断言给予TypeScript一些帮助(还是尽可能地避免使用类型断言)。让我们再来看一个例子(我们有200多个这种类型错误的实例):

type Player = {    name: string;    age: number;    position: "STRIKER" | "GOALKEEPER",};type F = () => Promise>;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类型的对象(打开练习环境可以看到确切的错误)。这是另一个我觉得TypeScript“不够聪明”的地方——至少与Flow相比不够聪明。

为了修正这个错误,开发者有几个选择:

断言"STRIKER"为"STRIKER",这样TypeScript就可以理解该字符串是个有效的枚举类型"STRIKER" | "GOALKEEPER";

断言整个对象为“Player”(as Player);

或者(我认为的最佳解决方案)无需任何类型的断言,只需写Promise.all(...)。

另一个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.  (conn.id);});

一个很小但非常有趣的例子是Flow判断Array.pop()的类型为T,而TypeScript则认为它属于T | void。这是我喜欢TypeScript的一个地方,因为它会强制你仔细检查该项是否存在(如果数组为空,则Array.pop返回undefined)。

TypeScript对于第三方依赖的定义

当然,在编写任何JavaScript应用程序时都有可能会有一些依赖。这些依赖都需要类型定义,否则开发者就失去了静态类型分析的大部分威力(如本文开头所述)。

从npm导入的库可以附带Flow类型定义或TypeScript类型定义,也可以两者兼有或两者都没有。许多小型库不带有任何方式的类型,所以必须自己编写类型定义,或从社区中找。Flow和TypeScript社区都有一个标准的JavaScript包的第三方类型定义代码仓库:flow-typed和DefinitelyTyped。

我不得不说使用DefinitelyTyped的体验更好。在使用flow-typed的时候,我们必须通过它的命令行工具将各种依赖的类型定义引入到项目中。DefinitelyTyped找到了一个很好的方法与npm的命令行集成,即它的软件包均以@types/package-name的方式命名。这一点非常了不起,有了它我们就可以很容易地为依赖引入类型定义了(jest、react、lodash、react-redux等等)。

除此之外,我花了大量时间向DefinitelyTyped贡献代码(当将代码从Flow移植到TypeScript时,不要指望类型定义是等价的)。我已经发送了几个拉取请求,所有工作都易如反掌。开发者只需要克隆、编辑类型定义、添加测试,然后发送拉取请求,DefinitelyTyped GitHub会将曾向这个类型定义贡献过代码的人标记为审核者。如果7日之内没有人审核代码,那么DefinitelyTyped的维护者会审核PR。在合并到master分支后,新版本的依赖包将会发送到npm。例如,当我第一次更新@types/redux-form包时,在合并到master分支后版本7.4.14自动被推送到了npm。我们可以非常容易地更新package.json文件,就可以获取新的类型定义。如果等不到PR被接受,那么也可以随时覆盖项目中使用的类型定义。

总的来说,DefinitelyTyped中类型定义的质量更好,这要归功于TypeScript背后的社区更大、更繁荣。事实上,在将我们的项目从Flow移植到TypeScript之后,我们的类型覆盖率从88%提高到了96%,主要是由于更好的第三方依赖类型定义,“any”类型的依赖减少了。

Linting与测试

在移植过程中,我们发现使用TypeScript的eslint比较复杂,所以我们就选择了tslint,从eslint转移到了tslint。

此外,我们还使用ts-jest来运行TypeScript的测试。有些测试是有类型的,而有些是无类型的(如果给测试用例添加类型的工作量太大,我们就将它们保存成.js文件)。

修复了所有类型错误后,情况怎样了?

经过历时一周的修复工作后,我们遇到了最后一个类型错误,我们决定利用@ts-ignore将其暂且搁置。

在解决了一些代码审查注释并修复了一些错误之后(不幸的是,我们不得不修改少量运行时来修复TypeScript无法理解的逻辑),在这个PR被合并后,我们就开始使用TypeScript了。(还有,我们在后续的PR中修复了最后一个@ts-ignore)。

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

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

我们将代码库从Flow移植到了TypeScript,这意味着我们在其中发现了Flow可以表达但TypeScript却不能表达的地方。如果这次移植是从TypeScript到Flow的,那么我们可能就会发现TypeScript的推断/表达比Flow更好。

类型推断很重要,它有助于保持我们的代码更简洁。但是强大的社区、类型定义的可用性等更为重要,因为弱类型推断只需要加强下类型检查就可以解决。

代码统计

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

$ cloc # ignoring tests and dependencies--------------------------------------------------------------------------------Language                      files          blank        comment           code--------------------------------------------------------------------------------TypeScript                      330           5179           1405          31463

下一步计划?

虽然移植完成了,但是代码中的静态类型分析还没有完成。

MemSQL还有其他项目也打算弃用Flow、转而投入TypeScript的怀抱(有些JavaScript项目可能一开始就使用TypeScript),所以我们希望使我们的TypeScript配置更加严格。

目前我们已经打开了“strictNullChecks”,但“noImplicitAny”仍处于禁用状态,这也需要后续解决。

此外,我们还打算删除代码中的一些危险的类型断言。

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分