大规模采用打字稿的感悟

2020-11-12 02:32:15

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

标题是,我们发现打字稿是一个很好的净值!在阅读我们探索的一些令人惊讶的角落时,请记住这一点。作为工程师,我们自然会被发现、解决和分享问题所吸引,即使我们在😉上玩得很开心。

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

将这种规模的代码库从普通的JavaScript转换到打字脚本是一件大事。因此,我们努力确保有一个深思熟虑的过程,使我们与标准保持一致,并保持我们现有的能力,以便快速安全地发展和部署我们的代码。

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

除了可伸缩性之外,这种TypeScrip集成的独特之处在于我们拥有自己的JavaScript运行时环境。这意味着,除了众所周知的JavaScript主机环境(如浏览器和Node)之外,我们还直接嵌入了V8引擎和Chromium来创建我们自己的JavaScript平台。这种情况的好处是,我们可以提供一种简单的开发体验,在这种体验中,我们的平台和包生态系统直接支持TypeScript。Ryan Dahl的Deno追求类似的理念,将打字脚本编译放入运行时,而我们将其放在独立于运行时版本的工具中。一个有趣的结果是,我们可以探索在跨越客户端和服务器且不使用特定于节点的约定(例如,没有NODE_MODULES目录)的独立JS环境中使用类型脚本编译器是什么感觉。

我们的平台支持使用通用工具和发布系统的包的内部生态系统。这允许我们鼓励和实施最佳实践,例如默认使用TypeScrip的“严格模式”,以及确保全局不变量。例如,我们保证所有发布的类型都是模块化的,而不是全局的。这也意味着工程师可以专注于编写代码,而不是需要想办法让打字脚本很好地与捆绑器或测试框架配合使用。DevTool和错误堆栈正确使用源地图。测试可以用打字脚本编写,代码覆盖率可以用原始打字代码准确地表达出来。只是起作用了。

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

⚖️可伸缩性:随着越来越多的包采用TypeScrip,应该保持较高的开发速度。安装、编译和检查代码所花费的时间应该最小化。

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

令我们惊讶的发现通常归结为我们不确定是否能保留这些原则。

多年来,打字团队一直在积极采用标准ECMAScript语法和运行时语义,并与之保持一致。这使得TypeScript专注于在JavaScript之上提供类型语法和类型检查语义层。职责是明确分开的:类型脚本=JavaScript+类型!

这是一个很棒的模型。这意味着编译器的输出是人类可读的JavaScript,就像程序员写的那样。这使得调试产品代码变得容易,即使您没有原始源代码。这意味着您不必担心选择TypeScrip可能会切断您与未来ECMAScript功能的联系。它为运行时(甚至未来的JavaScript引擎)敞开了大门,这些引擎可以忽略类型语法,从而在本地“运行”类型脚本。一种更简单的开发体验即将到来!

在此过程中,TypeScript扩展了一些不太适合这种模式的功能。枚举、命名空间、参数属性和实验性修饰符都具有需要扩展为运行时代码的语义,而这些代码很可能永远不会被JavaScript引擎直接支持。

这没什么大不了的。Tyescript Design目标阐明了避免在未来引入更多运行时功能的必要性。打字团队的一名成员奥尔塔(Orta)制作了一张迷因幻灯片来强调这种认可。

我们的工具链通过阻止使用这些不受欢迎的功能来解决这些问题。这确保了我们不断增长的打字代码库是真正的JS+类型。

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

虽然TypeScript努力保持兼容性,但这些类型检查改进代表着构建过程的破坏性更改,因为在以前看起来没有错误的代码库中发现了新的错误。因此,升级打字稿需要一些干预才能获得这些好处。

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

如果库升级了TypeScript并开始生成带有新语法的现代声明文件,则使用该库的应用程序项目将无法编译(如果它们的TypeScript版本不理解该语法)。新声明语法的一个例子是TypeScript3.7中的getter/setter访问器的发出。这些是TypeScript3.5或更早版本无法理解的。这意味着拥有一个使用不同编译器版本的项目生态系统并不理想。

在彭博社,代码库分布在使用通用工具的各种Git库中。尽管没有Monorepo,但我们有一个集中的打字项目注册表。这使我们能够创建一个持续集成(CI)作业来“构建世界”,并验证每个TypeScript项目上的编译器升级的构建时和运行时效果。

这种全球检查非常强大。我们使用它来评估TypeScript的Beta和RC版本,以便在正式发布之前发现问题。拥有不同的真实代码语料库意味着我们还可以找到边缘案例。在编译器升级之前,我们使用这个系统来指导对项目的修补,因此升级本身是完美无缺的。到目前为止,这一策略运行良好,我们能够将整个代码库保存在最新版本的Tyescript上。这意味着我们不需要采用诸如降级DTS文件之类的缓解措施。

Tsconfig提供的很大一部分灵活性是允许您根据运行时平台调整类型脚本。在所有项目都以相同的Evergreen运行时为目标的环境中,事实证明,每个项目单独配置它是一件危险的事情。

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

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

这里存在一些矛盾,因为少数项目希望能够进行定制,比如切换到更宽松的模式以减轻迁移负担。

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

可以创建由";strictNullChecks";的值指示的条件类型。

如果启用了严格NullChecks,则A是一个数字。如果禁用了";strictNullChecks,则A是一个字符串。*如果导出此类型的包使用的是与导入它的包不同的严格设置,则此代码将中断。

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

