超 1.7 万个 JavaScript 文件,Etsy 大型代码库如何完成向 TypeScript 迁移?

2021-12-01 18:11:43 浏览数 (1)

Etsy 的 Web 平台团队在过去几年中花费了大量时间来更新我们的前端代码。仅在一年半以前,我们才将 JavaScript 构建系统现代化,以实现更高级的特性,比如 箭头函数 和 类,从 2015 年起,它们被添加到了这个语言中。尽管这个升级意味着我们对代码库的未来验证已经完成,并且可以编写出更加习惯化、更可扩展的 JavaScript,但是我们知道还有改进的空间。

Etsy 已经有十六年的历史了。自然地,我们的代码库变得相当庞大;Monorepo(单体仓库)拥有超过 17000 个 JavaScript 文件,并且跨越了网站的很多迭代。如果开发者使用我们的代码库,很难知道哪些部分仍被视为最佳实践,哪些部分遵循传统模式或者被视为技术债务。JavaScript 语言本身使得这个问题更加复杂:虽然在过去的几年里,为该语言增加了新的语法特性,但是 JavaScript 非常灵活,对如何使用也没有多少可强制性的限制。这样,在编写 JavaScript 时,如果没有事先研究依赖关系的实现细节,就很有挑战性。尽管文档在某种程度上有助于减轻这个问题,但是它只能在很大程度上防止 JavaScript 库的不当使用,从而最终导致不可靠的代码。

所有这些问题(还有更多!)都是我们认为 TypeScript 可能为我们解决的问题。TypeScript 自称是“JavaScript 的超集”。换言之,TypeScript 就是 JavaScript 中的一切,可以选择增加类型。类型从根本上来说,在编程中,类型是通过代码移动的数据的期望的方式:函数可以使用哪些类型的输入,变量可以保存哪些类型的值。(如果你不熟悉类型的概念,TypeScript 的手册有一个 很好的介绍)。

TypeScript 被设计成可以很容易地在已有的 JavaScript 项目中逐步采用,尤其是在大型代码库中,而转换成一种新的语言是不可能的。它非常擅长从你已经编写好的代码中推断出类型,并且其类型语法细微到足以正确地描述 JavaScript 中普遍存在的“怪癖”。此外,它由微软开发,已被 Slack 和 Airbnb 等公司使用,根据 去年的“State of JavaScript”调查,它是迄今为止使用最多、最流行的 JavaScript。若要使用类型来为我们的代码库带来某种秩序,TypeScript 看起来是一个非常可靠的赌注。

因此,在迁移到 ES6 之后,我们开始研究采用 TypeScript 的路径。本文将讲述我们如何设计我们的方法,一些有趣的技术挑战,以及如何使一家 Etsy 级别的公司学习一种新的编程语言。

在高层次上采用 TypeScript

我并不想花太多时间向你安利 TypeScript,因为在这方面还有很多其他的 文章 和 讲座,都做得非常好。相反,我想谈谈 Etsy 在推出 TypeScript 支持方面所做的努力,这不仅仅是从 JavaScript 到 TypeScript 的技术实现。这也包括许多规划、教育和协调工作。但是如果把细节弄清楚,你会发现很多值得分享的学习经验。让我们先来讨论一下我们想要的采用是什么样的做法。

采用策略

TypeScript 在检查代码库中的类型时,可能多少有点“严格”。据 TypeScript 手册 所述,一个更严格的 TypeScript 配置 “能更好地保证程序的正确性”,你可以根据自己的设计,根据自己的需要逐步采用 TypeScript 的语法及其严格性。这个特性使 TypeScript 添加到各种代码库中成为可能,但是它也使“将文件迁移到 TypeScript”成为一个定义松散的目标。很多文件需要用类型进行注释,这样 TypeScript 就可以完全理解它们。还有许多 JavaScript 文件可以转换成有效的 TypeScript,只需将其扩展名从 .js 改为 .ts 即可。但是,即使 TypeScript 对文件有很好的理解,该文件也可能会从更多的特定类型中获益,从而提高其实用性。

各种规模的公司都有无数的文章讨论如何迁移到 TypeScript,所有这些文章都对不同的迁移策略提出了令人信服的论点。例如,Airbnb 尽可能地 自动化 了他们的迁移。还有一些公司在他们的项目中启用了较不严格的 TypeScript,随着时间的推移在代码中添加类型。

确定 Etsy 的正确方法意味着要回答一些关于迁移的问题:

  • 我们希望 TypeScript 的味道有多严格?
  • 我们希望迁移多少代码库?
  • 我们希望编写的类型有多具体?

我们决定将严格性放在第一位;采用一种新的语言需要付出大量的努力,如果我们使用 TypeScript,我们可以充分利用其类型系统(此外,TypeScript 的检查器在更严格的类型上 执行得更好)。我们还知道 Etsy 的代码库相当庞大;迁移每个文件可能并不能充分利用我们的时间,但是确保我们拥有类型用于我们网站的新的和经常更新的部分是很重要的。当然,我们也希望我们的类型尽可能有用,容易使用。

我们采用的是什么?

以下是我们的采用策略:

  1. 使 TypeScript 尽可能地严格,并逐个文件地移植代码库。
  2. 添加真正优秀的类型和真正优秀的支持文档,包括产品开发者常用的所有实用程序、组件和工具。
  3. 花时间教工程师们学习 TypeScript,并让他们逐个团队地启用 TypeScript 语法。

让我们再仔细看看这几点吧。

逐步迁移到严格的 TypeScript

