将数十万行 CoffeeScript 代码迁移到 TypeScript

发布于:2020 年 6 月 27 日 10:16

将数十万行CoffeeScript代码迁移到TypeScript

本文最初发布于 Dropbox 技术博客,经 Dropbox 授权由 InfoQ 中文站翻译并分享。译文经过了 Dropbox 团队的审核和修订。

序言

2017 年 5 月,我首度加入 Dropbox 的时候,从 CoffeeScript 向 TypeScript 迁移的工作已经接近尾声。彼时,需要对已有的 CoffeeScript 文件更改时,一般都会先将它转换为 TypeScript。我们的部分代码库仍在使用 react-dom-factories,并且在 Redux 之前有一个自定义的 flux 实现。

那时我们的 Web 平台团队正全速向 TypeScript 迁移,但这一工作的规模或复杂性尚不为外人所知。如今 TypeScript 已成为 JavaScript 事实上的超集,我们的这段往事也是时候公之于众了。故事主要发生在 2017 年,在今天依旧颇具参考价值。

我联络到该项目的首席工程师之一 David Goldstein 来撰写本文。此外,还找到了另一位见证者,Web 平台工程师 Samer Masterson 来补充细节。

将数十万行 CoffeeScript 代码迁移到 TypeScript 是一项庞大的工程,本文将涉及其中的方方面面。我们将介绍一开始为什么选择了 TypeScript,如何规划迁移工作,还有那些计划外的各种细节。

迁移在 2017 年秋季结束。在此过程中我们开发了一些优秀的工具,并成为了首批大规模采用 TypeScript 的公司之一。——Matthew Gerstman

历史:采用 CoffeeScript

早在 2012 年,我们还是一家只有约 150 名员工的新兴公司。当时浏览器中的最新技术是 jQuery 和 ES5。HTML5 还有两年才会正式登台,而 ES6 还要等三年。由于 JavaScript 技术似乎停滞不前,我们想要找到一种更先进的 Web 开发方法。

当时,CoffeeScript 非常流行。它支持箭头函数,智能 this 绑定,甚至可选链,都比标准 JavaScript 领先数年。最后,我们的两名工程师在 2012 年的“黑客周”中将整个 dropbox.com Web 应用程序从 JavaScript 迁移到了 CoffeeScript 上。彼时 dropbox 规模不大,所以迁移很容易。我们从 CoffeeScript 社区获得了指导,并采纳了他们的样式建议,最终将 coffeelint 集成到了工作流程中。

在 CoffeeScript 中,花括号、圆括号,有时甚至逗号都是非必须的,是可有可无的选项。

例如,foo 12 与 foo(12) 是等同的。

多行数组可以不用逗号:

复制代码
// CoffeeScript
[
"foo"
"bar"
]
// JavaScript
["foo", "bar"]

这种语法方法在那时很流行,我们甚至采纳了社区的“可选的符号就不用写”建议。

当时,代码库包含约 100,000 行 JavaScript。所有文件按预先指定的顺序串联在一起打包发布。尽管公司的许多工程师都可以看到这些代码,但其中全职的 Web 工程师却不到 10 位。

自然,这种方法无法很好地扩展;在 2013 年我们采用了 RequireJS 模块系统,并开始编写新代码以符合“异步模块定义”(简称 AMD)规范。我们确实考虑过 CommonJS,但那时 npm 和 Node 生态系统尚未成熟,因此我们选择了专为在浏览器中使用而设计的工具。如果是几年后再做同样的决策,我们可能会改用 CommonJS。

语言迁移的号角声

一开始还好,但到了 2015 年底,产品工程师开始对 CoffeeScript 愈加不满。ES6 于当年早些时候发布,覆盖了 CoffeeScript 的那些最佳特性,与 CoffeeScript 相比,它具备更多优势。它支持对象和数组解构、类语法和箭头函数。结果一些团队抢先一步,开始在自己的独立项目中使用 ES6。

与此同时,CoffeeScript 代码库维护难度在加大。由于 CoffeeScript(和标准 JavaScript)都没有类型,因此很容易在无意间破坏某些内容。防御式编程随处可见,但却使代码难以理解。我们为 null 和 undefined 添加了额外的保护措施,还针对某种极端场景采用了特殊对策,无需 new 操作便可以安全构造一个函数。

复制代码
class URI
constructor: (x) ->
# enable URI as a global function that returns a new URI instance
unless @ instanceof URI
return new URI(x)
...

