ruanhongbiao 2019-01-30
作者 | David Gomes
译者 | 弯月
责编 | 郭芮
来源 | CSDN(ID:CSDNnews)
【编者按】在内存安全中,类型安全是很重要的一个命题。为了确保JavaScript项目运行的类型安全,本文的作者介绍了2016年时使用Flow的经历:由Facebook支持的Flow方案,不仅拥有查找类型、泛型参数默认值等基本功能,还有着较为完善的JavaScript开发生态系统。但是随着项目的不断复杂,以及TypeScript功能的逐渐优化,就对项目提出了更多的要求。本文就详解了将三万行代码从Flow移植到TypeScript的全过程。
以下为译文:
最近我们将MemSQL Studio的3万行JavaScript代码从Flow移植到了TypeScript。在本文中,我将介绍我们移植代码库的原因以及移植的全过程。
事先声明,我写这篇文章的目的不在于谴责Flow或Flow的使用。我非常欣赏Flow这个项目,我认为在JavaScript社区中Flow和TypeScript这两种类型检查器都有足够的发展空间。但是,每个团队都需要仔细研究并选择最适合自己的。因此我真诚地希望这篇文章对你的选择能有所帮助。
背景
首先介绍一下背景故事。在MemSQL,我们都喜欢静态强类型的JavaScript代码,这是为了避免动态弱类型的常见问题。例如:
这些还只是静态类型部分的优点。
2016年初,我们开始在一个内部的JavaScript项目上使用tcomb,以确保运行时的类型安全(声明:我并没有参与那个项目)。虽然有时运行时的类型检查很有用,但它与静态类型毫不沾边。考虑到这一点,2016年的时候我们决定在另一个项目中使用Flow。当时,Flow是一个很好的选择,因为:
当2017年年末开始开发MemSQL Studio时,我们准备在整个应用程序中实现完整的类型覆盖(整个应用程序都是用JavaScript编写的,前端和后端都在浏览器中运行)。因为以前成功使用的经验,所以我们决定此次也使用Flow。
然而,最新发布的Babel 7已经开始支持TypeScript了,这引起了我的注意。这个发布意味着采用TypeScript不再需要引入整个TypeScript生态系统,我们可以继续通过Babel来生成JavaScript。更重要的是,这意味着实际上我们可以将TypeScript作为类型检查器,而不是作为一种“语言”。
就个人而言,我认为将类型检查与JavaScript的生成分离是在JavaScript中实现静态(强)类型的更优雅的方式,因为:
当然,这种方法也有一些缺点:
TypeScript 能替代 Flow 方案吗?
我注意到网上和本地JavaScript社区对TypeScript的兴趣越来越浓厚。因此,当发现Babel 7支持TypeScript时,我就开始调查代替Flow的可能性。最重要的是,在使用Flow的时候我们遇到了很多挫折:
当然,我们还必须研究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个。剩下的部分只能手动修复了:
修复了所有语法错误之后,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 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的问题——它无法在函数中进行这类的推理。但是,由于这样的代码在我们代码库中很常见,所以我们就被迫手动替换每一个invariant的实例(有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> ,然后将其转换为Array<MemsqlNode>。我认为这是一个很好的例子,说明有的地方Flow很聪明,而TypeScript可能需要一点帮助(在这种情况下,我们可以用类型断言来帮助TypeScript,但是类型断言的使用很危险,请小心谨慎)。
尽管没有正式的证据,但是我还是想说我认为在类型推断方面Flow比TypeScript更优越。我非常希望TypeScript能够向Flow看齐, 因为TypeScript正处于非常积极的开发中,并且最近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类型的对象(打开练习环境可以看到确切的错误)。这是另一个我觉得TypeScript“不够聪明”的地方——至少与Flow相比不够聪明。
为了修正这个错误,开发者有几个选择:
另一个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对于第三方依赖的定义
当然,在编写任何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更好,但是两个原因可以解释为什么这不是什么大问题:
代码统计
$ npm run type-coverage # https://github.com/plantain-00/type-coverage 43330 / 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”仍处于禁用状态,这也需要后续解决。
此外,我们还打算删除代码中的一些危险的类型断言。
原文链接:https://davidgom.es/porting-30k-lines-of-code-from-flow-to-typescript/(本文为 AI大本营转载文章,转载请联系作者。)