我们需要能够显式声明我们对TypeScript的依赖关系的位置。这是因为我们的ES模块系统不依赖于Node文件系统约定,即通过遍历一系列名为NODE_MODULES的目录来查找依赖项。

我们需要能够声明将裸说明符(例如,lodash)映射到磁盘上的目录位置(";c:\Dependency\lowash";)。这类似于导入地图试图为Web解决的问题。起初,我们尝试使用tsconfig中的路径选项。

这对几乎所有用例都很有效。然而,我们发现这降低了生成的声明文件的质量。类型脚本编译器必须将合成的导入语句注入声明文件中,以支持复合类型-其中类型可以依赖于来自其他模块的类型。当合成导入引用依赖项中的类型时,我们发现路径方法注入了相对路径(导入(";../../Dependations/lodash";),而不是保留裸说明符(import";lodash";)。对于我们的系统来说,外部包类型的相对位置是一个可能会改变的实现细节,所以这是不可接受的。

//环境-模块.d.ts 声明模块";loash";{ 从";../../Dependations/Lotash";;导出* 从";../../Dependents/lowash";;中导出默认值 }。

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

应用程序的性能非常关键,因此我们尝试将应用程序在运行时加载的JS数量降至最低。我们的平台确保在运行时只使用包的一个版本。版本的这种去重复意味着给定的包不能“冻结”或“固定”它们的依赖关系。因此,这意味着包必须随时间保持兼容性。

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

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

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

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

在Tyescript中引入全局类型很容易。依赖全局类型甚至更容易。如果不选中,这意味着可能会在远程包之间发生隐藏耦合。《打字手册》(Tyescript Handbook)称这种做法“有些危险”。

//注入全局类型的声明 声明全局{ 接口字符串{ FancyFormat(opts?:StringFormatOptions):字符串; } } //在一个很远很远的文件里的某个地方... String.fancyFormat();//没有错误!

这个问题的解决方案是众所周知的:比起全局状态,更喜欢显式依赖关系。长期以来,TypeScript一直支持ECMAScript IMPORT和EXPORT语句,从而实现了这一目标。

因此,剩下的唯一需要是防止意外创建全局类型。值得庆幸的是,可以静态地检测TypeScrip允许引入全局类型的每一种情况。因此,我们能够更新我们的工具链,以便在使用这些工具的情况下检测并出错。这意味着我们可以放心地相信,导入包的类型是一种无副作用的操作。

并不是所有的声明文件都是平等的。声明文件以三种模式之一运行,具体取决于内容,特别是导入和导出关键字的使用。

全局-未使用导入或导出的声明文件将被视为全局文件。顶级声明将被全局导出。

模块-至少包含一个导出声明的声明文件将被视为模块。只导出导出声明,没有定义全局变量。

隐式导出-没有导出声明但使用导入的声明文件将触发已定义但未记录的行为。这意味着顶级声明被视为命名的导出声明,并且没有定义全局变量。

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

或许令人惊讶的是,我们发现略显诡异的第三模式很有用。只需在环境声明文件的顶部添加一行自导入,就可以防止它们污染全局命名空间:Import{}from";./<;my-own-name>;";;。这个一行代码使得将第三方声明(如lib.dom.d.ts)转换为模块化变得简单,并避免了维护更复杂的分支的需要。

打字团队似乎不喜欢第三种模式,因此请考虑在可能的情况下避免使用第三种模式。

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

Node最近以Package.json;Exports字段的形式获得了这一功能。这通过显式列出可从包外部访问的文件来定义封装边界。

现在,TypeScript不知道包导出,因此不知道依赖项中的哪些文件被认为是公共的。在声明生成期间,当TypeScript将导入语句合成为发出的.d.ts文件中的可传递类型时,这会成为一个问题。我们的.d.ts文件引用其他包中的私有文件是不可接受的。这里有一个出错的例子。

这很糟糕,因为";Another-Package/Private";不是该程序包兼容性承诺的一部分,因此可能会被移动或重命名,而不会出现SemVer大故障。今天的打字稿无法知道它生成了一个脆弱的导入。

1.我们的工具链将指向依赖项的有意公开的裸说明符路径通知给文本解析器(例如,";loash/Public 1&34;,";loash/Public 2&34;)。我们通过在类型脚本文件流入编译器之前将仅类型的导入语句静默地添加到类型脚本文件的底部来确保类型脚本知道全部合法的依赖项入口点。

//用户源代码 //工具链注入辅助声明发出 从";lowash/Public 1";导入类型*AS__FAKE_NAME_1; 从";loash/Public 2";导入类型*AS__FAKE_NAME_2;

在生成对推断的可传递类型的引用时,Tyescript的声明发出将更喜欢使用这些现有的名称空间标识符,而不是将导入合成到私有文件。

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

错误TS2742:在没有引用';...';的情况下,无法命名推断的';...';类型。 这可能不是便携的。类型批注是必需的。

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

我们期待着Tyescript获得入口点的一流支持,这样就不需要像这样的变通方法了。

包需要导出.d.ts声明,以便用户可以使用它们。我们选择使用类型脚本声明选项从原始的.ts文件生成.d.ts文件。虽然手写和维护.d.ts同级文件和常规代码是可能的,但这样做不太可取,因为保持它们同步是一种危险。

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

我们已经看到了极端的情况,这种重复使声明文件从7KB膨胀到700KB。这是相当多的冗余代码,需要下载和解析。

包中类型的内联不是生态系统问题,因为它在外部不可见。当类型跨包边界内联时就会出现问题,因为它会将这两个特定版本耦合在一起。在我们的非固定包裹系统中,包裹可以。

.