将超过5000万行JS代码迁移到TypeScript后的总结

译者 | 王强

策划 | 蔡芳芳

几年前,彭博工程公司决定采用 TypeScript 作为一等语言。本文分享了我们在这一旅程中学到的一些见解和教训。

本文的重点是我们在采用 TypeScript 后获得的巨大收益,当然,作为工程师,在醉心于 TS 的同时我们也会发现、解决和分享其中存在的问题。

背景

在 TypeScript 出现之前,彭博社已经在 JavaScript 上投入了大量资源——超过 5,000 万行 JS 代码。我们的主要产品是彭博终端,其中包含 10,000 多个应用。这些应用种类繁多,包括显示大量实时财务数据和新闻的应用、提供交互式交易解决方案的应用,还有多种消息应用,等等。早在 2005 年,公司就开始将这些应用从 Fortran 和 C/C++ 迁移到服务端 JavaScript,而客户端 JavaScript 于 2012 年左右推出。今天,我们公司有 2,000 多名软件工程师在编写 JavaScript。

将这么大规模的代码库从标准 JavaScript 转换为 TypeScript 是一件大事。因此,我们努力制定了完善的迁移流程,使我们得以遵循标准,并保留现有的特性,进而快速安全地改进和部署代码。

如果你曾在一家大公司中参与过技术迁移,那么你肯定见识过繁重的项目管理工作——这种工作的目的是为了强迫团队继续迁移,虽然他们宁愿去开发新特性。但我们发现 TypeScript 的采用过程完全不是这回事。工程师们是在自发迁移并推动这个过程!当我们启用 TypeScript 平台支持的 beta 版后,仅第一年就有 200 多个项目选择了 TypeScript。没有一个项目选择回退。

是什么让如此大规模的TS采用与众不同?

除了规模之外,这次 TypeScript 迁移活动很特殊的一点在于,我们拥有自己的 JavaScript 运行时环境。这意味着除了著名的 JavaScript 主机环境(例如浏览器和 Node)之外,我们还直接嵌入了 V8 引擎和 Chromium,以创建我们自己的 JavaScript 平台。这里的好处在于,我们可以提供简单便捷的开发体验,让自己的平台和软件包生态系统直接支持 TypeScript。Ryan Dahl 的 Deno 的理念是类似的,他们的办法是将 TypeScript 编译放入了运行时,而我们将其保留在独立于运行时进行版本控制的工具中。一个有趣的结果是,我们得以探索在跨客户端和服务器、且不使用 Node 专属约定的独立 JS 环境中使用 TypeScript 编译器的体验(例如,这里没有 node_modules 目录)。

我们的平台支持一个使用一套通用工具链和发布系统的内部软件包生态系统。这样我们就能鼓励和推行最佳实践,例如默认使用 TypeScript 的“严格模式”以及确保全局不变量。例如,我们可以保证所有发布的类型都是模块化的,而非全局的。这样一来,工程师可以专注于代码编写,而无需操心如何让 TypeScript 与打包程序或测试框架完美搭配。我们的 DevTools 和错误栈正确使用了源映射。我们可以使用 TypeScript 编写测试,并且可以根据原始 TypeScript 代码准确地表示代码覆盖率。一切都很好用。

我们的目标是让常规 TypeScript 文件成为我们 API 的唯一事实来源,而不用维护手写声明文件。也就是说我们有很多代码都非常依赖 TypeScript 编译器从 TypeScript 源代码自动生成的.d.ts 声明文件。因此如你所见,当声明发射出问题时我们会察觉的。

关键原则

下面概括一下我们正在努力遵循的三大关键原则。

  • Scalability(可扩展性):随着越来越多的软件包采用 TypeScript,开发速度应维持在较高水平。应该尽量减少花在安装、编译和检查代码上的时间。

  • Ecosystem Coherence(生态系统一致性):程序包应该协同工作。升级依赖项应该很容易。

  • Standards Alignement(标准一致性):我们希望坚持使用 ECMAScript 等标准,并为将来的标准做好准备。

令我们意外的发现通常来自于我们不知道是否能够遵循这三大原则的场景。

10 大学习要点

1. TypeScript 可以是 JavaScript+Types

多年来,TypeScript 团队一直积极推行和遵循标准 ECMAScript 语法和运行时语义。这样 TypeScript 可以集中精力在 JavaScript 之上提供一层类型语法和类型检查语义。职责很明确:TypeScript=JavaScript+Types!