此外,CoffeeScript 是一种基于空格的语言,即 tab 和空格具备不同的含义的,这与用 Python 构建 Dropbox 类似。然而,CoffeeScript 对标点却过于宽容。通常,“可选的标点”实际上意味着“CoffeeScript 会将其编译为意想不到的含义。”

举个例子:在 2013 年的秋天,曾经遇到过一个关于标点符号的 bug,Python 无法编译通过,CoffeeScript 将它进行了错误的编译。虽然 Coffee Script 与 Python 的相似性可能有助于 Dropbox 的应用,但这些差异往往会出问题。一些更有经验的开发人员选择通过将 JavaScript 与 CoffeeScript 代码并排打开来工作。

2015 年 11 月,对 Dropbox 的前端工程师进行了一项调查,发现只有 15%的受访者认为应该继续使用 CoffeeScript,而 62%的受访者则认为应该放弃它:

将数十万行CoffeeScript代码迁移到TypeScript

开发人员经常抱怨:

  • 缺少分隔符
  • 过于固执己见的句法糖
  • 缺乏社区对语言的支持
  • 由于语法密集而难以理解
  • 由于句法歧义而容易出错

基于开发人员的这些反馈,于是我们将目光转向业界,决定试用 TypeScript 和标准 ES6。我们将它们都集成到了 dropbox.com 技术栈中以选出更适合的选项。我们也考虑过 Flow,但它不如 TypeScript 流行,相关支持也较少。最后我们决定,如果要用类型语言就用 TypeScript,这在 2015 年是不寻常的决策。

2016 年上半年,有一位工程师将 Babel 和 Type 脚本集成到我们的构建脚本中。我们现在可以在主网站试用两种语言。经过生产测试,我们认为 TypeScript 实际上是带有类型的 ES6。由于团队偏爱类型,最终选择了 TypeScript。

但是有一个小问题:那时我们的代码库已增长到 329,000 行 CoffeeScript;我们的工程团队也大幅扩张,不再由单个团队负责整个网站。所以我们的迁移速度不会像上次那么快了。

乐观的迁移计划

最初的计划有 5 大里程碑:

M1:基本支持

  • 添加 TypeScript 编译器。
  • 使 TypeScript 和 CoffeeScript 代码可以互操作。
  • TypeScript 的基本测试、国际化和 linting。

M2:选定 TypeScript 为新代码的默认语言

  • 优化开发人员体验。
  • 迁移核心库。
  • 为最佳实践编写文档。
  • 为代码迁移编写文档。

M3:TypeScript 成为代码库的主成员

  • 在 M2 基础上更进一步,通过更多的教育过程,完整 linting 和测试支持,将其余重要的库进行转换。

M4:预期在 2017 年 4 月,将编辑最多的一组文件迁移到 TypeScript

  • 手动将约 100 个经常编辑的文件从 Coffeescript 转换为 TypeScript。原始的 CoffeeScript 将在 git 历史中可用。

M5:预期在 2017 年 7 月,删除 CoffeeScript 编译器

  • 将所有剩余 CoffeeScript 代码转换成 JavaScript。源 CoffeeScript 将在 git 历史中可用。
  • 更改这些 JavaScript 代码前需将整个文件迁移到 TypeScript。

2016 年下半年,M1、M2 和 M3 顺利完成。我们成功构建了稳健的 Coffee/TypeScript 互操作程序。测试很简单:重用现有的基于 Jasmine 的基础架构来测试两种语言(之后迁移到了 Jest,但这是另一个故事了)。我们整合了 TSLint 并编写了样式指南。

M4 和 M5 遇到了不少障碍,因为产品团队需要将已有代码移植到 TypeScript 上。我们希望各个团队负责迁移各自开发的代码,并决定给产品团队留出一年中 20%的时间用于“基础工作”,后文会详细说明。

CoffeeScript/TypeScript 的互操作性

我们实现了 CoffeeScript 和 TypeScript 的互操作,如下所示:对于每个 CoffeeScript 文件,在类型文件夹中创建了一个相应的.d.ts 声明文件。这些都是自动创建的,如:

复制代码
declare module "foo/bar" {
const exports: any;
export = exports;
}

也就是说所有内容都变成了 any 类型。重要模块可以转换为 TypeScript,或者逐步改变类型。对于流行的外部库(如 jQuery 或 React),可以从 DefinitelyTyped 找出可用的类型。对于不太常见的库,采用与默认存根相同的方法。

