大家好,我是 ConardLi
,现在一些大型的项目从 JS
迁移到 TS
已经成了一种趋势,最近又有一个大型的系统完成了 JS 到 TS
的迁移,在迁移完成后他们分享了一些很有用的经验,我们一起来看看吧。
文章的英文原文在:https://codeascraft.com/2021/11/08/etsys-journey-to-typescript/
Etsy
是美国的一个大型的电商平台,这个公司已经创建超过 16 年了,他们的代码仓库变得越来越大,在多次频繁的网站迭代中,甚至单独一个代码库已经拥有了超过一万七千个 JavaScript
文件。
在过去的几年里,Etsy
的 Web
平台团队花了很多的时间来重构更新前端代码。对于我们的开发人员来说,可能已经很难知道哪些部分是最佳实践,哪些部分是技术债。
JavaScript
语言本身让这类问题变得更加复杂 — 尽管在过去几年中它增加了很多新的语法特性,但 JavaScript
本身非常灵活,并且对其使用方式几乎没有什么强限制。这使得在没有研究使用的任何依赖项的实现细节的情况下编写 JavaScript
变得非常具有挑战性。虽然文档可以在一定程度上缓解这个问题,但它只能在很大程度上防止 JavaScript
库被滥用,从而最终导致不可靠的代码。
上面所有的问题都是我们认为 TypeScript
可以为我们解决的问题。TypeScript
将自己称为 Javascript 的超集
。换句话说,TypeScript
拥有 Javascript
中的一切,并且可以选择添加类型。在编码的时候,类型基本上就是声明代码使用数据的方式:函数可以接收什么样的输入,变量可以保存什么样的值。
TypeScript
可以让你轻松的在现有的 Javascript
项目中逐步迁移,尤其是在一些大型的代码库中。它非常擅长从你已经编写的代码中推断类型,并且它的类型语法足够细致,可以正确描述 Javascript
中一些常见小问题。此外,它是由微软开发的,已经在 Slack
和 AirBnB
等很多大型公司中使用,根据去年的 JS 状态调查报告
,它是迄今为止最常用和最受欢迎的 Javascript
风格。如果我们要使用类型来为我们的代码库带来一些良好的规范,TypeScript
是一个非常可靠的选择。
这篇文章介绍了我们如何设计我们的方法,迁移过程中产生的的一些有趣的技术挑战,以及在 Etsy
这样的规模的公司中引入新的编程语言需要注意什么。
迁移策略
TypeScript
可以非常自由的检查代码库中的类型。根据 TypeScript 手册
里所说的,更严格的 TypeScript
配置可以更好地保证程序的正确性。根据 TS 的设计,你可以根据你项目的需要逐步渐进式的采用 TypeScript
的语法及其严格性。这个功能就让将 TypeScript
添加到各种代码库中成为可能,但它同时也带来了一些新的问题,比如许多文件需要自己写一些类型声明,以便 TypeScript
完全理解它们。还有很多 Javascript
文件可以通过直接将它们的扩展名从 .js
更改为 .ts
来转换为有效的 TypeScript
。然而,即使 TypeScript
能够很好地理解文件,还是需要你去添加更详细的类型,这可以提高其对其他开发人员的可维护性。
各种规模的公司有很多关于迁移到 TypeScript
的方法的文章,所有这些文章都为不同的迁移策略提供了很有力的论据。例如,AirBnB
采用了非常自动化的迁移:
https://medium.com/airbnb-engineering/ts-migrate-a-tool-for-migrating-to-typescript-at-scale-cd23bfeb5cc
其他公司在他们的项目中启用了不太严格的 TypeScript,随着时间的推移向代码添加类型。
在开始 Etsy
的迁移之前,我首先要回答下面几个问题:
- 我们希望 TypeScript 的风格有多严格?
- 我们要迁移多少代码库?
- 我们希望我们编写的类型有多具体?
我们认为严格
是最优先的事项,采用一种新的语言需要付出很多努力,如果我们正在使用 TypeScript
,我们不妨尽可能的充分利用它的类型系统(此外,TypeScript
的检查器在处理更严格的类型时性能表现也会更好)。我们也知道 Etsy
的代码库非常大,迁移所有的文件可能要花费我们大量的时间,但确保我们为我们网站新的代码以及经常更新的部分提供类型是很重要的。当然,我们也希望我们的类型尽可能有用且易于使用。
所以我们采用下面的策略:
- 使
TypeScript
尽可能严格,并逐个迁移代码库的文件。 - 为开发人员经常会用到的所有实用程序、组件和工具添加非常好的类型和非常全面的支持文档。
- 花时间教授工程师有关
TypeScript
的知识,并逐个团队启用TypeScript
语法。
逐渐迁移到严格的 TypeScript
严格的 TypeScript
可以防止很多非常常见的错误,所以我们认为尽可能严格是最有意义的。这个决定的缺点是我们现有的大多数 Javascript
都需要提供类型声明。我们还需要逐个文件的去迁移我们的代码库。如果我们尝试使用一次将所有内容迁移到 严格的 TypeScript
,我们会遇到大量待解决的问题。
正如我之前提到的,我们的 monorepo
中有超过一万七千个 Javascript
文件,其中也有很多是不经常更改或者停止维护的。我们选择将精力集中在现在频繁迭代的区域上,清楚地划分出哪些文件需要编写可靠的类型,哪些文件没有分别使用 .js
和 .ts
文件扩展名。
确保工具库有良好的 TypeScript 支持
在我们的工程师开始为项目编写 TypeScript
之前,我们希望我们所有使用到的工具库都支持 TypeScript
,并且所有的核心库都具有高可用、定义良好的类型。在 TypeScript
文件中使用没有类型的依赖会使代码难以使用并且可能会引入类型错误;虽然 TypeScript
会尽可能的去推断非 TypeScript
文件中的类型,但如果推断不了的话,默认会使用 any
。
逐个团队培训 TypeScript 知识
我们在培训 TypeScript
知识上花了很多时间,这是我们在迁移过程中做出的最好的决定。Etsy
有数百名工程师,其中很少有人在迁移之前就拥有 TypeScript
经验(包括我自己)。我们意识到,如果我们的项目想要迁移成功,人们必须首先要学习如何使用 TypeScript
。我们在公司内部进行逐个团队的培训,这样我们可以努力改进我们的工具和培训材料。
技术细节(一些有趣的东西)
迁移过程中我们遇到了很多有趣的技术挑战。令人惊讶的是,迁移 TypeScript
最简单的部分是在我们的构建过程中添加对它的支持。我就不详细介绍这方面的内容了,因为每个项目的构建系统都有不同的风格,但简而言之:
- 我们使用
Webpack
来构建我们的Javascript
代码。Webpack
使用Babel
将我们的现代Javascript
语言转换为更旧的、更兼容的 Javascript。 Babel
有一个可爱的插件,叫做babel-preset-typescript
,可以快速将TypeScript
转换为Javascript
。- 为了检查我们的类型,我们将
TypeScript
编译器作为测试套件的一部分运行,并使用它的noEmit
选项将其配置为不实际转译任何文件。
以上所有过程大概花了一两个星期,其中大部分时间都花在验证我们发布到生产环境的 TypeScript
是否有奇怪的表现。另外我们围绕 TypeScript
的其余工具花费了更多的时间,结果证明更有趣。
使用 typescript-eslint
在 Etsy
,我们大量使用自定义 ESLint linting
规则。他们为我们捕捉代码中的各种不良写法。如果一些风格很重要,我们会尝试为它编写一个 lint
规则。我们发现 linting
的一个地方是强制类型的特异性,我通常用它来表示“类型与它所描述的事物的匹配程度”。
例如,假设一个函数接受一个 HTML
标签的名称并返回一个 HTML
元素。该函数可以接受任何旧字符串作为参数,但如果它使用该字符串来创建一个元素,那么最好确保该字符串实际上是一个真正的 HTML
元素的名称。
// This function type-checks, but I could pass in literally any string in as an argument.
function makeElement(tagName: string): HTMLElement {
return document.createElement(tagName);
}
// This throws a DOMException at runtime
makeElement("literally anything at all");
但如果我们可以付出一点努力使我们的类型更加具体,其他开发人员将更容易正确使用我们的功能。
代码语言:javascript复制// This function makes sure that I pass in a valid HTML tag name as an argument.
// It makes sure that ‘tagName’ is one of the keys in
// HTMLElementTagNameMap, a built-in type where the keys are tag names
// and the values are the types of elements.
function makeElement(tagName: keyof HTMLElementTagNameMap): HTMLElement {
return document.createElement(tagName);
}
// This is now a type error.
makeElement("literally anything at all");
// But this isn't. Excellent!
makeElement("canvas");
迁移到 TypeScript
意味着我们有很多新的实践需要考虑和检测。typescript-eslint
项目为我们提供了一些特定于 typescript
的规则。例如,ban-types
规则允许我们警告不要使用泛型 Element
类型而使用更具体的 HTMLElement
类型。
我们还做了一个(有点争议的)决定,不允许在代码库中使用非空断言和类型断言。前者允许开发人员在 TypeScript
认为某个东西可能为空时告诉它不为空,而后者允许开发人员将某个东西作为他们选择的任何类型来对待。
// This is a constant that might be ‘null’.
const maybeHello = Math.random() > 0.5 ? "hello" : null;
// The `!` below is a non-null assertion.
// This code type-checks, but fails at runtime.
const yellingHello = maybeHello!.toUpperCase()
// This is a type assertion.
const x = {} as { foo: number };
// This code type-checks, but fails at runtime.
x.fo
这两种语法特性都允许开发者覆盖 TypeScript
对某个变量类型的理解。在许多情况下,它们都暗示了可能需要修复的类型的更深层次的问题。通过消除它们,我们强迫我们的类型能更具体地描述他们所描述的内容。例如,你可能可以使用“as”将一个 Element
转换为一个 HTMLElement
,但你可能想首先使用一个 HTMLElement
。TypeScript
本身没有办法禁用这些语言特性,但 linting
允许我们识并禁用它们。
作为一种工具, linting
确实很有用,可以阻止人们使用糟糕的编码模式,但这并不意味着每个被规则命中的代码都是糟糕的,凡事都有例外。linting
的好处是它提供了一个合理的忽略方式。如果我们真的真的需要使用" as ",我们可以添加一个一次性的忽略注释。
// NOTE: I promise there is a very good reason for us to use `as` here.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const x = {} as { foo: number }
向我们的 API 添加类型
我们希望开发人员能够编写有效的 TypeScript
代码,所以我们需要确保尽可能多地为开发环境提供类型。乍一看,这意味着向我们的可重用设计组件、工具库和其他通用代码添加类型。但理想情况下,开发人员可能需要访问的任何数据都应该有自己的类型。我们网站上几乎所有的数据都会通过 Etsy API
,所以如果我们能在那里提供类型,我们就能很快覆盖我们的代码库。
Etsy
的 API
是用 PHP
实现的,我们为每个端点生成 PHP
和 Javascript
配置,以帮助简化发出请求的过程。在 Javascript
中,我们使用一个名为 EtsyFetch
的简单包装器来帮助实现这些请求,看起来就像下面这样:
// This function is generated automatically.
function getListingsForShop(shopId, optionalParams = {}) {
return {
url: `apiv3/Shop/${shopId}/getLitings`,
optionalParams,
};
}
// This is our fetch() wrapper, albeit very simplified.
function EtsyFetch(config) {
const init = configToFetchInit(config);
return fetch(config.url, init);
}
// Here's what a request might look like (ignoring any API error handling).
const shopId = 8675309;
EtsyFetch(getListingsForShop(shopId))
.then((response) => response.json())
.then((data) => {
alert(data.listings.map(({ id }) => id));
});
这种模式在我们的代码库中是很常见的。如果我们不为 API
的响应生成类型,开发人员将不得不手工编写它们,并希望它们与实际的 API
保持同步。我们想要严格的类型,但我们也不希望我们的开发人员额外浪费很多力气去获得它们。
我们最终利用了我们自己的开发人员 API
的一些工作,将我们的端点转换为 OpenAPI
规范。OpenAPI
规范是用 JSON
之类的格式描述 API
的标准化方法。当我们的开发者 API
使用这些规范来生成面向公众的文档时,我们也可以利用它们来为 API
的响应生成 TypeScript
类型。我们花了很多时间来实现一个可以跨所有内部 API
工作的 OpenAPI
规范生成器,然后使用一个名为 OpenAPI - TypeScript
的库将这些规范转换为 TypeScript
类型。
一旦我们为所有的端点生成了 TypeScript
类型,我们仍然需要以一种可用的方式将它们放入代码库中。我们决定将生成的响应类型编织到生成的配置中,然后更新 EtsyFetch
,在它返回的 Promise
中使用这些类型。把所有这些放在一起大致是这样的:
// These types are globally available:
interface EtsyConfig<JSONType> {
url: string;
}
interface TypedResponse<JSONType> extends Response {
json(): Promise<JSONType>;
}
// This is roughly what a generated API config file looks like:
import OASGeneratedTypes from "api/oasGeneratedTypes";
type JSONResponseType = OASGeneratedTypes["getListingsForShop"];
function getListingsForShop(shopId): EtsyConfig<JSONResponseType> { return {
url: `apiv3/Shop/${shopId}/getListings`,
};
// This is (looooosely) what EtsyFetch looks like:
function EtsyFetch<JSONType>(config: EtsyConfig<JSONType>) {
const init = configToFetchInit(config);
const response: Promise<TypedResponse<JSONType>> = fetch(config.url, init);
return response;
}
// And this is what our product code looks like:
EtsyFetch(getListingsForShop(shopId))
.then((response) => response.json())
.then((data) => {
data.listings; // "data" is fully typed using the types from our API
});
这种模式的结果非常有帮助。对 EtsyFetch
的现有调用现在具有开箱即用的强类型,不需要更改。另外,如果我们以一种会导致客户端代码发生破坏性变化的方式更新 API
,那么我们的类型检查器将会失败,代码将永远无法投入生产。
API 调用的改造也给我带来一些新的想法,如果我们想确保我们的API支持的所有地区都有一个标志表情,我们可以使用类型来强制执行:
代码语言:javascript复制type Locales OASGeneratedTypes["updateCurrentLocale"]["locales"];
const localesToIcons : Record<Locales, string> = {
"en-us":