这个模型很厉害,意味着编译器输出是人类可读的 JavaScript,就像是程序员写的一样。即使你没有原始源代码,生产代码也很容易调试。你不必担心选择 TypeScript 可能会让你错过将来的 ECMAScript 特性。它为运行时,甚至未来的 JavaScript 引擎打开了大门;未来的引擎可以忽略类型语法,从而原生地“运行”TypeScript。开发体验会越来越简单轻松的!

在此过程中,TypeScript 扩展了一些不太适合该模型的特性。enum、namespace、参数属性和 experimental 修饰符都具有需要它们扩展为运行时代码的语义,很可能永远不会被 JavaScript 引擎直接支持。

标准对齐?

这不是什么问题。TypeScript 设计目标明确了避免将来引入更多运行时特性的需求。TypeScript 团队的一名成员 Orta 做了一张 meme 幻灯片来强调这一原则。

我们的工具链会避免使用这些前景不明的特性,确保我们不断增长的 TypeScript 代码库是真正的 JS+Types。

标准对齐,OK!

2. 跟上编译器是值得的

TypeScript 发展迅速。新版语言引入了新的类型级别特性、增加了对 JavaScript 特性的支持、提高了性能和稳定性、并改进了类型检查器以查找更多类型错误。因此新版本很诱人!

虽然 TypeScript 在努力保持兼容性,但是这些类型检查改进会对构建流程引入重大更改,因为以前看起来没有错误的代码库中会因此出现新的错误。因此,升级 TypeScript 时需要一些干预才能获得这些收益。

还可以考虑另一种形式的兼容性,即项目间兼容性。随着 JavaScript 和 TypeScript 语法的发展,声明文件需要包含新的语法。

如果一个库升级到 TypeScript,并开始使用新语法生成新的声明文件,那么如果使用该库的应用项目的 TypeScript 版本不理解新语法,就会无法编译。新声明语法的一个示例是 TypeScript 3.7 中的 getter/setter 访问器的发射。TypeScript 3.5 或更早版本无法理解这些内容。这意味着使用不同编译器版本的项目生态系统并不好用。

生态系统一致性?

在彭博社,我们的代码库分布各个 Git 存储库中,它们使用的是通用的工具链。尽管没有单体代码库,但我们确实有一个 TypeScript 项目的中心化存储库。这样我们就能创建一个持续集成(CI)作业来“构建世界”,并验证每个 TypeScript 项目上编译器升级的构建时间和运行时效果。

这种全局检查非常强大。我们用它来评估 TypeScript 的 Beta 和 RC 版本,以便在升级标准版本之前发现问题。拥有各种各样的实践代码还意味着我们可以找到很多边缘情况。我们使用这套系统在编译器升级之前为项目提供修复指导,以便确保升级完美实现。到目前为止,这一策略的效果很不错,我们已经能将整个代码库保持在最新版本的 TypeScript 上。这意味着我们不需要采取缓解措施,例如降低 DTS 文件的等级之类。

生态系统一致性,OK!

3. 一致的 tsconfig 设置是值得的

tsconfig 提供的灵活性主要在于,它使你可以让 TypeScript 适应你的运行时平台。在所有项目都以同一个常绿运行时为目标的环境中,事实证明对每个项目进行单独配置是风险很大的。

生态系统一致性?

因此,我们让工具链负责在构建时使用“理想”设置生成 tsconfig。例如,默认情况下启用“strict”模式以增加类型安全性。强制执行“isolatedModules”,以通过每次操作一个文件的简单编译器快速编译我们的代码。

将 tsconfig 视为生成的文件(而非源文件)的另一个好处是,它允许高层工具链负责定义“references”和“paths”之类的选项,从而将多项目“工作区”灵活地链接在一起。

这里出了些问题,因为少数项目希望能够自定义,例如切换到较宽松的模式以减轻迁移负担。

一开始我们试图满足这些要求,并提供了一些选项。后来我们发现,当使用一组选项构建的声明文件被使用不同选项的程序包占用时,就会导致程序包间冲突。下面是一个例子。

可以创建一个由“strictNullChecks”值定向的条件类型。

type A = unknown extends {} ? string : number;

如果启用了“strictNullChecks”,则 A 为一个 number。如果禁用了“strictNullChecks”,则 A 为一个 string。如果导出此类型的包未使用与导入它的包相同的严格性设置,这段代码就会中断。以上是我们面临的现实问题的简化示例。结果,我们选择弃用严格性模式的灵活性,换取对所有项目都有一致的配置。

生态系统一致性,OK!

4. 如何指定依赖项的位置很重要

我们需要明确声明 TypeScript 依赖项的位置。这是因为我们的 ES 模块系统不依赖“通过遍历一系列名为 node_modules 的目录来查找依赖项”的 Node 文件系统约定。

