译者 | 王强
策划 | 蔡芳芳
几年前,彭博工程公司决定采用 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”值定向的条件类型。
代码语言:javascript复制type A = unknown extends {} ? string : number;
如果启用了“strictNullChecks”,则 A 为一个 number。如果禁用了“strictNullChecks”,则 A 为一个 string。如果导出此类型的包未使用与导入它的包相同的严格性设置,这段代码就会中断。以上是我们面临的现实问题的简化示例。结果,我们选择弃用严格性模式的灵活性,换取对所有项目都有一致的配置。
生态系统一致性,OK!
4. 如何指定依赖项的位置很重要
我们需要明确声明 TypeScript 依赖项的位置。这是因为我们的 ES 模块系统不依赖“通过遍历一系列名为 node_modules 的目录来查找依赖项”的 Node 文件系统约定。
我们需要能够声明 bare-specifier(裸指定符,例如“lodash”)到磁盘上目录位置(“c:dependencieslodash”)的映射。这很像是试图解决 Web 类似问题的 import maps。首先,我们尝试在 tsconfig 中使用“paths”选项。
代码语言:javascript复制// tsconfig.json
"paths": {
"lodash": [ "../../dependencies/lodash" ]
}
这几乎适用于所有用例。但我们发现它降低了生成的声明文件的质量。TypeScript 编译器必须将合成(synthetic)的 import 语句注入声明文件中,以允许使用复合类型——其中的类型可以取决于其他模块的类型。当合成的 import 引用依赖项中的类型时,我们发现“paths”方法注入了相对路径(import("../../dependencies/lodash")),而不是保留裸指定符(import "lodash")。对于我们的系统来说,外部包类型的相对位置是可能会更改的实现细节,因此这是不可接受的。
生态系统一致性?
我们找到的解决方案是使用 Ambient 模块:
代码语言:javascript复制// 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 手册称其为“有点危险”。
可扩展性?生态系统一致性?
代码语言:javascript复制// 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 关键字的用法会有不同。
- global——不使用 import 或 export 的声明文件将被视为 global。顶级声明是全局导出的。
- module——具有至少一个 export 声明的声明文件将被视为模块。只有 export 声明会被导出,不会定义任何 global。
- 隐式 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 文件引用其他包中的私有文件是不可接受的。下面是一个出错的例子。
代码语言:javascript复制// index.ts
import boxMaker from "another-package"
export const box = boxMaker();
以上源可能导致 tsc 发出以下不良声明。
代码语言:javascript复制// 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 了解全部合法依赖项的入口点。
代码语言:javascript复制// 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 意识到它正在生成一个依赖项的潜在危险路径时,也会报错,这两种错误很像。
代码语言:javascript复制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,冗余代码实在太多了。
可扩展性?
包内类型的内联不是生态系统问题,因为它在外部不可见。当跨包边界内联类型时就出问题了,因为它将这两个特定版本耦合在一起。在我们的非固定包系统中,每个包都可以独立进化。这意味着存在类型不兼容的风险,尤其是类型陈旧的风险。
生态系统一致性?
- 通过实验,我们发现了防止内联类型声明的一些可选方法,例如:
- 首选 interface 而不是 type(接口不内联)
- 如果未导出声明所需的 interface,则 tsc 将拒绝内联该类型并生成明显错误(例如,TS4023: Exported variable has or is using name from external module but cannot be named.)
- 如果未导出生成的声明所需的 type,则 tsc 将静默内联该类型
- Nicholas Jamieson 写了一篇关于优先使用 interface 而非 type 的文章,包括了一条 ESlint 规则
- 使类型 nominal(带有私有成员的 enum 和 class 之类的 nominal 类型不被内联)
- 将类型注解添加到导出
- 没有注解,就会内联
- 用显式类型注解,我们可以强制引用行为
可扩展性,OK;生态系统一致性,OK
这种内联行为似乎没有被严格指定。这是声明文件构造方式的副作用。因此,上述方法将来可能无法使用。我们希望这是可以在 TypeScript 中形式化的内容。在此之前,我们将依靠用户培训来缓解这种风险。
10. 生成的声明可以包含非必要依赖项
TypeScript 声明文件的消费者通常只关心包的公共类型 API。TypeScript 声明发射会为项目中的每个 TypeScript 文件恰好生成一个声明文件。这些内容中某些可能与用户无关,并且可能会暴露私有的实现细节。这种行为对于 TypeScript 的新手来说可能很难想象,他们希望类型是公共 API 的表示,就像在“Definitely Typed”上找到的手写类型一样。
其中一个示例是:生成的声明包括仅用于内部测试的函数类型。
可扩展性?
由于我们的包系统知道所有公共包的入口点,因此我们的工具链可以爬取可达类型的图,以识别出不需要公开的所有类型。这就是死类型消除(DTE),或更确切地说是摇树。我们编写了一个工具来执行这一操作——它只从声明文件中消除代码,这样任务最轻松。它不会重写或重定位代码——毕竟它不是打包器。这意味着发布的声明是 TypeScript 生成声明的一个不变子集。
减少发布类型的数量有几个优点:
- 它减少了与其他软件包的耦合(某些软件包不会从其依赖项中重新导出类型);
- 它防止了完全私有的类型泄漏,从而改善了封装;
- 它减少了需要用户下载和解压缩的已发布声明文件的数量和大小;
- 它减少了 TypeScript 编译器在类型检查时必须解析的代码量。
这种“摇树”会带来显著的效果。我们发现,有些包可以删除 90%以上的文件和 90%以上的类型代码行。
可扩展性,OK!
有些选项效果很不错
我们在某些 tsconfig 选项的语义中发现了一些惊喜。
tsconfig 中的 baseUrl
在 TypeScript 4.0 中,如果要使用项目引用或“paths”,则还需要指定一个 baseUrl。这样做的副作用是导致所有裸指定符的导入都相对于项目的根目录进行解析。
代码语言:javascript复制// package-a/main.ts
import "sibling" // Will auto-complete and type-check if `package-a/sibling.js` exists
这里的危险在于,如果要引入任何形式的“paths”,则会带来额外的含义,使 import "sibling"被 TypeScript 意外地解析为从源目录内部导入的/sibling.js。
标准对齐?
为解决问题,我们使用了一个 baseUrl。使用 null 字符可以防止意外的自动完成。我们不建议你在家尝试。
我们在 TypeScript 问题跟踪器上报告了这个 issue,很高兴看到 Andrew 在 TypeScript 4.1 中解决了它,我们可以告别 null 字符了!
标准对齐,OK!
JSON 模块暗示合成默认导入
如果你要使用“resolveJsonModules”,则还必须启用“useSyntheticDefaultImports”,以便 TypeScript 将 JSON 模块视为默认导入。将来,使用默认导入可能会成为 Node 和 Web 处理 JSON 模块的方式。
不幸的是,启用“useSyntheticDefaultImports”会人为地允许从不具有默认导出的常规 ES 模块中默认导入!这是一种危险,你只有在开始运行代码时才会发现它,而且它很快就会崩溃。
标准对齐?
理想情况下,应该有一种方法可以导入不涉及全局启用合成默认值的 JSON 模块。
值得称赞的内容
从工具链的角度来看,我们在 TypeScript 中看到的一些出色内容也是值得一提的。
增量构建很有用。TypeScript 3.6 对增量构建的 API 支持给我们带来了巨大的收益,让我们可以自定义工具链进行快速重建。我们报告了一个 incremental 与 noEmitOnError 结合使用时的性能问题后,Sheetal 在 TypeScript 4.0 中解决了它。
可扩展性,OK!
“isolatedModules”可以确保我们进行快速的独立(一进一出)转译。TypeScript 团队修复了许多问题来改进这一选项,包括:
- 允许 isolatedModules 的 emitDeclaration
- 允许 isolatedModules 的 noEmitOnError
- 声明必须使用 isolatedModules 显式导出类型
可扩展性,OK!生态系统一致性,OK!
项目引用是提供无缝 IDE 体验的关键所在。我们充分利用它们来进行基于多包工作区的开发工作,开发起来就像单项目一样流畅。感谢 Sheetal 为其带来的改进,还支持了无文件的“解决方案样式”tsconfigs。
可扩展性,OK!
仅类型导入非常有用。我们在各处都在使用它们,以安全地区分运行时导入和编译时导入。对于使用“isolatedModules”的某些模式而言它们是必不可少的,还允许我们使用"importsNotUsedAsValues": "error"来获得最大安全性。感谢 Andrew!
生态系统一致性,OK!标准对齐,OK!
“useDefineForClassFields”可以确保我们发射的 ESNext 代码不会被重写,从而保持语言的 JS Types 性质。这意味着我们可以原生地使用类字段。感谢 Nathan 提供了这一特性,使我们的迁移过程更加顺利。
标准对齐,OK!
TypeScript 中的特性交付经常给人惊喜。每次我们意识到自己需要一个特性时,经常发现它已经在下一版本中提供了。
结 论
如今,TypeScript 是我们应用平台的一等语言。将 TypeScript 与另一个运行时集成在一起的过程,证明这种语言和编译器似乎和 JavaScript 一样灵活——它们几乎都可以在任何地方使用。
虽然我们需要一路学习很多东西,但过程中没有什么不可逾越的障碍。当我们需要支持时,社区和 TypeScript 团队的反馈让我们如沐春风。使用共享开源技术的一个明显好处是,当你遇到问题时,常常会发现自己并不孤单。当你找到答案时,也会分享它们。
致 谢
非常感谢 Thomas Chetwin、Robin Ricard、Kubilay Kahveci、ScottWhittaker、Daniel Rosenwasser、Nathan Shively-Sanders、Titian Dragomir 和 Maxwell Heiber 的审阅。感谢 Orta 提供的 Twoslash 代码格式。
延伸阅读:
https://www.techatbloomberg.com/blog/10-insights-adopting-typescript-at-scale/