大规模使用TypeScript的10个心得

作者:Rob Palmer,彭博社JavaScript基础设施和工具主管,TC39联席主席

几年前,彭博工程决定采用这种方法打印稿作为一种一流的支持语言。这篇文章分享了我们在这段旅程中学到的一些见解和教训。

标题是我们发现TypeScript是一个强大的净积极的!在阅读我们探索的一些令人惊讶的角落时,请记住这一点。作为工程师,我们很自然地被吸引去观察、解决和分享问题,即使我们玩得很开心

我们的代码库+ TypeScript。保险丝点燃了。

背景

在TypeScript出现之前,Bloomberg就已经在JavaScript上投入了巨大的资金——超过5000万行JS代码。我们的主要产品是彭博终端,它包含1万多个应用程序。应用程序种类繁多,从密集的实时金融数据和新闻显示到交互式交易解决方案和多种形式的消息传递。早在2005年,该公司就开始将这些应用程序从Fortran和C/ c++迁移到服务器端JavaScript, 2012年左右,客户端JavaScript出现了。今天,我们公司有2000多名软件工程师在编写JavaScript。

彭博终端

将如此大规模的代码库从普通JavaScript转换为TypeScript是一件大事。因此,我们努力确保有一个深思熟虑的过程,使我们与标准保持一致,并保持我们现有的能力,以快速、安全地开发和部署代码。

如果你曾经参与过一家大公司的技术迁移,你可能会习惯于高压的项目管理,迫使那些不愿开发新功能的团队取得进展。我们发现采用TypeScript是完全不同的。工程师们正在自我启动转换并支持这一过程!当我们推出测试版的TypeScript平台支持时,仅在第一年就有200多个项目选择使用TypeScript。零个项目回去了。

TypeScript的这种用法有什么特别之处?

除了规模之外,TypeScript集成的独特之处在于我们有自己的JavaScript运行时环境。这意味着,除了众所周知的JavaScript主机环境,如浏览器和Node,我们还直接嵌入V8引擎和Chromium来创建我们自己的JavaScript平台。这种情况的好处是,我们可以提供简单的开发体验,在这种体验中,我们的平台和包生态系统直接支持TypeScript。Ryan Dahl的Deno采用了类似的思路,将TypeScript编译放到运行时中,而我们将其保存在独立于运行时的工具中。一个有趣的结果是,我们可以在一个横跨客户端和服务器的独立JS环境中练习TypeScript编译器,并且不使用特定于node的约定(例如,没有node_modules目录)。

我们的平台支持使用通用工具和发布系统的包的内部生态系统。这允许我们鼓励和执行最佳实践,比如默认为TypeScript的“strict模式”,以及确保全局不变量。例如,我们保证所有发布的类型都是模块化的,而不是全局的。这也意味着工程师可以专注于编写代码,而不是弄清楚如何让TypeScript与绑定器或测试框架良好地运行。DevTools和错误堆栈正确使用sourcemaps。测试可以用TypeScript编写,代码覆盖率可以用原始的TypeScript代码准确地表达。它只是工作。

我们的目标是让常规的TypeScript文件成为api的唯一真实来源,而不是维护手写的声明文件。这意味着我们有很多代码严重依赖于TypeScript编译器的自动生成.d.tsTypeScript源代码中的声明文件。因此,当declaration-emit不理想时,我们会注意到它,正如您将看到的。

原则

让我们来概述我们正在努力争取的三个关键原则。

⚖️可扩展性:随着越来越多的包采用TypeScript,开发速度应该保持在较高水平。应该尽量减少安装、编译和检查代码的时间。

☮️生态系统的一致性:包应该一起工作。升级依赖项应该是免费的。

标准校准:我们希望坚持使用ECMAScript等标准,并为它们的下一步发展做好准备。

让我们惊讶的发现通常都归结于我们不确定是否能保留这些原则的情况。

10点学习


1.TypeScript可以是JavaScript + Types

多年来,TypeScript团队一直在积极追求标准ECMAScript语法和运行时语义的采用和对齐。这就让TypeScript专注于在JavaScript之上提供一层类型语法和类型检查语义。职责明确划分:TypeScript = JavaScript + Types!

这是一个很棒的模型。这意味着编译器输出是人类可读的JavaScript,就像程序员写的那样。这使得调试生产代码变得很容易,即使您没有原始源代码。这意味着你不需要担心选择TypeScript会让你失去未来ECMAScript特性。它为运行时,甚至未来的JavaScript引擎打开了一扇门,它们可以忽略类型语法,从而在本地“运行”TypeScript。一个更简单的开发体验就在眼前!