严格的 TypeScript 能够避免许多常见的错误,所以我们认为最合理的做法是尽量严格的。这一决定的缺点是我们现有的大多数 JavaScript 都需要类型注释。它还需要以逐个文件的方式迁移我们的代码库。使用严格的 TypeScript,如果我们尝试一次转换所有的代码,我们最终将会有一个长期的积压问题需要解决。如前所述,我们在单体仓库中有超过 17000 个 JavaScript 文件,其中很多都不经常修改。我们选择把重点放在那些在网站上积极开发的区域,明确地区分哪些文件具有可靠的类型,以及哪些文件不使用 .js 和 .ts 文件扩展名。

一次完全迁移可能在逻辑上使改进已有的类型很难,尤其是在单体仓库模式中。当导入 TypeScript 文件时,出现被禁止的类型错误,你是否应该修复此错误?那是否意味着文件的类型必须有所不同才能适应这种依赖关系的潜在问题?哪些具有这种依赖关系,编辑它是否安全?就像我们的团队所知道的,每个可以被消除的模糊性,都可以让工程师自己作出改进。在增量迁移中,任何以 .ts 或 .tsx 结尾的文件都可以认为存在可靠的类型。

确保实用程序和工具有良好的 TypeScript 支持

当我们的工程师开始编写 TypeScript 之前,我们希望我们所有的工具都能支持 TypeScript,并且所有的核心库都有可用的、定义明确的类型。使用 TypeScript 文件中的非类型化依赖项会使代码难以使用,并可能会引入类型错误;尽管 TypeScript 会尽力推断非 TypeScript 文件中的类型,但是如果无法推断,则默认为“any”。换句话说,如果工程师花时间编写 TypeScript,他们应该能够相信,当他们编写代码的时候,语言能够捕捉到他们所犯的类型错误。另外,强制工程师在学习新语言和跟上团队路线图的同时为通用实用程序编写类型,这是一种让人们反感 TypeScript 的好方法。这项工作并非微不足道,但却带来了丰厚的回报。在下面的“技术细节”一节中,我将对此进行详细阐述。

逐个团队地进行工程师适职培训

我们已经花了很多时间在 TypeScript 的教育上,这是我们在迁移过程中所做的最好的决定。Etsy 有数百名工程师,在这次迁移之前,他们几乎没有 TypeScript 的经验(包括我)。我们知道,要想成功地迁移,人们首先必须学习如何使用 TypeScript。打开这个开关,告诉所有人都要这么做,这可能会使人们迷惑,使我们的团队被问题压垮,也会影响我们产品工程师的工作速度。通过逐步引入团队,我们能够努力完善工具和教学材料。它还意味着,没有任何工程师能在没有队友能够审查其代码的情况下编写 TypeScript。逐步适职使我们的工程师有时间学习 TypeScript,并把它融入到路线图中。

技术细节

在迁移过程中,有很多有趣的技术挑战。令人惊讶的是,采用 TypeScript 的最简单之处就是在构建过程中添加对它的支持。在这个问题上,我不会详细讨论,因为构建系统有许多不同的风格,但简单地说:

  • 用 Webpack 来构建我们的 JavaScript。Webpack 使用 Babel 将我们的现代 JavaScript 移植到更古老、更兼容的 JavaScript。
  • Babel 有个可爱的插件 babel-preset-typescript,可以快速地将 TypeScript 转换成 JavaScript,但希望你能自己进行类型检查。
  • 要检查我们的类型,我们运行 TypeScript 编译器作为我们测试套件的一部分,并配置它不 使用 noEmit 选项 来实际转译任何文件。

上面所做的工作花费了一到两个星期,其中大部分时间是用于验证我们发送到生产中的 TypeScript 是否会发生异常行为。在其他 TypeScript 工具上,我们花费了更多的时间,结果也更有趣。

使用 typescript-eslint 提高类型特异性

我们在 Etsy 中大量使用了自定义的 ESLint Lint 规则。它们为我们捕捉各种不良模式,帮助我们废除旧代码,并保持我们的 pull request(拉取请求)评论不跑题,没有吹毛求疵。如果它很重要,我们将尝试为其编写一个 Lint 规则。我们发现,有一个地方可以利用 Lint 规则的机会,那就是强化类型特异性,我一般用这个词来表示“类型与所描述的事物之间的精确匹配程度”。

举例来说,假设有一个函数接受 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,当 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.foo;

这两种语法特性都允许开发者覆盖 TypeScript 对某物类型的理解。很多情况下,它们都意味着某种类型更深层次问题,需要加以修复。消除这些类型,我们强迫这些类型对于它们所描述得更具体。举例来说,你可以使用“as”将 Element 转换为 HTMLElement,但是你可能首先要使用 HTMLElement。TypeScript 本身无法禁用这些语言特性,但是 Lint 使我们能够识别它们并防止它们被部署。

作为防止人们使用不良模式的工具,Lint 确实非常有用,但是这并不意味着这些模式是普遍不好的:每个规则都有例外。Lint 的好处在于,它提供了合理的逃生通道。在任何时候,如果确实需要使用“as”,我们可以随时添加一次性的 Lint 例外。

代码语言: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 实现的,所以如果我们能在那里提供类型,我们很快就可以涵盖大部分的代码库。

Etsy 的 API 是用 PHP 实现的,并且我们为每个端点生成 PHP 和 JavaScript 配置,从而帮助简化请求的过程。在 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 类型。在编写和改进 OpenAPI 规范生成器之前,我们已经花费了大量的时间来编写和改进,它可以适用于我们所有的内部 API 端点,然后使用一个名为 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 作为唯一的真相。举例来说,如果我们希望确保支持某个 API 的所有区域都有一个标志的表情符号,我们可以使用以下类型来强制执行:

代码语言:javascript复制
type Locales  OASGeneratedTypes["updateCurrentLocale"]["locales"];
const localesToIcons : Record<Locales, string> = {
   "en-us": "


	

0 人点赞