查看原文
其他

Etsy 的 TypeScript 迁移之旅

ConardLi code秘密花园 2022-05-11

大家好,我是 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 元素的名称。

// 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");

但如果我们可以付出一点努力使我们的类型更加具体,其他开发人员将更容易正确使用我们的功能。

// 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 ,但你可能想首先使用一个 HTMLElementTypeScript 本身没有办法禁用这些语言特性,但 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,所以如果我们能在那里提供类型,我们就能很快覆盖我们的代码库。

EtsyAPI 是用 PHP 实现的,我们为每个端点生成 PHPJavascript 配置,以帮助简化发出请求的过程。在 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支持的所有地区都有一个标志表情,我们可以使用类型来强制执行:

type Locales  OASGeneratedTypes["updateCurrentLocale"]["locales"];
 
const localesToIcons : Record<Locales, string> = {
  "en-us": 🇺🇸",
  "
de": 🇩🇪",
  "fr": 🇫🇷",
  "
lbn": 🇱🇧",
  //... If a locale is missing here, it would cause a type error.
}

最重要的是,这些功能都不需要更改我们工程师现有的工作流程。

通过分析类型来改善开发体验

迁移 TypeScript 后,我们非常密切关注来自我们工程师的抱怨。虽然我们的迁移工作还处于早期阶段,但一些人提到他们的编辑器在提供类型提示后变得很慢。例如,有些人告诉我们,当鼠标悬停在变量上时,他们等待了将近半分钟才能显示类型信息。考虑到我们可以在一分钟内对所有 TS 文件运行类型检查器,这个问题令人困惑,单个变量的类型信息不应该那么慢。

我们有幸与 TypeScript 项目的一些维护者讨论了一下。他们很有兴趣看到 TypeScript 在像 Etsy 这样的独特代码库中取得成功。听到我们提到编辑器的问题,他们也非常惊讶,还有听到 TypeScript 花了将近 10 分钟来检查我们的整个代码库、未迁移的文件和所有内容时,他们更加惊讶。

在反复确认我们包含的文件数量不会超出我们的需要之后,他们向我指出了他们当时刚刚引入的性能跟踪功能。跟踪报告中表明,当 TypeScript 尝试对未迁移的 Javascript 文件进行类型检查时,我们的一种类型存在问题。下面是该文件的跟踪报告(此处的宽度表示时间)。

事实证明,我们的类型中有一个循环依赖,用于帮助我们创建不可变对象的内部实用程序。到目前为止,这些类型对于我们使用过的所有代码都完美无缺,但是在代码库中尚未迁移的部分中的某些使用存在问题,从而导致了无限类型循环。当有人在代码库的这些部分打开文件时,或者当我们对所有代码运行类型检查器时,TypeScript 会进入无限循环,花费大量时间试图理解该类型,然后放弃并记录类型错误。修复该类型后,检查该文件所需的时间从近 46 秒减少到不到 1 秒。

这种类型在其他地方也存在问题。全部修复后,检查整个代码库只需要花费了之前大约三分之一的时间,而且减少了整个过程的内存使用量:

如果我们没有发现这个问题,它会让我们的开发和部署都变得很慢。这也会让编写 TypeScript 对每个人来说都非常非常不愉快。

培训

毫无疑问,采用 TypeScript 的最大障碍是让每个人都学习 TypeScriptTypeScript 的类型越多,效果越好。如果工程师不习惯编写 TypeScript 代码,那么写代码就会变成一个非常艰难的斗争。正如我上面提到的,我们认为逐个团队的迁移和改造是比较有效的。

基础工作

我们通过直接与少数团队合作来开始我们的部署。我们寻找了一些即将在相对灵活的期限内开始新项目的团队,而且对他们是否有兴趣使用 TypeScript 编写这些项目进行了调研。当他们工作时,我们唯一的工作是就是 Review 他们的代码,为他们需要的模块实现类型,并在他们学习时与他们交流。

在此期间,我们能够改进我们的类型并开发专门针对 Etsy 代码库中棘手部分的文档。因为只有少数工程师在编写 TypeScript,所以很容易从他们那里获得直接反馈并快速解决他们遇到的问题。这些早期团队给我们提供了许多 linting 规则,他们帮助确保我们的文档更加清晰且有用。它还为我们提供了完成迁移的一些技术部分所需的时间,例如向 API 添加类型。

让团队接受培训

一旦我们觉得大部分问题都已解决了,我们决定向更多既感兴趣又准备好的团队进行推广。为了让团队准备编写 TypeScript,我们首先会要求他们先完成一些培训。我们发现 ExecuteProgram 的一门课程挺不错的,它以一种交互式且有效的方式很好地教授了 TypeScript 的基础知识。

https://www.executeprogram.com/courses/typescript

然后我们鼓励新入职的同学留出一些时间来迁移他们团队负责的 JS 文件。我们发现迁移已经熟悉的文件是学习如何使用 TypeScript 的好方法。事实上,我们也决定不会使用更复杂的自动迁移工具(如 AirBnB 提供的那种),部分原因是它带走了一些学习的机会。另外,具有一点上下文的工程师可以比自动化脚本更有效地迁移文件。

TypeScript Advisors

事实证明,Review 代码是一种早期发现问题的好方法,它为我们后续的 linting 规则提供了很多信息。为了帮助更好的迁移,我们决定明确 Review 每个包含 TypeScriptPR,直到部署顺利完成。我们将 Review 的范围扩大到语法本身。我们称这个小组为 TypeScript Advisors,他们成为新晋 TypeScript 工程师的宝贵技术支持。

现在

从今年秋天早些时候开始,我们开始要求所有新文件都用 TypeScript 编写。我们目前大约有 25% 的文件有类型,这个统计数字没有考虑已经过时的特性、内部工具和不再迭代的代码。在撰写本文时,公司的团队都已成功加入 TypeScript

“完成向 TypeScript 的迁移” 并不是一个明确的定义,尤其是对于大型代码库。虽然我们的 repo 中可能会有一段时间存在未迁移的 Javascript 文件,但我们从这里发布的几乎所有新功能都将使用 TypeScript 。除此之外,我们的工程师已经在有效地编写和使用 TypeScript 开发他们自己的工具,开始关于类型的真正深思熟虑的对话,并分享他们认为有用的文章和开发模式。很难确定,但人们似乎正在享受一种去年这个时候几乎没有人体验过的语言。对我们来说,这感觉就像一次成功的迁移。

附录:一些学习资源

  • 如果你是 TypeScript 的新手 TypeScript 文档https://www.typescriptlang.org/docs/handbook/intro.html) 是一个很棒的资源。
  • 我个人从 Dan VanderkamEffective TypeScripthttps://effectivetypescript.com/) 中学到了很多关于 TypeScript 的知识。
  • TypeScript 项目的 Performance wikihttps://github.com/microsoft/TypeScript/wiki/Performance) 提供了大量有用的建议。
  • 如果你想为你的代码库编写更复杂和高级的类型,type-challenges repohttps://github.com/type-challenges/type-challenges) 会给你提供很多帮助。1

抖音前端正急缺人才,如果你想加入我们,欢迎加我微信和我联系。另外如果你想加入高质量前端交流群,或者你有任何其他事情想和我交流也可以添加我的个人微信 ConardLi 。

文中如有错误,欢迎在后台和我留言,如果这篇文章帮助到了你,欢迎点赞、在看和关注。你的点赞、在看和关注是对我最大的支持!

创作不易,你的每一个点赞、在看、分享都是对我最大的支持!❤️


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存