在此过程中,TypeScript扩展了少量的特性,这些特性不太适合这个模型。枚举名称空间参数属性,实验修饰符它们的语义都要求将它们扩展为运行时代码,而JavaScript引擎很可能永远不会直接支持这些代码。

标准对齐❔

这没什么大不了的。的打字稿设计目标阐明避免将来引入更多运行时功能的需要。TypeScript团队的一名成员,Orta他制作了一张表情包幻灯片来强调这一点。

TypeScript团队的一个成员,Orta,创建了一个表情包幻灯片来强调在未来避免引入更多运行时特性的必要性(按照TypeScript设计目标)。

我们的工具链通过阻止它们的使用来解决这组不受欢迎的特性。这确保了我们不断增长的TypeScript代码库是真正的JS + Types。

标准对齐✔️

2.跟上编译器是值得的

打印稿的发展迅速.该语言的新版本引入了新的类型级特性,增加了对JavaScript特性的支持,提高了性能和稳定性,并改进了类型检查器以发现更多类型错误。所以使用新版本有很大的诱惑力!

在TypeScript努力保持兼容性的同时,这些类型检查的改进代表了构建过程的重大变化,因为新的错误在之前看似没有错误的代码库中被识别出来。因此,升级TypeScript需要一些干预才能获得这些好处。

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

如果库升级了TypeScript并开始使用新语法生成现代声明文件,则如果使用该库的应用程序项目的TypeScript版本不理解该语法,则将无法编译TypeScript 3.7中getter/setter访问器的emit. TypeScript 3.5或更早版本不理解这些。这意味着使用不同编译器版本的项目生态系统并不理想。

☮️生态系统相干❔

在Bloomberg,代码库分布在使用通用工具的各种Git存储库中。尽管我们没有一个单一的注册表,但是我们有一个集中的TypeScript项目注册表。这让我们可以创建一个持续集成(CI)工作来“构建世界”,并在每个TypeScript项目上验证编译器升级的构建时和运行时效果。

这种全局检查功能非常强大。我们用它来评估TypeScript的Beta版和RC版,以便在常规版发布之前发现问题。拥有多样化的真实代码语料库意味着我们还能找到边缘情况。我们使用这个系统来指导在编译器升级之前对项目进行修复,这样升级本身就完美无缺了。到目前为止,这种策略效果很好,我们已经能够将整个代码库保持在最新版本的TypeScript上。这意味着我们不需要采用诸如底层的DTS文件

☮️生态系统相干✔️

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

提供了很大的灵活性tsconfig就是让TypeScript适应你的运行时平台。在所有项目都以相同的常青运行时为目标的环境中,对每个项目单独配置这一点是有风险的。

☮️生态系统相干❔

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

治疗的另一个好处tsconfig作为一个生成的文件,而不是作为一个源文件,它允许高级工具通过负责定义选项来灵活地链接多项目“工作区”,例如“引用”“路径”

这里有一些紧张,因为少数项目希望能够进行定制,例如切换到更宽松的模式以减少迁移负担。

最初,我们试图满足这些要求,并提供少量选择。后来我们发现,当使用一组选项构建的声明文件被使用不同选项的包使用时,这会导致包间冲突。这里有一个例子。

可以创建由值指示的条件类型“strictNullChecks”

类型A = unknown extends {} ?字符串:数量;

如果“strictNullChecks”,则A是A数量.如果“strictNullChecks”是禁用的,那么A是A一串。如果导出此类型的包未使用与导入此类型的包相同的严格性设置,则此代码将中断。

这是我们所面临的现实问题的一个简化例子。因此,我们选择放弃在严格模式上的灵活性,而倾向于为所有项目提供一致的配置。

☮️生态系统相干✔️

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

我们需要能够显式地向TypeScript声明依赖项的位置。这是因为我们的ES模块系统不依赖于通过遍历一系列名为node_modules

我们需要能够声明裸说明符的映射(例如,“lodash”)到磁盘上的目录位置(“c: \ \ lodash依赖”).这个问题类似于导入地图试图解决的Web。一开始,我们试着用“路径”选项tsconfig

//tsconfig.json“路径”: {“lodash”:【“. . / . . /附件/ lodash”