我们需要能够声明 bare-specifier(裸指定符,例如“lodash”)到磁盘上目录位置(“c:\dependencies\lodash”)的映射。这很像是试图解决 Web 类似问题的 import maps。首先,我们尝试在 tsconfig 中使用“paths”选项。

// tsconfig.json

“paths”: {

“lodash”: [ “…/…/dependencies/lodash” ]

}

这几乎适用于所有用例。但我们发现它降低了生成的声明文件的质量。TypeScript 编译器必须将合成(synthetic)的 import 语句注入声明文件中,以允许使用复合类型——其中的类型可以取决于其他模块的类型。当合成的 import 引用依赖项中的类型时,我们发现“paths”方法注入了相对路径(import(“…/…/dependencies/lodash”)),而不是保留裸指定符(import “lodash”)。对于我们的系统来说,外部包类型的相对位置是可能会更改的实现细节,因此这是不可接受的。

生态系统一致性?

我们找到的解决方案是使用 Ambient 模块:

// ambient-modules.d.ts

declare module “lodash” {

export * from “…/…/dependencies/lodash”;

export default from “…/…/dependencies/lodash”;

}

Ambient 模块是特殊的。TypeScript 的声明发射保留对它们的引用,而不是将其转换为相对路径。生态系统一致性,OK!

5. 避免重复类型很重要

应用的性能是关键指标,因此我们试着尽量减少应用在运行时加载的 JS 数量。我们的平台确保在运行时仅使用一个版本的软件包。移除版本的重复数据意味着给定的包不能“冻结”或“固定”其依赖项。因此,这意味着软件包必须时刻保持兼容性。

我们希望为类型提供相同的“完全唯一(exactly-one)”保证,以确保对于给定的项目编译,类型检查仅考虑软件包依赖项的一个版本。除了提高编译时效率外,这里的动机还在于确保类型检查的世界更好地反映运行时世界。我们特别想避免陈旧(staleness)问题和“nominal 地狱”,在这些情况下可能会通过“钻石模式”导入两个不兼容的 nominal 类型版本。随着生态系统采用的 nominal 类型日益增多,这种危害也可能随之加剧。

可扩展性?生态系统一致性?

我们编写了一个确定性解析器,其根据所构建软件包的声明版本,确保为每个依赖项只选择一个版本。

可扩展性,OK!生态系统一致性,OK

这意味着类型依赖图是动态组合的——它不会冻结。尽管这种非固定的依赖方法可以带来很多好处并避免了某些危害,但我们后来了解到,由于 TypeScript 编译器中的一些行为细节,它可能会带来新的危害。请参阅第 9 部分以了解更多信息。

这些折衷和选择不是只适用于我们自己的平台。它们同样适用于发布到 DefinitelyTyped/npm 的任何人,并取决于 package.json "dependencies"中表示的所有包版本约束的累加效果。

6. 应避免隐式类型依赖

在 TypeScript 中引入全局类型很容易。依赖全局类型甚至更容易。如果不加以检查,那么在距离遥远的包之间可能出现隐藏的耦合。TypeScript 手册称其为“有点危险”。

**可扩展性?生态系统一致性?

**

// A declaration that injects global types

declare global {

interface String {

fancyFormat(opts?: StringFormatOptions): string;

}

}

// Somewhere in a file far, far away…

String.fancyFormat(); // no error!

这里的解决方案大家都熟悉:相对于全局状态,优先使用显式依赖。TypeScript 长期以来一直为 ECMAScript 的 import 和 export 语句提供支持,从而实现了这一目标。

因此,剩下的唯一需求是防止意外创建全局类型。所幸我们可以静态检测 TypeScript 允许引入全局类型的所有情况。于是我们更新了工具链,以检测并报错这些情况。也就是说我们可以放心地确认一个事实,即导入一个包的类型是无副作用的操作。

可扩展性,OK!生态系统一致性,OK!

7. 声明文件具有三种导出模式

并非所有的声明文件都相等。声明文件根据其内容,会以三种模式之一运行;特别是 import 和 export 关键字的用法会有不同。

  1. global——不使用 import 或 export 的声明文件将被视为 global。顶级声明是全局导出的。

  2. module——具有至少一个 export 声明的声明文件将被视为模块。只有 export 声明会被导出,不会定义任何 global。

  3. 隐式 export——没有 export 声明,但使用 import 的声明文件将触发已定义但尚未说明的行为。也就是将顶级声明视为命名的 export 声明,并且不会定义 global。

我们不使用第一种模式。我们的工具链会避免使用全局声明文件(请参见上一节)。这意味着所有声明文件都使用 ES 模块语法。

