大规模采用 TypeScript 之后的 10 个见解
几年前,彭博社工程部决定采用 TypeScript 作为首选开发语言。在这篇文章中,将分享我们在这次迁移过程中学到的经验教训以及一些见解。
总体而言,我们认为 TypeScript 是个完全正向的升级。当你读到那些我们发现的困扰时,请记住这一点。作为工程师,我们天然的会对发现、解决和分享问题给吸引,即使在娱乐的时候。?
背景
在 TypeScript 问世以前,彭博社就已经对 JavaScript 有着巨量的开发投入—— 5000 万行以上的 JS 代码。我们的主要产品是包含了一万多个应用的彭博社终端。这些应用的种类差异性巨大,从显示密集的实时财务数据和新闻,到交互式交易解决方案以及多种格式的消息传递等。早在 2005 年,公司就开始将这些应用的实现方式从 Fortran 和 C/C++ 迁移至基于服务器端的 JavaScript,而在 2012 年左右,已经迁移为基于客户端 JavaScript 的版本。
将这样大规模的纯 JavaScript 代码转换为 TypeScript 是一个很大的工程。我们下了很大的功夫,确保在迁移时有一个稳妥的过程 —— 既遵循代码标准又能保证我们既有的功能可以快速安全的转化和部署。
如果你在一个大公司经历过技术迁移,那你一定对那种用严格的项目管理来迫使技术团队向前推进的做法不会感到陌生,因为往往这些团队宁可去开发新的功能也不愿意来做这件事。但是我们发现,TypeScript 的迁移过程却完全不同。工程师们会自发的进行代码转换,并且非常的支持这个过程!当我们发布 beta 版的 TypeScript 平台支持时,仅第一年就有超过 200 个项目选择切换到了 TypeScript,并且没有一个回头。
是什么让这次 TypeScript 实践如此特别呢?
除了规模之外,这次集成 TypeScript 的特别之处在于,我们有自己的 JavaScript 运行时环境。也就是说,除了像浏览器和 Node 这类众所周知的 JavaScript 运行环境外,我们也直接嵌入了 V8 引擎以及 Chromium 内核来建造自己的 JavaScript 平台。这样带来的好处就是,我们的平台和软件包生态原生支持 TypeScript,这使得我们可以向开发者提供一个简单的开发体验。Ryan Dahl 的 Deno 通过将 TypeScript 编译到运行时中来实现相似的目标,而我们将其保留为独立于运行时外的可以进行版本控制的工具。一个有趣的结论是,我们开始探索在跨客户端和服务端且不满足 Node 使用规则(例如,没有 node_modules 目录)的独立 JS 环境中使用 TypeScript 编译器将会如何。
我们的平台支持使用通用的加工和发布系统的内部的软件包生态系统。这使我们可以促进和实施最佳的开发实践,比如默认使用 TypeScript 的“严格模式”,来保障全局的不变量。例如,我们保证了所有发布的类型是模块化的,而不是全局性的。同时也代表着,工程师们可以专注于编写代码,而不是去花精力解决如何让 TypeScript 去兼容某个打包器或者测试框架。开发者工具和错误堆栈可以正确的使用 sourcemaps。测试也可以用 TypeScript 编写,并依据原始 TypeScript 代码准确的展示代码覆盖率。就是这么好用。
我们的目标是让常规的 TypeScript 文件成为我们 API 实质上的唯一来源,从而不需要手动维护声明文件。这意味着我们有大量的代码非常依赖于 TypeScript 编译器从源码中自动生成的 .d.ts
声明文件。如你所见,当声明文件没有像预想的一样产生时,我们会立刻发现它。
原则
我来列出我们追求的三条关键原则:
可扩展性:随着越多的包采用 TypeScript,项目的开发速度应该越来越快。花费在安装、编译和检查代码上的时间应该最小化。
系统一致性:软件包工作时需要互相兼容;升级依赖需要可以无痛进行。
遵循标准:我们坚持和标准保持一致,例如 ECMAScript,随时准备好接受他们的下一个版本。
一些让我们感到惊讶的发现,通常都是来自于一些我们不确定是否能够维持这些原则的案例。
10 个重点
1. TypeScript 可以是 JavaScript + Types
多年以来,TypeScript 团队一直积极的追求采用和兼容标准的 ECMAScript 语法和运行时语义。这使得 TypeScript 专注于在 JavaScript 之上提供一层定义类型的语法和检查类型的语义。代码的职能被清晰的划分开来:TypeScript = JavaScript + Types!
这是一个极好的模型。这意味着编译出来的代码是可读的 JavaScript,就像编程人员自己写的一样。这也使得即使在没有原始代码的情况下,调试生产环境下的代码也会变得容易。你不需要担心选择了 TypeScript 之后会斩断你在将来使用 ECMAScript 新功能的可能性。TypeScript 为当前的运行时敞开了大门,甚至于将来的 JavaScript 引擎也许可以忽略类型定义语法,原生“运行” TypeScript 代码。一种更简单的开发体验指日可待!
在发展过程中,TypeScript 扩展了一小部分不太适合这个模型的功能。enum
, namespace
, parameter properties
以及 experimental decorators
都需要有将他们扩展为运行时代码的语义,而 JavaScript 引擎很可能永远都不会为这些功能提供支持。
这不是大问题。TypeScript Design Goals 明确表示了避免在未来引入更多的运行时特征。TypeScript 团队的一名成员 Orta 制作了一个 MEME 幻灯片来强调了对这一说法的认可。
我们的工具链通过阻止使用这些功能来避免这些不良的设计,以确保我们不断增长的 TypeScript 代码库是真正的 JS + Types。
2. 持续更新编译器的版本是值得的
TypeScript 发展的很快。新版本一般会引入新的类型层面的功能、对 JavaScript 功能的支持、提升性能和稳定性,同时也会增强类型检测器,用以发现更多的类型错误。所以,使用新版本是一件非常吸引人的事情!
当 TypeScript 努力保持兼容性时,改进的类型检查会对构建过程表现出一些破坏性改变,因为新的错误会在过去没有错误的代码中被识别出来。因此,需要一些干预才能完成对 TypeScript 版本的升级,从而获得新版带来的优势。
还有另一种兼容性问题需要考虑,也就是跨项目的兼容性。随着 JavaScript 和 TypeScript 语法的发展,声明文件也需要容纳新的语法。
假设某一个库升级了 TypeScript 版本并且使用新的语法输出了声明文件。而引用了这个库的项目,如果它们的 TypeScript 版本无法理解这些语法,那么这些项目将会编译失败。例如 TypeScript 3.5 或更早的版本就无法理解 TypeScript 3.7 新增的 getter/setter
存取器方法。这也就意味着,在同一个生态系统中如果各个项目使用不同版本的编译器,情况就会很不理想。
在彭博社,代码分布在各种使用通用工具的 Git 仓库中。尽管没有使用 Monorepo 来统一管理,但我们使用一个注册表集中式管理 TypeScript 项目。这样就使我们可以创建一个持续集成的任务来 “构建一切”,并且检验升级后的编译器对每个 TypeScript 项目构建和运行时的影响。
这个全局检查很强大,我们用它评估 TypeScript 的 Beta 版和 RC 版,以便在常规版本发布前发现问题。拥有多样的真实代码作为资料集意味着我们可以找到边际情况。我们用这个系统来引导项目为编译器升级做好准备,使得它们完美的完成升级。到目前为止,这个策略运行的很好,因此我们可以使整个代码库保持在最新的 TypeScript 之上。这样就意味着我们不需要采取诸如对 DTS 文件降级之类的缓解措施来应对版本升级。
3. 保持一致的 tsconfig 设置是非常重要的
tsconfig
配置文件提供了很大的灵活性,使得你可以根据运行时平台来调整 TypeScript。但是在一个追求多项目共存且长时间持续运行的环境中,对每个项目单独配置却是极具风险的事情。
因此,我们让工具链来负责在编译时基于 “最优” 设置生成 tsconfig 。例如,默认启用 "Strict"
模式来增强类型的安全性;强制使用 "isolatedModules"
则可以确保我们的代码可以使用简单转义器每次对单个文件进行快速编译。
将 tsconfig 视作被生成的文件而不是源文件的另一个好处就是,它允许高级工具通过不同选项(如 “references”,“paths” 等)灵活地将多个项目的 “工作区” 链接在一起。
也有一些例外情况,少数项目需要自定义的配置,比如使用宽松模式来减少迁移负担。
举个例子。最初我们试图使用更少的选项来满足一致性的要求。但后来我们发现了软件包间的冲突,当使用某组选项构建的软件包被使用其他选项构建的软件包引用时,这些冲突就发生了。
合理的做法是创建一个带条件的类型,用于指向被 "strictNullChecks" 检测到的类型值。
type A = unknown extends {} ? string : number;
如果启用了 “strictNullChecks”,那么 A 的类型就是 number;如果没启用,则是 string。如果软件包导出的这个类型和它导入的软件包没有使用相同的严格模式设置,那么程序将会出错。
这是在现实中我们碰到问题的一个简单事例。最终我们放弃了严格模式,选择牺牲灵活性来保持所有项目配置的一致性。
4. 如何指定依赖关系的位置很重要
我们需要显式地向 TypeScript 代码声明依赖的位置。这是因为我们的 ES 模块系统不会像通常的 Node 程序那样,向上递归查找 node_modules
文件夹。
我们需要对修饰符(例如 “lodash”)和其在磁盘上的目录位置(“c:\dependencies\lodash”)的映射进行声明。这类似于在 Web 中引入 maps 的方式来解决映射问题。最初,我们尝试在 tsconfig 中使用 "paths"
这一选项:
// tsconfig.json
"paths": {
"lodash": [ "../../dependencies/lodash" ]
}
这种方式在几乎所有的用例中都运行的很好。尽管如此,我们还是发现这种方式降低了自动生成的声明文件的质量。TypeScript 编译器必须在声明文件中注入复合的导入语句,以实现复合类型的声明 —— 某些类型的定义依赖于其他模块下的类型。当复合引用依赖中的类型时,我们发现 "paths"
并未使用已经定义的修饰符(import "lodash"
),而是引入了相对路径(import("../../dependencies/lodash")
)。对于我们的系统来说,一些类型定义引入自外部软件包,而他们的相对位置是可能会发生改变,这种情况是不可接受的。
我们最终的解决方案是使用 Ambient Modules
// ambient-modules.d.ts
declare module "lodash" {
export * from "../../dependencies/lodash";
export default from "../../dependencies/lodash";
}
Ambient Modules 特别之处在于,TypeScript 在发表声明时保持对修饰符的引用,从而避免将它们转化为相对路径。
5. 类型去重很重要
程序的性能很关键,所以我们要尽量使在运行时中的 JS 保持最小的体积。我们的平台会确保在运行时中每个包只有一个版本的存在。通过这种方式,确保了给定的包不会因为版本不同而锁定和引入不同的依赖。因此,这也使得软件包必须随时保持对系统的兼容性。
我们希望对类型提供一种 “精确且唯一” 的定义,以确保对于给定的编译项目,类型检查只需要对依赖进行单一版本的检查。除了增加编译时的效率以外,这么做的另一个动机就是确保类型检查能够更好的反应运行时环境。我们尤其希望避免落入失效定义问题和 “声明地狱”,即通过 “菱形模式” 导入同一类型声明的多个版本。随着生态采用的声明增加,这个问题的危害会被放大。
我们编写了一个决策式解析器用来根据正在构建的包约束中正确的选择出一个依赖的版本。
这就意味着依赖的类型关系图是动态生成的——而不是静态的锁定某个版本。虽然这种不锁依赖版本的方法带来了很多优点并且回避了很多危险,但我们后来发现,这个方式会因为 TypeScript 编译器的一些古怪行为引入一些不一样的危险。在 9. 声明文件中生成的类型会内联传递自依赖中的类型 中会详细说明。
这些权衡和选择并不是特定于我们的平台。它们同样适用于任何基于类型定义的 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 导入和导出语句提供了支持,从而实现了这一目标。
剩下我们唯一要防止就是意外创建的全局类型。幸运的是,我们在 TypeScript 中可以静态地检测到每一个局类型的引入用例。因此我们可以通过升级工具链来发现这些全局类型的每个用例并抛出错误,因此我们可以安全地依赖于无副作用的包类型引入。
7. 声明文件有三种输出模式
不同的声明文件并不完全等价。内容的不同决定了一个声明文件属于以下三种形式中的哪一种。特别是 import
和 export
关键字的使用:
全局 —— 不使用
import
和export
关键字的声明文件就被认为是全局声明。顶级声明都是输出在全局作用域。模块 —— 至少包含一个
export
关键字的声明文件即为模块声明。只有export
关键字引导的声明会被输出,而且其作用域不会是全局。隐式输出 —— 不使用关键字
export
引导声明,但使用import
关键字导入时会触发未文档化的已定义行为。也就是不再将顶级声明视作全局作用域,而是作为命名空间的声明导出。
我们不使用模式一。我们使用工具链防止全局作用域的声明文件(详见 6. 隐式类型依赖关系应当避免)。所有的声明文件均遵循 ES Module 语法。
有些令人惊讶的是,我们发现看上去有些令人不安的第三种模式却非常有用。通过在声明文件的顶部添加一行 “自引导” 的方式,就可以防止它们污染全局命名空间:import {} from "./
。这个单行使得将第三方声明(如 lib.dom.d.ts)模块化变得非常容易,而且可以避免去维护一个更复杂的代码克隆。
然而 TypeScript 团队看上去并不喜欢第三种模式,所以尽可能的回避这种方式。
8. 包的封装可能会被破坏
如在前文中表述的(5. 类型去重很重要),我们不锁定依赖版本,这意味着我们的包不仅需要保持对运行时的兼容性,同时也要保证在版本更迭时保持类型的兼容性。这是一个挑战,为了实现对兼容性的保护,我们必须真正的了解哪些类型是公开的,且需要对版本加以限制。第一件要干的事情,就是明确区分公共模块和私有模块。
Node 通过在 package.json 中的 exports 字段来实现这个功能。通过显示列出可从包外访问文件的方式,定义封装边界。
目前,TypeScript 并不在意包的导出,因此也不知道依赖中哪些文件是公开的,哪些是不公开的。在声明生成过程中,TypeScript 将导入的语句合成并传递为类型定义再封装成 .d.ts
文件,这时就会产生问题 —— 我们的 .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
这是很糟糕的。“来自另一个私有包” 不能保证兼容性,因为这些声明很可能在某个微小的改动时就被移除或者重命名了。到目前为止,TypeScript 仍无法知晓它生成的文件中是否存在不安全的导入。
我们通过两个步骤来缓解这个问题:
我们的工具链会将试图公开的修饰符指向的路径(例如:"lodash/public1", "lodash/public2")告知 TypeScript 解析器。在 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 的声明执行器将使用这些已知的命名空间修饰符来代替路径方式实现对私有文件的导入。
如果 TypeScript 生成了一个路径,而我们已知其为某个依赖的私有文件,这时我们的工具链就会抛出一个错误。这就像是 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.
这样就会通知到用户需要注释掉这个输出才能解决这个错误。或者,在某些情况下,它们可以更新依赖,直接从公共包入口输出内部类型。
我们期待 TypeScript 能够对入口点问题提供更好的支持,这也就不需要使用这种替代方案了。
9. 声明文件中生成的类型会内联传递自依赖中的类型
软件包需要输出 .d.ts
声明文件给用户使用。我们选择用 TypeScript 的 declaration
选项依照原始 .ts
文件生成 .d.ts
文件。尽管也可以在编写代码的同时手写和维护 .d.ts
文件,但这种做法并不科学,因为时刻维护它们的一致性是件非常难做的事情。
TypeScript 在大多数情况下自动生成声明文件都没问题。我们发现的其中一个问题是有时 TypeScript 会将依赖中的类型内联传递给当前的类型。这就意味着相对于用 import
语句标识为引用,这种方式的类型定义被重定向了,并且存在潜在的重复定义。对于结构化的类型定义,编译器不会强制性验证被引用的类型是否和源定义一致 —— 因此重复的类型定义是不会报错的。
我们见过一些更极端的例子,由于这些重复的类型定义,声明文件的大小从 7KB 膨胀到了 700KB。这使得程序运行时需要下载和解析大量的冗余代码。
包内的内联类型定义不会造成系统性问题,因为它们对外是不可见的。但是当类型定义在不同包里使用了不同的版本时,问题就出现了。在我们这样不锁定包版本的系统中,各个包可以独立演化,这带来了类型兼容性的风险,特别是类型失效的风险。
通过实验,我们找到了一个能防止内联类型声明的潜在技术:
使用
interface
代替type
(interface 接口是不存在内联问题的)如果一个
interface
没有在声明中输出,tsc 不会去内联查询这个类型,而是抛出一个异常(例如:TS4023: Exported variable has or is using name from external module but cannot be named.
)。如果一个
type
没有在声明中输出,tsc 会在依赖中内联寻找这个类型的定义。Nicholas Jamieson 写了一篇文章来推荐使用接口来替代类型,以及相应的 Eslint 规则。
使用标明类型定义(像
enum
,class
这些有私有成员的标明类型是不会内联定义的)对输出添加类型注释
没有类型注释时,发生了内联引用
使用显示类型注释后,我们强制指定了引用的行为
这种内联行为似乎没有被严格的指出。这只是构造声明文件方式的副作用,因此上述方式有可能会在将来失效。希望这能在 TypeScript 中被正式化。在此之前,我们将依靠用户教育来降低这种风险。
10. 生成的声明文件有可能会包含不必要的依赖
TypeScript 声明文件的使用者通常只关心包的公有类型的API。TypeScript 声明生成器会对项目中的每个 TypeScript 文件只产生一个声明文件。其中一些内容可能与用户无关,并可能会暴露私有部分的实现细节。这种行为可能会让 TypeScript 新手感到惊讶,他们往往会期待类型定义应该像 Definitely Typed 那样,仅仅展示公有API。
这种情况的一个例子是:生成的声明文件中包含了仅仅用于内部测试方法的类型。
由于我们的包管理系统知晓所有的公共包入口,我们的工具可以在可访问类型图中爬取所有不需要公开的类型。这是 Dead Type Elimination(DTE),或者更准确的说,Tree-Shaking。我们编写了一个工具来做这件事 —— 它仅通过消除声明文件中的冗余来完成最小化代码的工作。它不会去重写或者重定向代码 —— 他不是一个打包器。也就是说,最终发布的声明文件是 TypeScript 自动生成的声明文件的子集。
软件包减少发布类型的体积有以下好处:
减少了与其他包的耦合性(一些包不会对依赖中的类型重新输出)
它通过防止完全私有类型的泄漏来帮助封装
在发布中减少了用户需要下载并解压的声明文件的数量和体积
减少了 TypeScript 编译器在类型检查时需要解析的代码量
"Shaking" 有时会效果极为显著。我们曾经遇到过一些包中超过 90% 文件中有超过 90% 的类型定义行是可以去掉的。
一些选项有严格的使用场景
我们发现一些 tsconfig 选项中的语义是令人惊讶的。
tsconfig
中强行使用 baseUrl
在 TypeScript 4.0 中。如果你希望使用项目引用或者某个“路径”,你就同时需要指定一个 baseUrl
。这样做的副作用就是会导致所有的修饰符导入相对路径时都会被补全为相对于根目录的路径形式。
// package-a/main.ts
import "sibling" // Will auto-complete and type-check if `package-a/sibling.js` exists
这样做的危险在于,如果你想引入任何形式的“路径”,它会带来一个额外的结果, import "sibling"
会被TypeScript 自动补全为
。
为了解决这个问题,我们使用了一个糟糕的 baseUrl
。使用 null
来防止不必要的自动补全。我们不建议你在家里尝试这样做。
我们在 TypeScript issue 上报告了这个问题,很高兴地看到 Andrew 已经在 TypeScript 4.1 解决了这个问题,这将使我们告别 null 字符!
JSON 模块导入没有默认开启
如果你希望使用 resloveJsonModules
,你就同时需要开启 useSyntheticDefaultImports
选项,从而使 TypeScript 识别导入 JSON 模块。在将来,使用导入的方式处理 JSON 模块,很可能成为 Node 和 Web 的标准方式。
启用 useSyntheticDefaultImports
会有一个不幸的结果,即允许导入没有默认输出的常规 ES 模块!这是一种风险,你只有在运行代码时才会发现,并且它一闪而过。
理想情况下,应该有一种不需要启用 useSyntheticDefaultImports
而能够导入JSON模块的方法。
非常好的部分
从工具化的角度来看,TypeScript 中展现出的一些特别好的东西是值得一提的。
增量构建成为基本功能。TypeScript 3.6 的 API 对增量构建提供支持对我们来说是一件有巨大推动作用的事,这使得自定义工具链可以进行快速重建。在我们报告了将 incremental
和 noEmitOnError
结合使用而产生的性能问题时,Sheetal 使它们在 TypeScript 4.0 中运行速度更快了。
"isolatedModules"
在确保我们可以执行快速的独立(一个进,一个出)置换时至关重要。TypeScript团队修复了一系列问题来改进这个选项,包括:
同时允许
emitDeclaration
和isolatedModules
同时允许
noEmitOnError
和isolatedModules
当启用
isolatedModules
时,类型必须显式输出
项目引用是提供无缝 IDE 体验的关键。我们利用它们极大地提升了基于多个包工作区的项目开发体验,使它变得像单个项目开发一样灵活。多亏了 Sheetal,它们现在更好了,并且支持不需要文件的 "Solution Style"的tsconfigs。
仅类型导入是非常有用的。我们在导出都会用到它们来安全地区分运行时导入还是编译时导入。它们在启用 "isolatedModules"
模式时必不可少,并且允许我们使用 "importsNotUsedAsValues":"error"
来获得最佳的安全性。感谢 Andrew 提交了这个功能!
"useDefineForClassFields"
对于确保我们发布的 ESNext 代码不会被重写,保持语言的JS + 类型特性非常重要。这使得我们可以原生的使用 Class 字段。感谢 Nathan 提供了这个功能,并尽可能顺利地进行了迁移。
TypeScript 中的新增特性有时会有惊喜。每当我们意识到我们需要一个特性时,我们经常发现它已经在下一个版本中交付了。
总结
最终,TypeScript 现在是我们应用平台的首选语言了。在需要将 TypeScript 与另一种运行时集成在一起时,语言和编译器表现的似乎和 JavaScript 一样灵活 —— 它们都可以在任何地方使用。
虽然一路上我们遇到了很多问题,但没有什么是不可逾越的。当我们需要支持时,我们为来自社区和 TypeScript 团队本身的响应感觉惊喜。使用共享开源技术的一个明显好处是,当您遇到问题时,您通常会发现您并不孤单。当你找到了答案,你就能从分享中得到乐趣。