这对于几乎所有的用例都非常有效。然而,我们发现这降低了生成的声明文件的质量。TypeScript编译器必须将合成导入语句注入到声明文件中,以允许复合类型——复合类型可以依赖于来自其他模块的类型。当合成导入依赖项中的引用类型时,我们发现“路径”注入相对路径的方法(导入(“. . / . . /附件/ lodash”)),而不是保留裸说明符(进口“lodash”).对于我们的系统,外部包类型定义的相对位置是可能改变的实现细节,因此这是不可接受的。

☮️生态系统相干❔

我们找到的解决办法是使用环境模块

//环境温度模块声明模块“lodash”出口“. . / . . /附件/ lodash”出口违约“. . / . . /附件/ lodash”

环境模块是特殊的。TypeScript的声明emit保留对它们的引用,而不是将它们转换为相对路径。

☮️生态系统相干✔️

5.重复数据删除类型可能很重要

应用程序的性能是至关重要的,所以我们试图最小化应用程序在运行时加载的JS量。我们的平台确保在运行时只使用包的一个版本。这种版本的重复删除意味着给定的包不能“冻结”或“固定”它们的依赖项。因此,这意味着包必须随着时间的推移保持兼容性。

我们希望为类型提供相同的“完全相同”保证,以确保对于给定的项目编译,类型检查只考虑包依赖项的一个版本。除了提高编译时的效率,其动机还在于确保类型检查的世界更好地反映了运行时的世界。我们特别想要避免过时问题和“名义地狱”,即通过“菱形模式”导入两个不兼容的名义类型版本。随着生态系统采用名义类型的增加,这种危险可能会增加。

⚖️可伸缩性❔

☮️生态系统相干❔

我们编写了一个确定性解析器,它根据正在构建的包的声明版本约束,精确地选择每个依赖项的一个版本。

⚖️可伸缩性✔️

☮️生态系统相干✔️

这意味着类型依赖关系图是动态组装的——它不是冻结的。虽然这种无固定依赖的方法提供了好处,并避免了一些危险,但我们后来了解到,由于TypeScript编译器中的微妙行为,它可能会引入另一种危险。看到9.生成的声明可以从依赖项内联类型了解更多。

这些权衡和选择并不特定于我们的平台。它们同样适用于发布到DefinitelyTyped/npm的任何人,并由package.json中表示的每个包的版本约束的聚合效果决定“依赖”

6.应该避免隐式类型依赖关系

在TypeScript中引入全局类型很容易。更容易依赖全局类型。如果不选中,这意味着远程包之间可能发生隐藏耦合。TypeScript手册将其称为being“有点危险。”

⚖️可伸缩性❔

☮️生态系统相干❔

//注入全局类型的声明声明全球{接口串{fancyFormat(选择?:StringFormatOptions): string;//在文件的某个地方,很远很远。。。String.fancyFormat ();/ /没有错误!

这个问题的解决方案是众所周知的:比起全局状态,更喜欢显式依赖。TypeScript支持ECMAScript进口出口声明了很长一段时间,实现了这一目标。

因此,唯一剩下的需要是防止意外创建全局类型。幸运的是,可以静态地检测TypeScript允许引入全局类型的每种情况。因此,我们能够更新我们的工具链,以便在使用这些工具的情况下检测错误。这意味着我们可以安全地依赖这样一个事实:导入包的类型是一个没有副作用的操作。

⚖️可伸缩性✔️

☮️生态系统相干✔️

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

并非所有的声明文件都是相同的。声明文件操作于三种模式之一,视乎内容而定;特别是进口出口关键词。

  1. 全球-没有使用的声明文件进口出口将被视为全球的.顶级声明是全局导出的。
  2. 模块- - - - - -至少包含一个的声明文件出口声明将被视为一个模块。只有出口导出声明并且没有定义全局变量。
  3. 隐含的出口,一个声明文件,它没有出口声明,但使用进口将触发已定义但未记录的行为。这意味着顶级声明被视为命名声明出口没有定义声明和全局变量。

我们不使用第一种模式。我们的工具链阻止全局声明文件(参见上一节:6.应该避免隐式类型依赖关系)。这意味着所有声明文件都使用ES模块语法。

⚖️可伸缩性✔️

生态系统相干✔️

标准对齐✔️

也许令人惊讶的是,我们发现有点怪异的第三种模式非常有用。只需在环境声明文件的顶部添加一行自导入,就可以防止它们污染全局命名空间:Import {} from "./";.这一行代码使得转换第三方声明变得很简单,例如lib.dom.d.ts,是模块化的,避免了维护一个更复杂的分叉的需要。

打字组不喜欢第三种模式吗因此,在可能的情况下考虑避免它。

8.包的封装可以被违反