可扩展性,OK!生态系统一致性,OK!标准对齐,OK!

也许会令人惊讶的是,我们发现看起来有点诡异的第三种模式很有用。通过在 ambient 声明文件的顶部只添加单行 self-import,可以防止它们污染全局名称空间:import {} from “./”;。这种单行代码简化了将第三方声明(例如 lib.dom.d.ts)转换为模块化的操作,并且避免了维护更复杂的 fork 的麻烦。

TypeScript 团队似乎并不喜欢第三种模式,因此请尽可能避免使用第三种模式。

8. 包的封装可能出问题

如前所述(第 5 节),我们使用未固定的依赖项意味着:对于我们的包来说,不仅要保留运行时兼容性,还要时刻保持类型兼容性,这一点很重要。这是一个挑战,因此要确保兼容性能保持下去,我们必须深度了解哪些类型被公开,并且必须以这种方式加以约束。第一步是明确区分公共模块与私有模块。

Node 最近以 package.json “exports” 字段的形式获得了这种能力。它通过显式列出可从包外部访问的文件来定义封装边界。

如今,TypeScript 尚不了解 package exports,因此不理解依赖项中的哪些文件被视为公共或私有的概念。在声明生成期间,当 TypeScript 在发射的.d.ts 文件中合成 import 语句以传递类型时,这就成为了一个问题。我们的.d.ts 文件引用其他包中的私有文件是不可接受的。下面是一个出错的例子。

// index.ts

import boxMaker from “another-package”

export const box = boxMaker();

以上源可能导致 tsc 发出以下不良声明。

// index.d.ts

export const box : import(“another-package/private”).Box

这就不对了,因为“another-package/private”不属于这个包的兼容性保证,因此可以在没有 SemVer 重大 bump 的情况下进行移动或重命名。如今,TypeScript 无法知道它生成的是一个脆弱的导入。

生态系统一致性?

我们使用两个步骤来缓解这一问题:

1、我们的工具链会向 TypeScript 解析器通知指向依赖项的,有意公开的裸指示符路径(例如“lodash/public1”“lodash/public2”)。我们在 TypeScript 文件流入编译器之前,静默地将 type-only 的导入语句添加到 TypeScript 文件的底部,从而确保 TypeScript 了解全部合法依赖项的入口点。

// user’s source code

// injected by toolchain to assist declaration emit

import type * as __fake_name_1 from “lodash/public1”;

import type * as __fake_name_2 from “lodash/public2”;

在生成对推断的传递类型的引用时,TypeScript 的声明发射会优先使用这些现有的名称空间标识符,而不是合成对私有文件的导入。

2、如果 TypeScript 对我们知道是私有的依赖项中的文件生成路径,则工具链会报错。当 TypeScript 意识到它正在生成一个依赖项的潜在危险路径时,也会报错,这两种错误很像。

error TS2742: The inferred type of ‘…’ cannot be named without a reference to ‘…’.

This is likely not portable. A type annotation is necessary.

这会通过显式注解导出来通知用户解决问题。或者在某些情况下,他们需要直接从公共包入口点导出内部类型来更新依赖项,以公开内部类型。

生态系统一致性,OK!

我们期待 TypeScript 获得对入口点的一等支持,这样就用不着这种解决方法了。

9. 生成的声明可以内联依赖项中的类型

程序包需要导出.d.ts 声明,以便用户可以消费它们。我们选择使用 TypeScript 的 declaration 选项从原始.ts 文件生成.d.ts 文件。尽管我们可以与常规代码一起手写和维护.d.ts 兄弟文件,但这种方法不太可取,因为保持它们同步意味着一种危险。

在大多数情况下,TypeScript 的声明发射很好用。我们发现的一个问题是,有时 TypeScript 会将类型从依赖项内联到生成的类型中(#37151)。这意味着类型定义将被重定位,并可能被复制,而不是通过导入语句进行引用。使用结构化类型时,编译器不必强制类型是从一个定义站点引用的——这些类型可以复制。

我们还发现了一些极端情况,其中这种复制让声明文件从 7KB 膨胀到了 700KB,冗余代码实在太多了。

可扩展性?

包内类型的内联不是生态系统问题,因为它在外部不可见。当跨包边界内联类型时就出问题了,因为它将这两个特定版本耦合在一起。在我们的非固定包系统中,每个包都可以独立进化。这意味着存在类型不兼容的风险,尤其是类型陈旧的风险。

最后

开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

最后写上我自己一直喜欢的一句名言:世界上只有一种真正的英雄主义就是在认清生活真相之后仍然热爱它

  • 26
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值