最近,我们将 MemSQL Studio 的 3 万行 Flow 代码移植到了 TypeScript。在本文中,我将分享为什么我们要移植代码、我们是如何移植的,以及代码移植背后的原理。
免责声明:写这篇文章不是为了谴责 Flow,我本人其实非常欣赏这个项目,我认为 JavaScript 社区为两种类型检查器的存在提供了足够的空间。只是每个团队都应该研究透所有的可选项,并选择最适合他们的方案。我希望这篇文章能够帮助你做出这样的选择。
先让我们从背景开始讲。在 MemSQL(https://www.memsql.com/),我们喜欢使用静态强类型的 JavaScript,因为这样可以避免动态弱类型带来的常见问题,例如:
1. 由于代码的不同部分使用了不一致的隐式类型契约,导致发生运行时类型错误。
2. 需要花费太多时间来处理诸如参数类型检查之类的微不足道的事情(运行时类型检查增加了包的大小)。
3. 缺乏编辑器和 IDE 集成,因为在没有静态类型的情况下,很难使用“跳转到定义”、机械式重构和其他功能。
4. 围绕数据模型编写代码,也就是说,我们可以先设计数据类型,然后代码基本上可以自己“编写代码”。
这些只是静态类型的一些优点,我在最近的一篇有关 Flow 的文章(https://davidgom.es/what-i-wish-i-had-known-before-starting-to-use-flow/)中也描述了其他一些好处。
2016 年初,我们开始在一个内部 JavaScript 项目中使用 tcomb(https://github.com/gcanti/tcomb)来确保运行时类型安全(免责声明:我不是该项目的一部分)。虽然运行时类型检查有时很有用,但它无法取代静态类型。因此,我们决定在 2016 年开始的另一个项目中使用 Flow。当时,Flow 是一个很好的选择,因为:
由 Facebook 提供支持,Facebook 在发展 React 和 React 社区方面做得非常出色(他们也使用 Flow 开发 React)。
我们没有必要采用一个全新的 JavaScript 开发生态系统。为了 tsc(TypeScript 编译器)而放弃 Babel 对我们来说有点可怕,因为这样并不会给我们带来在将来切换到 Flow 或其他类型检查器(显然这件事已经发生了)的灵活性。
我们没有必要为整个代码库添加类型(我们想要在全盘添加类型之前先感受一下静态类型的 JavaScript),我们只为一部分文件添加类型。请注意,Flow 和 TypeScript 现在都允许你这么做。
TypeScript(当时)缺少 Flow 已经支持的一些基本功能,例如查找类型、泛型参数默认值等。
2017 年末,我们开始在整个应用程序中实现完整的类型覆盖(所有这些都是用 JavaScript 编写的,前端和后端都在浏览器中运行)。我们决定使用 Flow,因为我们有成功的经验。
不过,Babel 7(支持 TypeScript)的发布引起了我的注意。这个版本意味着采用 TypeScript 并不一定要引入整个 TypeScript 生态系统,我们可以继续使用 Babel。更重要的是,这意味着我们可以使用 TypeScript 作为类型检查器,而不是一门“语言”。
在我看来,将类型检查器分离是在 JavaScript 中实现静态(和强)类型的一种更优雅的方式,因为:
1. 对 ES5 代码和类型检查进行关注点分离是一个好主意。这样可以减少类型检查器锁定,并加快开发速度(如果类型检查器因为某些原因更新较慢,并不会影响到代码发布)。
2.Babel 提供了 TypeScript 所没有的插件和功能。例如,Babel 允许你指定支持哪些浏览器,它将自动生成可以在这些浏览器上运行的代码。这实现起来非常复杂,有 Babel 的实现就够了,没有必要让社区在两个项目中实现同样的东西。
3. 我喜欢将 JavaScript 作为一门编程语言(除了缺少静态类型),而且我不知道 TypeScript 还会存在多长时间,但我相信 ECMAScript 会一直存在。因此,我更喜欢使用 JavaScript 编写代码。(请注意,我总是说“使用 Flow”或“使用 TypeScript”,而不是“使用 Flow 编写”或“使用 TypeScript 编写”,是因为我总是将它们视为工具而不是编程语言)。
当然,这种方法有一些缺点:
1. 理论上,TypeScript 编译器可以根据类型执行包优化,但如果进行关注点分离,就会缺失这个特性。
2. 当你使用更多的工具和开发依赖项时,项目配置会变得有点复杂。但我认为这并不是个大问题,因为在我们的项目中,Babel + Flow 的配置从来都不是什么问题。
我注意到,在线和本地的 JavaScript 社区对 TypeScript 越来越感兴趣。因此,当我第一次发现 Babel 7 开始支持 TypeScript 时,我开始想办法抛弃 Flow。最重要的是,在使用 Flow 时,我们遇到了各种挫败:
1. 编辑器或 IDE 集成度低(与 TypeScript 相比)。
2. 社区力量较弱,因此库的数量较少,而且库的类型定义质量不高(稍后将详细介绍)。
3.Facebook Flow 团队与社区之间缺乏互动,而且没有公共路线图。
4. 高内存消耗和频繁的内存泄漏——我们团队中的工程师见过 Flow 偶尔会占用近 10 GB 的内存。
当然,我们还必须研究 TypeScript 是否适合我们。这件事非常复杂,我们全面阅读了它的文档,终于搞清楚 Flow 中的每个功能在 TypeScript 中都有等效的功能。然后,我研究了 TypeScript 公共路线图,并对它提供的功能感到非常满意(例如,部分类型参数推断是我们在 Flow 中使用的一个特性)。
将所有代码从 Flow 移植到 TypeScript 的第一步是将 Babel 从 6 升级到 7。这个其实很简单,但也花了我们大约 2 个人天,因为我们同时也将 Webpack 从 3 升级到了 4。因为我们的源代码中存在一些遗留的依赖项,所以给升级过程带来了一点难度。
完成升级后,我们使用新的 TypeScript 预设替换 Babel 的 Flow 预设,然后运行 TypeScript 编译器,结果出现了 8245 个语法错误(tsc 编译器只会在没有语法错误的情况下告诉我们真正的代码错误是什么)。
这个数字起初吓了我们一跳,但我们很快发现,其中大部分都与 TypeScript 不支持.js 文件有关。经过一番调查,我发现 TypeScript 文件必须以“.ts”或“.tsx”结尾(如果文件中包含了 JSX)。我在创建新文件时才不管是用哪个扩展名,所以我认为这是一个糟糕的开发者体验。为此,我不得不将每个文件的扩展名改为“.tsx”。
在更完扩展名之后,还有约 4000 个语法错误,大多数与导入类型有关,可以替换为 TypeScript 的“import”,也可以替换为 Flow 的对象表示法。在经过几次快速的正则表达式替换之后,语法错误降到了 414 个。剩下的都必须手动修复。
我们用于部分泛型类型参数推断的存在类型(existential type)必须被替换为显式命名的各种类型参数,或使用未知类型告诉 TypeScript,我们不关心某些类型参数。$Keys 类型和其他 Flow 高级类型在 TypeScript 中具有不同的语法(例如,$Shape<>对应于 TypeScript 中的 Partial<>)。
在修复了所有语法错误之后,tsc(TypeScript 编译器)终于可以告诉我们代码库中有多少类型错误——大概有 1300 个。这个时候我们需要决定是继续下去,还是就此止步。毕竟,如果修复这些错误需要花费数周的时间,那么继续移植这些代码可能是不值得的。不过,我们认为实际的时间应该不会超过一个人周,于是我们决定继续。
请注意,在移植代码期间,我们不得不停止代码库上的其他工作。尽管我们可以继续添加新的代码,但前提是需要处理潜在的数百种类型错误,这不是一件容易的事。
TypeScript 和 Flow 对很多不同的东西做出了不同的假设,就是让 JavaScript 代码做不同的事情。Flow 在某些方面更严格一些,而 TypeScript 则在其他方面更严格一些。要深入比较两种类型检查器之间的区别需要写很长的文章,在这篇文章中,我们只给出一些例子。
注意:本文假设所有代码都启用了“strict”模式。
在我们的源代码中,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 运行完全相同的代码片段。我们会得到一个错误,因为它无法根据上一行代码知道“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 好,但也不是什么大问题。
Flow 有一个非常有趣的功能,类似于 @ts-ignore,只是如果它的下一行代码不包含错误,它就会报错。这在编写“类型测试”时非常有用,这些测试确保类型检查器能够找到我们希望它们找到的某些类型错误。
不幸的是,TypeScript 没有这个功能,这意味着我们的类型测试失去了一些价值。我很期待 TypeScript 能够实现这个功能(https://github.com/Microsoft/TypeScript/issues/29394)。
通常,Flow 比 TypeScript 更加“聪明”一些。
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
即使我这么说可能缺乏正式的证据支持,但我认为 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 类型的对象。这是另一个我认为 TypeScript 不够“聪明”的例子。
为了让这个奏效,你有几个选择:
将“STRIKER”断言为“STRIKER”,这样 TypeScript 就知道这个字符串是"STRIKER" | "GOALKEEPER"类型的有效枚举。
将整个对象断言为 Player。
或者使用 Promise.all
另一个例子如下,再次说明 Flow 的类型推断比 TypeScript 更好:
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
在开发 JavaScript 应用程序时,你肯定会有一些第三方依赖项。它们也需要类型,否则就会失去静态类型的大部分功能(如本文开头所述)。
从 npm 导入的库会附带 Flow 类型定义或 TypeScript 类型定义,可能两者都会有,也可能都没有。(较小的)库不提供类型定义的情况也很常见,你必须为它们编写类型定义或者从社区中获取。Flow 和 TypeScript 社区都提供了一个 JavaScript 第三方类型定义存储库:flow-typed(https://github.com/flow-typed/flow-typed)和 DefinitelyTyped(https://github.com/DefinitelyTyped/DefinitelyTyped)。
我不得不说 DefinitelyTyped 让我们度过了更美好的时光。在使用 flow-typed 时,我们必须使用 CLI 工具将各种依赖项的类型定义引入到项目中。而 DefinitelyTyped 直接将这个功能合并到 npm 的 CLI 工具中,这样就可以更容易为依赖项引入类型定义(jest、react、lodash、react-redux,等等)。
除此之外,我也花了很多时间为 DefinitelyTyped 做贡献。我已经发出了几个拉取请求,只需要克隆代码,编辑类型定义,添加测试,最后发送拉取请求。DefinitelyTyped 的 GitHub 机器人将自动标记被指定评审变更的人,如果他们在 7 天内没有进行评审,那么 DefinitelyTyped 的维护者将会评审拉取请求。合并到 master 后,依赖项的新版本将被发送到 npm。
总体而言,DefinitelyTyped 的类型定义要好一些,因为 TypeScript 背后的社区更强大、更繁荣。事实上,在将我们的项目从 Flow 移植到 TypeScript 之后,我们的类型覆盖率从 88%增加到 96%,这主要是因为有了更好的第三方依赖类型定义。
1. 我们从 eslint 转向了 tslint(我们发现在 TypeScript 中使用 eslint 会比较复杂,所以选择了 tslint)。
2. 我们使用 ts-jest 来运行 TypeScript 相关的测试。我们的一些测试是有类型的,有些是无类型的。
经过一个人周的工作后,只剩下最后一个类型错误,我们暂时使用 @ts-ignore 忽略了它。
在处理了一些代码评审注释并修复了一些错误之后(我们不得不修改少量运行时代码来修复 TypeScript 无法理解的逻辑),我们开始创建拉取请求,并从那时起,我们就一直在使用 TypeScript(我们在后续拉取请求中修复了最后一个 @ts-ignore)。
除了编辑器集成之外,使用 TypeScript 与使用 Flow 非常相似。Flow 服务器的性能稍微快一点,但这并不是一个大问题,因为它们为你正在查看的文件提供错误信息的速度是一样的。唯一的性能差异是 TypeScript 需要更长的时间(约 0.5 到 1 秒)来告诉你项目中是否有新的错误(在你保存文件后)。服务器启动时间大致相同(约 2 分钟),但这并不重要。到目前为止,我们没有遇到内存占用太高的问题,tsc 似乎一直使用大约 600 M 的内存。
Flow 的类型推断似乎比 TypeScript 好得多,但这不是个大问题,因为:
1. 我们将整个使用 Flow 的代码库迁移成使用 TypeScript。在这个过程中,我们肯定会发现一些 Flow 可以表达但 TypeScript 却做不到的东西。如果是反过来,我相信也会发现一些东西是 TypeScript 能够推断或表达但 Flow 却做不到的。
2. 类型推断很重要,它有助于保持代码的简洁。但强大的社区和类型定义的可用性之类的东西更重要,因为弱类型推断可以通过更多的“手持”类型检查器来实现。
我们的静态类型分析改进还没有结束。MemSQL 还有其他项目,最终也会从 Flow 转向 TypeScript。我们希望使我们的 TypeScript 配置更加严格。我们目前已启用“strictNullChecks”,但仍然禁用“noImplicitAny”。我们还打算移除一些危险的类型断言。
英文原文:
https://davidgom.es/porting-30k-lines-of-code-from-flow-to-typescript/
2019 年 5 月 6-8 日,QCon 与您相约北京国际会议中心,深度解析业界前沿领域及技术趋势。点击 「 阅读原文 」或识别二维码了解 QCon 十周年精心策划,现在购票即享 8 折限时折扣,立减 1760 元,团购还有更多优惠!有任何问题欢迎联系票务小姐姐 Ring:电话 010-53935761,微信 qcon-0410