如前所述(在5.重复数据删除类型可能很重要),我们使用未固定的依赖项意味着我们的包不仅要保持运行时的兼容性,而且要随着时间的推移保持类型的兼容性。这是一个挑战,所以要使这种兼容性保护切实可行,我们必须真正了解哪些类型是公开的,并且必须以这种方式进行约束。第一步是明确区分公共模块和私有模块。

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

今天,TypeScript还不知道包装出口因此没有一个概念,即依赖关系中的哪些文件被认为是公共的,哪些不是。当TypeScript合成import语句到所发出的传递类型时,这就成了一个问题.d.ts文件。这对我们来说是不能接受的.d.ts文件引用其他包中的私有文件。这是一个出错的例子。

/ / index.ts进口boxMaker“另一个包”出口常数盒= boxMaker ();

以上的来源可以导致tsc发出下列不想要的声明。

/ / index.d.ts出口常数箱:进口(“另一个包/私人”).盒子

这很糟糕,因为“另一个包/私人”不是该包的兼容性承诺的一部分,所以可能会被移动或重命名,而不会有SemVer的重大颠簸。今天的TypeScript无法知道它生成了一个脆弱的导入。

☮️生态系统相干❔

我们通过两个步骤来缓解这个问题:

1.我们的工具链告诉TypeScript解析器指向依赖项(例如,“lodash / public1”“lodash / public2”).我们通过在TypeScript文件流进编译器之前,在文件底部默默地添加只类型导入语句,来确保TypeScript知道所有合法的依赖入口点。

//用户的源代码//工具链注入,以辅助声明发出进口类型作为__fake_name_1“lodash / public1”进口类型作为__fake_name_2“lodash / public2”

当生成对推断传递类型的引用时,TypeScript的声明emit会更喜欢使用这些现有的命名空间标识符,而不是合成对私有文件的导入。

2.如果TypeScript生成了一个我们知道是私有的依赖项中的文件路径,我们的工具链就会产生错误。这类似于现有的TypeScript错误,当TypeScript意识到它正在生成一个潜在的危险路径到某个依赖时,就会发出错误。

错误TS2742的推断类型...如果没有对的引用,则无法命名...这可能是不可移植的类型注释是必要的

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

☮️生态系统相干✔️

我们期待TypeScript获得对入口点的一流支持,这样就没有必要采取这样的解决方案了。

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

包裹需要出口.d.ts声明,以便用户可以使用它们。我们选择使用TypeScript宣言选项来生成.d.ts原始文件.ts文件。虽然它可以手写和维护.d.ts兄弟文件与常规代码放在一起是不太可取的,因为保持它们同步是一种危险。