将所有 TypeScript 和 CoffeeScript 文件放在同一文件夹中,所以两种语言的文件模块 ID 都一样。在学习 AMD import/export 与 TypeScript 的语法如何对应时我们遇到了些麻烦,还好问题不大。我们没有使用 --esModuleInterop。

等效的 import 语句如下:

TypeScript(推荐)

复制代码
import * as foo from "foo";

TypeScript(不推荐)

复制代码
import foo = require("foo");

与 AMD JavaScript(或等效的 CoffeeScript)相同

复制代码
define(["foo", ...], function(foo, ...) { ... }

将导出命名为 export const foo 类;可以导入模块然后解构{foo},实现在 CoffeeScript 中读取。这样就和标准的 ES6 命名 import 建立了良好的语法关系。TypeScript 的 export default 导入到 AMD 模块后,等效于对象{default: …},真是令人惊讶。

大多数模块都可以用这些等效方法,但有些模块会动态确定它们将导出的内容。我们从每个文件导出了所有可能的导出,如果没有返回的话就改为 undefined。

之前

复制代码
define([...], function(...) {
...
if (foo) {
return {bar};
} else {
return {baz};
}
})

之后

复制代码
let foo, bar;
if (foo) {
bar = // define bar;
} else {
baz = // define baz;
}
// Export both regardless.
export {bar, baz}

禁用 CoffeeScript 新文件

M2 阶段代码库不再接收新的 CoffeeScript 文件。已有 CoffeeScript 的编辑不受影响,但多数工程师也因此开始学习 TypeScript 了。

一开始我们编写了一个遍历代码库的测试,找到所有.coffee 文件并将其路径加入白名单。对此测试文件的任何更改都需要经过一位 Web 平台工程师的审核。

同时我们采用了 Bazel 作为构建系统。在迁移到 Bazel 期间这一测试暂时失效了,为已有的 CoffeeScript 文件返回了一个空列表,还断言该空列表是已有 CoffeeScript 文件白名单的子集。还好我们很快修复了这个问题,没有造成严重影响。

我们在这里学到了一个教训:如果测试中带有任何假设,请试着确保它们能够测试这些假设并在中断时报错。原始测试应该断言 CoffeeScript 文件列表为非空,这样一旦出错时,就能立刻发现问题。

修复这个问题时,我们对白名单加入了严格的检查,这样文件删除时也必须从白名单中移除,且不能重新引入(除非明确地重新添加文件)。这种方法之后用在了所有白名单相关工作上,既能让不符合测试假设的问题快速暴露,又能避免人们无意间回退迁移工作。这里有一个小的缺陷:缩小白名单会阻断代码审核,但问题不大,我们会尽快(在一个工作日内)接受这些审核。

早期经验:没有遗漏 CoffeeScript 的语法糖

最初选择要迁移的语言时,我们担心的一个问题是:ES6 和 TypeScript 并没有包括 CoffeeScript 的所有特性,比如说没有? 和?. 运算符。

起初,我们以为会遗漏这些:但当采用了 TypeScript2.0 的 --strictNullChecks 后,这就不是问题了。可选链运算符主要用来处理 undefined 或 null 之类的不确定性,而 TypeScript 帮助我们消除了这种不确定性。

有趣的是,optional chaining 和 nulllish coallescing 最近都被重新添加到 vanilla Java 脚本中,并以类型脚本语言显示,尽管有一些小的语法变化与原始 CoffeeScript 变量之间略有差异。

优先级竞争

2016 年下半年,公司成立了一个并行团队,用 React 重新设计和重构我们的网站。他们的目标是:到 2017 年第一季度末(时间接近最初的 M4 里程碑)发布新网站。该项目称为“Maestro”,优先级比将他们负责的部分迁移到 TypeScript 的工作更高。此外其他一些团队也会参与其中。

经过讨价还价,Maestro 团队最终承诺在第二季度完成迁移工作。前面他们就用 React 和 TypeScript 重写了很多功能,剩下的文件则在第二季度迁移完毕。

迁移过程中用到“highly edited ”这个工具,强烈鼓励社区转换它们。可惜 100 个文件好像太多了,这个里程碑没有按时交付。

这样来看,删除 CoffeeScript 编译器的计划也得推迟了。除了这 100 个热门文件,后面还有 2000 多个虽然没那么常用,但也时不时用得上的 CoffeeScript 老文件呢。

推迟 M5

M5 里程碑在组织中引起了很多混乱,通常把它总结为“去除 CoffeeScript 编译器”。

公司内却出现了另一种解释。许多人认为,虽然无法在截止日期之后编写 CoffeeScript,但产品团队可以编辑本应该只读的代码,甚至可以编辑 CoffeeScript,然后检查新的编译后的代码。

可如果只 check in 已编译的代码,那么大部分代码就不会有 i18n 与 linting 支持了;不想追加投资的话,应假设代码没变才能找回这些支持。

此外,从平台的角度来看,这个里程碑意义不大。去除编译器主要是为了有一个单语言的代码库,并让注意力集中在 TypeScript 工具链上。

不知道“只读 JavaScript”是否比保留为 CoffeeScript 文件更好,用 Bazel 重新实现构建系统的工作即将完成,并已对 CoffeeScript 和 TypeScript 编译器都提供了支持。

因此在 6 月,TypeScript 的迁移工作被无限期推迟,完成时间没有 ETA。

事后看来这一决定似乎是不可避免的。假设每个工程日(包括测试和代码审查)大约要转换 1000 行代码,那么一位工程师要花一年的时间才能完成迁移。这个速度实际上是非常乐观的,因为实际报告的进度每天大约是 100 行,指望一两个月就完成根本做不到。

至于之前承诺的“20%的时间用于基础工作”,我们也没有达成共识。有的人知道这是用来满足基础架构需求的时间,有的人则认为这些时间可以用来偿还自己的技术债。而且 20% 这个限制也形同虚设,没人真的遵守它。

2017 年后,我们再做迁移时就不再开这种空头支票了。

使用 decaffeinate 的新方案

对 decaffeinate 的早期测试

早在 2017 年 1 月,一些工程师就曾使用 decaffeinate 来简化代码转换工作,甚至开始围绕它构建一些工具来处理 AMD,并通过一些开源代码来清理 React 样式。

不幸的是,我们首次尝试 decaffeinate 时出现了严重的故障。我们转换了 i18n 库,然后审查,测试并交付生产,结果发现 decaffeinate 误转换了未测试的,可识别语言环境的排序函数。只有一个页面用了这个函数,但在 Safari 中这个页面完全错乱了。之后我们查看了 decaffeinate 的错误积压,结果发现了几十个类似问题。我们也不知道需要花多久才能真正信任 decaffeinate,所以当时没打算用这种方法。

不过一些工程师还是决定使用它来手动转换代码,我们在文档中将其记为一种可行的工作流程。基于 decaffeinate 的脚本通常会生成明显无效的代码,这没什么大不了的,因为 TypeScript 在编译时会报告它们。真正的问题是潜在的 bug,它们改变了代码的语义,编译器却发现不了。

六个月后

2017 年夏天,decaffeinate 声明自己做到了无 bug。于是我们开始重新考虑这一选项,经过研究发现:

  • decaffeinate 的声明应该是可信的
  • 更令人信服的是,我们的内部开发人员报告说,使用基于 decaffeinate 的脚本比手动转换的结果更加可靠。

于是我们制定了新计划:将剩余的迁移工作自动化。

现在对于 decaffeinate 无法提供类型的情况,可以添加为 any,直到 TypeScript 满意为止。这种方法有以下优点:

  • 工程师(尤其是新员工)不必再学习阅读(或编辑)CoffeeScript
  • Web 平台无需再支持 CoffeeScript linting、国际化和编译器
  • codemod 或静态分析之类工具的改进只需应对一种语言

迁移结束后,团队可以按自己的进度修复代码中的类型;无需再维护指向未转换的 CoffeeScript 的声明文件。

此时,产品团队的空闲时间不多了,迁移得不到代码所属团队的大量支持。而且要完成目标就要尽量减少引入的错误,有超过 2000 个文件要迁移,但错误超过一打就可能让项目延迟或取消。这意味着我们必须在保持保持现有代码语义的同时进行转换。

两阶段计划

需要针对所有文件创建一个多步流水线方法来完成迁移。

首先,运行 decaffeinate 以生成有效的 ES6。该代码没有类型,甚至包括了 pre-JSX React。然后我们用一个自制的 ES6 到 TypeScript 转换器处理这段 ES6 代码。

全面 decaffeinate

decaffeinate 有一些选项可以生成更漂亮的代码,代价是降低代码的正确率。这些选项以 --loose 开头。最初包括以下选项:

  • –loose-for-expressions
  • –loose-for-includes
  • –loose-includes

这样就无需用 Array.from() 包装代码的大部分内容。但尝试并测试后,我们发现了很多足以让我们对这些选项失去信心的错误——它们很可能引入了回归。

而下面这些选项引发错误为数不多,因此最终使用了它们:

  • –prefer-const
  • –loose-default-params
  • –disable-babel 构造方法

decaffeinate 会留下有关潜在样式问题的注释,例如,

复制代码
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/

此后,我们使用了几个 codemod 来清理生成的代码。首先,使用 JavaScript-codemod 转换函数,例如 function() {}.bind(this) 转换为箭头函数:() => {}。接下来,对于导入了 React 的文件,使用 react-codemod 更新了旧的 React.createElement 调用,并将 React.createClass 的实例转换为 class MyComponent extends React.Component。

这一过程生成了可运行的 Javascript,但仍使用 AMD 模块格式。就算修复了这个问题,它也没有使用我们的设置进行类型检查。我们希望最终的 TypeScript 代码使用与其余代码相同的标志,尤其是 noImplicitAny 和 strictNullChecks。

我们必须编写自己的自定义转换才能进行类型检查。

构建一个 ES6 到 TypeScript 转换器

自制转换器有很多工作要做:通过迭代便能解决影响文件的所有问题,为此需要编写一种工具来自动处理以下问题。

为了开发这些工具,我们主要使用 https://astexplorer.net/ 来探索在构建原型转换时将要使用的抽象语法树。

将 AMD 转换为 ES6 模块格式

首先,需要将 AMD import 更新为 ES6 import。

下面的代码:

复制代码
define(['library1', 'library2'], function(lib1, lib2) {})

会变成:

复制代码
import * as lib1 from 'library1';
import * as lib2 from 'library2';

在 CoffeeScript 中,销毁 import 是一种常见的模式,与 named import 关系很近。因此我们将:

复制代码
define(['m1', 'm2'], function(M1, {somethingFromM2}) {
var tmp = M1(somethingFromM2);
});

转换为:

复制代码
import * as M1 from 'm1';
import {somethingFromM2} from 'm2';
var tmp = M1(somethingFromM2);

对导出进行转换。如下代码:

复制代码
define(function() {
return {hello: 1}
}

变为:

复制代码
export {1 as hello}

当无法转换为 named export 时,便回退到使用 export = 。例如:

复制代码
define([], function() {
let Something;
return Something = (function() {
Something = class Something {
}
return Something;
})();
});

变为:

复制代码
let Something;
Something = (function() {
Something = class Something {
}
return Something;
})();
export = Something;

对于未用到的导入,之后会再做清理,以避免某些模块会产生全局副作用。因此我们改为将其转换为 import “x”; 样式,并注释说这可能是没必要的。

类型签名

接下来,我们必须将每个函数参数和 var 声明注解为 any 类型。例如,function(hello) {} 变为 function(hello: any) {} 。

我们还需要为在类内部分配给 this 的每个属性添加一个类属性。例如:

复制代码
class Hello {
constructor() {
this.hi = 1;
}
someFunc() {
this.sup = 1;
}
}

会转换为:

复制代码
class Hello {
hi: any;
sup: any;
...

为 React 添加类型

另外,需要使用带有类型的 React.Component 对 React 类组件进行注解。这些更改消除了许多 TypeScript 错误。

为转换编写文档

因为不想丢失任何给定文件的版本控制历史,所以我们自动在每个文件的顶部添加了一条消息,说明如何查找原始 coffeescript 版本。

复制代码
//
// NOTE This file was converted from a Coffeescript file.
// The original content is available through git with the command:
// git show 21e537318b56:metaserver/static/js/legacy_js/widgets/bubble.coffee
//

修复类型错误

我们不想添加不必要的 any;但就算经过上述管道处理,仍然会遇到数千种类型错误。因此,转换管道中的最后一步是一个脚本,其运行类型检查,解析类型检查输出,然后根据每个错误代码尝试在关联的 AST 节点上插入适当的 any 用法。

一开始,我们在脚本里使用了 node-falafel,但发现用它时需要解析 TypeScript,所以我们 fork 了 falafel,进而使用 tslint-eslint-parser 来替代它;这样我们只需重写需要更改的代码即可。

保持专注

我们的目标不是要做出最优秀的转换工具,而是要转换代码库。首先,从小的内部功能入手来测试工具,用它们来捕获转换工具中的崩溃以及读取输出时发现的明显错误,当不再出现转换崩溃之后,便开始在随机的代码库子集中查看数据类型错误。这暴露出一些非常常见的问题,例如无效变量和复杂表达式中的类型错误,这些问题都不难解决:可以直接删除无效变量,尽管在默认状态下,保留它们的初始化器,以防表达式会产生其它副作用 - 将类似这样的复杂表达式封装成:(this as any).foo 。但是:这种方法变得越来越低效,所以后来我们开始改变策略。

当将整个代码库可靠地转换为 TypeScript 后,便开始在整个代码库上试运行,并对结果进行类型检查。我们将类型错误按代码分组 (例如。“TS7030”),并统计了发生的情况。这样就可以专心针对最常见的错误开发修复程序,避免浪费时间和精力了。

这是一个重大转折点。在此之前,我们一直在不停地编写修补程序,以修复我们决定手动测试的各个文件中不时出现的各种错误。即便这样,我们还是不能确定能否得到一个成熟的工具。通过对每个错误代码的出现情况进行分组和计数,我们能够了解到还有多少工作要做,并且能够集中精力处理发生了十几次以上的类型错误。

对于那些发生频次比较少或至少频次少到不足以需要费力去通过工具修复的类型错误,我们计划稍后再手动进行修复。有一个令人难忘的例子是,在我们更改策略之前我们发现的一个问题:ES6 类构造器在调用 super() 之前无法执行任何操作。在 CoffeeScript 类构造器中随时调用 super() 都是合法的,因此当将它们转换为 ES6 类时 TypeScript 会报错。下面这种 CoffeeScript 代码最容易出这种问题:

复制代码
class Foo extends Bar
constructor: (@bar, @baz) ->
super()

decaffeinate 后变成:

复制代码
class Foo extends Bar {
constructor(bar, baz) {
this.bar = bar;
this.baz = baz;
super(); // illegal: must come first
}
}

在几乎每个这样的实例中,在作业之前调用 super() 都是有效的,但是需要几分钟读取超类构造器以对此进行检查。我们发现的 super() 函数的误调用只有一两次真正存在问题, 这种情况对于自动更新代码库过程中发生的错误来说,错误次数不算太多(大约有 20 多次),所以手工对它们修复的难度不是太大。将容易修复的代码单列出来,安全地进行重新排序,对于那些较为复杂的情况,需要人工反复检查,不值得花时间重写。

转换完成时,我们的类型错误率约为:每个转换的文件有 0.5–1 个类型检查错误,需要手动修复。

因工具提升了信心

在编写工具的后期阶段,我们更关注如何安全地部署转换后的代码。只对转换后的代码进行类型检查是不够的,特别是考虑到我们要自动添加很多 any。

因此,在代码通过管道之前和之后,都会对代码运行的所有单元测试。这样就可以找出更多的错误,主要是隐藏的 CoffeeScript 错误代码,转换为 ts 后就会报错。每当发现一个错误,都会在整个代码库中搜索类似的模式来修复它。这种办法不行的时候,我们会在转换工具中添加一个断言,让它们在遇到可疑代码时迅速失效。

谈谈一个有趣的错误

这个错误是意外覆盖了导出的函数。

CoffeeScript 与大多数语言的不同之处在于:它没有变量阴影的概念。例如在 Javascript 中,如果你运行:

复制代码
let myVar = "top-level";
function testMyVar() {
let myVar = "shadowed";
console.log(myVar);
}
testMyVar();
console.log(myVar);

它会打印出来:

复制代码
shadowed
top-level

尽管它们共享相同的名称,但在 testMyVar 中创建的 myVar 与顶级 myVar 是不同的。这在 CoffeeScript 中是不可能做到的。等效代码如下所示:

复制代码
myVar = "top-level"
testMyVar ->
myVar = "shadowed"
console.log(myVar)
testMyVar()
console.log(myVar)

打印出来:

复制代码
shadowed
shadowed

在代码中找到一个实例,如下所示:

复制代码
define(() ->
sortedEntries = (...) ->
...
sortedEntries = entries.sortBy(getSortKey, cmpSortKey)
...
return {
sortedEntries
}

sortedEntries 被声明为一个函数,但其自身的函数主体被一个实体数组覆盖。第一次调用该模块后,对模块内部 sortedEntries 的任何调用都将失败;但由于 sortedEntries 函数导出的是副本,因此我们从未发现此问题。该代码翻译为:

复制代码
let sortedEntries = function() {
...
sortedEntries = entries.sortBy(getSortKey, cmpSortKey)
}
export { sortedEntries };

由于 TypeScript 代码使用的是 ES6 模块而不是 AMD 模块,因此 sortedEntries 将作为引用而不是副本导出。这意味着当另一个模块导入 sortedEntries 并调用它时,sortedEntries 成为了一个数组,随后对其进行的任何调用均将无效。

遇到过一次这个错误后,我们在翻译代码中添加了一个 assert ,如果发现导出的函数被重新分配时就能解决问题。

降低从稀松模式转换为严格模式的风险

在构建这些工具的过程中,我们意识到从 AMD 转换为 ES6 模块的副作用是:将有史以来第一次为绝大多数代码启用严格模式。

乍听起来,这似乎很可怕;为此我们通读了 严格模式的 MDN 文档,并制作了可预期行为的更改清单,然后逐一浏览清单,并找出减轻它们影响的方法。

对于大多数更改,我们发现 TypeScript 解析器或类型检查器就能处理了 -——TypeScript 会正常抱怨新的语法错误。有些更改则可以通过我们的代码搜索工具轻松验证。还有些更改则不是问题,因为 CoffeeScript 实际上在其代码生成中并未使用有问题的结构。

关于 eval、.caller 和.callee 的更改:我们在代码库中很少使用 eval,在 CoffeeScript 中都没有使用。并且我们没有使用.caller 和.callee,因此不必担心它们。

剩下的最后一类:只能通过运行代码来验证的更改。其中,与 eval 有关的更改是无关紧要的,而 arguments 很少用,很容易处理。这下需要担心的行为更改只剩下 3 种:

1、给不可写属性、getter-only 属性以及非扩展对象的属性的分配时会报错。向由 Object.freeze 冻结的对象写入属性是我们最有可能遇到的形式。

2、删除不可删除的属性现在会报错。

3、对 this 行为的更改——不再有 boxing,也不再有隐式 this=window 行为。

我们实际上无法提前知道这三个更改是否会带来问题,但现在这份简短的清单使我们更容易管理风险了。

还值得一提的是,代码库中最古老的部分是在引入 AMD 和 RequireJS 之前就以非模块化代码编写的内容,其中我们最担心的是非严格模式的行为可能是代码正常运行所必需的。

我们发现可以将代码转换为 TypeScript,而无需将其转换为 ES6 模块。这样一来便可以保持稀松模式。虽然这意味着我们在这部分代码中基本上没有跨模块的类型检查,但我们认为这是可以接受的折衷方案。

第一次转换后的特征

我们首先对 Jasmine 测试套件开始了大规模转换(后来我们迁移到了 Jest),这样一来,便可以确保以后的迁移不会同时更改测试和代码,于是更有信心不引入静默错误。转换了 Jasmine 测试之后,我们开始寻找生产代码中第一个转换的候选者。

在 Dropbox,我们有一种在发布功能之前进行 bug 修复的文化:QA 和团队的许多工程师会坐在一起,尝试手动找出功能的 bug。与 QA 和许多团队讨论之后,我们决定首先转换内部工具和共享链接页面的评论 UI。

然后开始转换内部崩溃报告、功能 gating 和电子邮件发送工具,接着开始大批量开始转换其余面向用户的代码库。

附带说明:因为我们最近投资采用了 Bazel 作为构建工具,并且以此工具作为我们开发和集成测试框架的基础,所以很容易确定一个 bug 是否是由更改引起的。由于我们使用 Bazel 和自己的 itest 工具提供服务,我们可以轻松查看之前的版本,并对其运行 itest。通过在代码的确切版本上重建和启动 dev 服务的副本,很容易看到错误是否是由更改引入的。Dropbox 工程师本杰明·彼得森(Benjamin Peterson)在 2017 年 Bazel 大会上发表的关于集成测试的演讲中谈到了 itest 是如何运行的。

从这里开始转换内部崩溃报告、功能门控和电子邮件发送工具,然后开始批量转换其余面向用户的代码库。

严谨的意义

编写代码转换器时我们学到的一条经验是:你必须严谨,涵盖每个角落才行。明确指出哪些内容没有覆盖是非常重要的,因为错过的任何场景都可能会出错。如果要编写自己的转换工具,请参考以下提示:

  • 每当你为一个 node 类型添加转换时,请在文档中查看需要覆盖的所有情况。
  • 如果你认为某个 node 类型不太可能出现并且不值得覆盖,请抛出一个错误;这样一来,如果它确实出现在代码中,你就不会感到惊讶了。为此,我们高度依赖 ESTree 规范 和 ts-estree 源代码。
  • 每当你发现错误时,请搜索你的代码库以查找该错误模式的其他实例并修复它们。否则,你会在生产中不停遇到类似的错误,结果焦头烂额。

尾声

在项目的最后几周,我们一次转换大约 100-200 个文件。通过改进工具,让这种规模的转换可以在几个小时的工程时间内完成。这意味着可以在一两天内就从零开始集成到主分支中,尽量降低重新部署的开销。大部分时间都花在类型检查和调整上了,因为在前期验证工作中已经解决了 Jasmine 和 Selenium 测试的大多数问题。

我们的一个技巧是在代码库上运行 tsc --noEmit --watch 快速迭代,这样就可以在大约 10 秒内获得增量类型检查结果。之所以能这么快,部分是因为在迁移过程中从 TypeScript 2.5 升级到了 2.6,后者大幅提升了 --watch 的速度。

为了保持专注,我们还在团队区的白板上写上了剩余的 CoffeeScript 文件的计数,并在每次将代码合并到 master 分支时更新数据。

转换完最后的 CoffeeScript 之后,我们与内部客户一起畅饮咖啡,欢送 CoffeeScript。

只有两个错误

我们一开始就知道,如果引发了太多错误,整个项目最后都会报销。结果,我只记得有两个错误进入了生产环境。大多数潜在错误是在手动修复类型检查错误时引入的,尽管我们的测试覆盖率不高,但它们并没有闯过我们 Jasmine 和 Selenium 测试的考验。

因此,大多数团队除了意识到他们的代码现在是 TypeScript 之外,并没有感到有什么变化。虽然他们需要重做一些工作,但他们很满意新的 TypeScript 环境,因此我们没有收到太多抱怨。

我们最后才转换那些最担心出问题的团队的代码,这样就能用之前零错误的表现说服他们了。但有一个团队还是不放心,于是我们承诺说:即便出现了重大错误,我们也会 24 小时快速响应并修复(只要他们告诉我们如何重现),还会在一个工作日内解决次要错误。

之所以做出这一承诺,是因为我们对转换脚本充满信心。结果他们并没有遇到重大错误,唯一一个小错误我们也是在异常报告中发现的,在他们第二天上班之前就解决掉了。

还有一些错误一开始他们说是我们的转换造成的,但最后都被我们证明来自于其他原因。

回顾

最终,自动迁移过程仅花费了大约两个月时间,有三名工程师参与,花费了大约 19 个工程师周。当然,迁移输出的不是大多数人最初想要的理想的 TypeScript,而是一些杂乱无章,遍布 any 的 TypeScript。

这一代价是值得的。它让我们更快地摆脱了 CoffeeScript,这样就不用继续支持 CoffeeScript,也不用让新员工学习这种语言。可以在所有地方使用 TypeScript,同时逐步改进代码样式和类型安全。

在整个过程中我们吸取了很多技术教训,其中可能最重要的教训是:应该将政治和组织资源省下来,用在不能为所有人自动化的那些任务上。尽管没有人特别喜欢 CoffeeScript,而且有些团队可能已经自愿将代码转换为 TypeScript,但让其他人在一年时间里手动转换到 TypeScript 的要求太不切实际了。

事后看来,我们应该尽量自动化那些重复性的劳动,遇到无法自动化,真正需要专业编程知识的问题时才去动用宝贵的人力资源。

现今

后记:快进到 2020 年,Dropbox 已经有了 200 万行 TypeScript 代码。我们的整个代码库都是静态类型的,并且内部有一个繁荣的 TypeScript 社区。TypeScript 使我们能够扩展工程组织,使各个团队可以独立工作,同时在整个代码库中保持清晰的联系。

TypeScript 这种语言已迅速普及,我们很幸运能成为最早迁移的大公司之一。因此我们得以发展这一领域的专业知识并与外界分享。我们的 JS 公会定期分享 TypeScript 的技巧和窍门,我们的工程师喜欢他们使用的语言。一位工程师甚至撰写了一份案例研究,总结 TypeScript 不是 JavaScript 严格超集的那些情况。

仍然有少数文件带有“此文件从 coffeescript 迁移过来”的注释,但这些文件仅占代码库的一小部分。我们现在的代码有良好的类型,并且一般会 push back 那些 any。最近,我们将所有代码库都升级到了 TypeScript 3.8。——Matthew Gerstman

英文原文

The Great CoffeeScript to Typescript Migration of 2017

阅读数:1 发布于:2020 年 6 月 27 日 10:16

评论

发布
暂无评论