Etsy 的 TypeScript 迁移之旅

2021-12-02 09:19:01 浏览数 (1)

大家好,我是 ConardLi ,现在一些大型的项目从 JS 迁移到 TS 已经成了一种趋势,最近又有一个大型的系统完成了 JS 到 TS 的迁移,在迁移完成后他们分享了一些很有用的经验,我们一起来看看吧。

文章的英文原文在:https://codeascraft.com/2021/11/08/etsys-journey-to-typescript/

Etsy 是美国的一个大型的电商平台,这个公司已经创建超过 16 年了,他们的代码仓库变得越来越大,在多次频繁的网站迭代中,甚至单独一个代码库已经拥有了超过一万七千个 JavaScript 文件。

在过去的几年里,EtsyWeb 平台团队花了很多的时间来重构更新前端代码。对于我们的开发人员来说,可能已经很难知道哪些部分是最佳实践,哪些部分是技术债。

JavaScript 语言本身让这类问题变得更加复杂 — 尽管在过去几年中它增加了很多新的语法特性,但 JavaScript 本身非常灵活,并且对其使用方式几乎没有什么强限制。这使得在没有研究使用的任何依赖项的实现细节的情况下编写 JavaScript 变得非常具有挑战性。虽然文档可以在一定程度上缓解这个问题,但它只能在很大程度上防止 JavaScript 库被滥用,从而最终导致不可靠的代码。

上面所有的问题都是我们认为 TypeScript 可以为我们解决的问题。TypeScript 将自己称为 Javascript 的超集。换句话说,TypeScript 拥有 Javascript 中的一切,并且可以选择添加类型。在编码的时候,类型基本上就是声明代码使用数据的方式:函数可以接收什么样的输入,变量可以保存什么样的值。

TypeScript 可以让你轻松的在现有的 Javascript 项目中逐步迁移,尤其是在一些大型的代码库中。它非常擅长从你已经编写的代码中推断类型,并且它的类型语法足够细致,可以正确描述 Javascript 中一些常见小问题。此外,它是由微软开发的,已经在 SlackAirBnB 等很多大型公司中使用,根据去年的 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 元素的名称。

代码语言:javascript复制
// 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 认为某个东西可能为空时告诉它不为空,而后者允许开发人员将某个东西作为他们选择的任何类型来对待。

代码语言:javascript复制
// 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 ,但你可能想首先使用一个 HTMLElementTypeScript 本身没有办法禁用这些语言特性,但 linting 允许我们识并禁用它们。

作为一种工具, linting 确实很有用,可以阻止人们使用糟糕的编码模式,但这并不意味着每个被规则命中的代码都是糟糕的,凡事都有例外。linting 的好处是它提供了一个合理的忽略方式。如果我们真的真的需要使用" as ",我们可以添加一个一次性的忽略注释。

代码语言:javascript复制

// 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,所以如果我们能在那里提供类型,我们就能很快覆盖我们的代码库。

EtsyAPI 是用 PHP 实现的,我们为每个端点生成 PHPJavascript 配置,以帮助简化发出请求的过程。在 Javascript 中,我们使用一个名为 EtsyFetch 的简单包装器来帮助实现这些请求,看起来就像下面这样:

代码语言:javascript复制

// 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 中使用这些类型。把所有这些放在一起大致是这样的:

代码语言:javascript复制

// 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": 


	

0 人点赞