TypeScript的声明emit大多数时候都工作得很好。我们发现的一个问题是有时候TypeScript会将依赖项中的类型内联到生成的类型中(#37151).这意味着类型定义被重新定位并可能被复制,而不是通过导入语句引用。使用结构类型,编译器不必确保类型是从一个定义站点引用的——复制这些类型也可以。

我们已经看到极端情况下这种复制将声明文件从7KB扩展到700KB。需要下载和解析的冗余代码相当多。

⚖️可伸缩性❔

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

☮️生态系统相干❔

通过实验,我们发现了防止类型声明内联的潜在技术,比如:

⚖️可伸缩性

☮️生态系统一致性

内联行为似乎没有严格规定。这是声明文件构造方式的副作用。因此,上述方法在未来可能不起作用。我们希望这是可以用TypeScript形式化的东西。在此之前,我们将依靠用户教育来降低这一风险。

10.生成的声明可以包含非必要的依赖项

TypeScript声明文件的消费者通常只关心包的公共类型API。TypeScript declaration emit会为项目中的每个TypeScript文件生成一个声明文件。其中一些内容可能与用户无关,并可能公开私有的实现细节。这种行为可能会让TypeScript的新来者感到惊讶,他们希望类型化是公共API的一种表示,就像手写类型化一样绝对类型

这方面的一个例子是生成的声明,包括仅用于内部测试的函数的类型

⚖️可伸缩性❔

由于我们的包系统知道所有公共包入口点,我们的工具可以爬行可到达类型的图,以识别所有不需要公开的类型。这就是死亡类型消除(DTE),或者更准确地说,摇树。我们为此编写了一个工具——它执行的工作最少只有消除声明文件中的代码。它不重写或重定位代码——它不是一个捆绑器。这意味着发布的声明是typescript生成的声明的一个未更改的子集。

减少出版类型的数量有几个优点:

  • 它减少了与其他包的耦合(有些包不从它们的依赖项中重新导出类型)
  • 它通过防止完全私有类型泄漏来帮助封装
  • 它减少了需要用户下载和解压缩的已发布声明文件的数量和大小
  • 它减少了类型检查时TypeScript编译器必须解析的代码量

“摇晃”可以产生戏剧性的效果。我们已经看到一些包,其中>90%的文件和>90%的类型行都可以删除。

⚖️可伸缩性✔️

有些选项有锋利的边缘

我们在一些tsconfig选项。

授权baseUrltsconfig

4.0在打印稿。如果您想使用项目引用或“路径”,则需要指定abaseUrl.这有一个副作用,就是导致所有裸说明符导入都要相对于项目的根目录进行解析。

//包-a/main.ts进口“兄弟”//将自动完成并键入检查“package-a/sibling.js”是否存在

危险在于,如果你想引入任何形式的“路径”,它带有额外的含义进口“兄弟姐妹”会被TypeScript错误地解释为导入/sibling.js从源目录中。

标准对齐❔

为了解决这个问题,我们使用了一种无法形容的方法baseUrl.使用空字符可以防止不必要的裸自动补全。我们不建议你在家尝试。

我们报道这个在TypeScript问题跟踪器上看到这一点我很激动Andrew已经为TypeScript 4.1解决了这个问题,这将使我们能够对空字符说再见!

标准对齐✔️

JSON模块意味着合成默认导入

如果你想用“resolveJsonModules”,您也需要启用“使用默认导入”以便TypeScript将JSON模块视为默认导入。使用默认导入很可能成为Node和Web将来处理JSON模块的方式。

启用“使用默认导入”有一个不幸的结果,即人为地允许从没有默认导出的常规ES模块中默认导入!这是一个只有在运行代码时才会发现的危险,它很快就会崩溃。

标准对齐❔

理想情况下,应该有一种导入JSON模块的方法,该方法不涉及全局启用合成默认值。

伟大的地方

从工具的角度来看,我们从TypeScript中看到的一些特别好的东西值得一提。

增量构建已经必不可少。对增量构建的API支持是对我们在TypeScript 3.6中的一个巨大的推动,它允许自定义工具链进行快速重构。在我们报道性能问题当结合增量的noEmitOnErrorSheetal让他们在TypeScript 4.0中甚至更快。

⚖️可伸缩性✔️

“isolatedModules”这对于确保我们能够执行快速独立(一进一出)传输至关重要。TypeScript团队解决了一系列问题以改进此选项,包括:

⚖️可伸缩性✔️

☮️生态系统相干✔️

项目引用是提供无缝IDE体验的关键。我们充分利用它们,使基于多包工作区的开发与单个项目开发一样流畅。多亏了Sheetal在美国,它们现在甚至更好,支持无文件“解决方案样式”t配置。

⚖️可伸缩性✔️

类型只进口它们非常有用。我们在任何地方都使用它们来安全地区分运行时导入和编译时导入。它们对于使用“isolatedModules”允许我们使用“ImportsNotUsedAsValue”:“错误”最大的安全。多亏了安得烈提供这个!

☮️生态系统相干✔️

标准对齐✔️

“useDefineForClassFields”这对于确保我们发出的ESNext代码不会被重写非常重要,保留了语言的JS+类型特性。这意味着我们可以本机使用类字段。多亏了内森提供此功能并使迁移过程尽可能顺利。

标准对齐✔️

TypeScript中的特性交付是非常偶然的。每次当我们意识到我们需要一个功能时,我们经常会发现它已经在下一个版本中交付了。

结论

最终的结果是,TypeScript现在是我们应用平台的一流语言。把TypeScript和另一个运行时集成在一起,这表明语言和编译器似乎和JavaScript一样灵活——它们几乎可以在任何地方使用。

一个JavaScript盒爆炸显示里面的TypeScript。

虽然我们必须在这一过程中学到很多东西,但没有什么是不可逾越的。当我们需要支持时,我们对社区和TypeScript团队自己的反应感到惊喜。使用共享开源技术的一个明显好处是,当您遇到问题时,往往会发现您并不孤单。当你找到答案时,你会得到分享的快乐。金宝搏网址

致谢

非常感谢Thomas Chetwin, Robin Ricard, Kubilay Kahveci, Scott Whittaker, Daniel Rosenwasser, Nathan Shively-Sanders, Titian Dragomir和Maxwell Heiber的评论。感谢Orta的双斜杠代码格式。

香蕉和盒子的图形是由Max Duluc创建的。