@sambodhi
2021-11-16T07:10:08.000000Z
字数 11268
阅读 758
作者 | Salem Hilai
译者 | Sambodhi
策划 | 蔡芳芳
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,因为在这方面还有很多其他的文章和讲座,都做得非常好。相反,我想谈谈 Etsy 在推出 TypeScript 支持方面所做的努力,这不仅仅是从 JavaScript 到 TypeScript 的技术实现。这也包括许多规划、教育和协调工作。但是如果把细节弄清楚,你会发现很多值得分享的学习经验。让我们先来讨论一下我们想要的采用是什么样的做法。
TypeScript 在检查代码库中的类型时,可能多少有点“严格”。据 TypeScript 手册所述,一个更严格的 TypeScript 配置 “能更好地保证程序的正确性”,你可以根据自己的设计,根据自己的需要逐步采用 TypeScript 的语法及其严格性。这个特性使 TypeScript 添加到各种代码库中成为可能,但是它也使“将文件迁移到 TypeScript”成为一个定义松散的目标。很多文件需要用类型进行注释,这样 TypeScript 就可以完全理解它们。还有许多 JavaScript 文件可以转换成有效的 TypeScript,只需将其扩展名从 .js 改为 .ts 即可。但是,即使 TypeScript 对文件有很好的理解,该文件也可能会从更多的特定类型中获益,从而提高其实用性。
各种规模的公司都有无数的文章讨论如何迁移到 TypeScript,所有这些文章都对不同的迁移策略提出了令人信服的论点。例如,Airbnb 尽可能地自动化了他们的迁移。还有一些公司在他们的项目中启用了较不严格的 TypeScript,随着时间的推移在代码中添加类型。
确定 Etsy 的正确方法意味着要回答一些关于迁移的问题:
我们决定将严格性放在第一位;采用一种新的语言需要付出大量的努力,如果我们使用 TypeScript,我们可以充分利用其类型系统(此外,TypeScript 的检查器在更严格的类型上执行得更好)。我们还知道 Etsy 的代码库相当庞大;迁移每个文件可能并不能充分利用我们的时间,但是确保我们拥有类型用于我们网站的新的和经常更新的部分是很重要的。当然,我们也希望我们的类型尽可能有用,容易使用。
以下是我们的采用策略:
让我们再仔细看看这几点吧。
严格的 TypeScript 能够避免许多常见的错误,所以我们认为最合理的做法是尽量严格的。这一决定的缺点是我们现有的大多数 JavaScript 都需要类型注释。它还需要以逐个文件的方式迁移我们的代码库。使用严格的 TypeScript,如果我们尝试一次转换所有的代码,我们最终将会有一个长期的积压问题需要解决。如前所述,我们在单体仓库中有超过 17000 个 JavaScript 文件,其中很多都不经常修改。我们选择把重点放在那些在网站上积极开发的区域,明确地区分哪些文件具有可靠的类型,以及哪些文件不使用 .js 和 .ts 文件扩展名。
一次完全迁移可能在逻辑上使改进已有的类型很难,尤其是在单体仓库模式中。当导入 TypeScript 文件时,出现被禁止的类型错误,你是否应该修复此错误?那是否意味着文件的类型必须有所不同才能适应这种依赖关系的潜在问题?哪些具有这种依赖关系,编辑它是否安全?就像我们的团队所知道的,每个可以被消除的模糊性,都可以让工程师自己作出改进。在增量迁移中,任何以 .ts 或 .tsx 结尾的文件都可以认为存在可靠的类型。
当我们的工程师开始编写 TypeScript 之前,我们希望我们所有的工具都能支持 TypeScript,并且所有的核心库都有可用的、定义明确的类型。使用 TypeScript 文件中的非类型化依赖项会使代码难以使用,并可能会引入类型错误;尽管 TypeScript 会尽力推断非 TypeScript 文件中的类型,但是如果无法推断,则默认为“any”。换句话说,如果工程师花时间编写 TypeScript,他们应该能够相信,当他们编写代码的时候,语言能够捕捉到他们所犯的类型错误。另外,强制工程师在学习新语言和跟上团队路线图的同时为通用实用程序编写类型,这是一种让人们反感 TypeScript 的好方法。这项工作并非微不足道,但却带来了丰厚的回报。在下面的“技术细节”一节中,我将对此进行详细阐述。
我们已经花了很多时间在 TypeScript 的教育上,这是我们在迁移过程中所做的最好的决定。Etsy 有数百名工程师,在这次迁移之前,他们几乎没有 TypeScript 的经验(包括我)。我们知道,要想成功地迁移,人们首先必须学习如何使用 TypeScript。打开这个开关,告诉所有人都要这么做,这可能会使人们迷惑,使我们的团队被问题压垮,也会影响我们产品工程师的工作速度。通过逐步引入团队,我们能够努力完善工具和教学材料。它还意味着,没有任何工程师能在没有队友能够审查其代码的情况下编写 TypeScript。逐步适职使我们的工程师有时间学习 TypeScript,并把它融入到路线图中。
在迁移过程中,有很多有趣的技术挑战。令人惊讶的是,采用 TypeScript 的最简单之处就是在构建过程中添加对它的支持。在这个问题上,我不会详细讨论,因为构建系统有许多不同的风格,但简单地说:
上面所做的工作花费了一到两个星期,其中大部分时间是用于验证我们发送到生产中的 TypeScript 是否会发生异常行为。在其他 TypeScript 工具上,我们花费了更多的时间,结果也更有趣。
我们在 Etsy 中大量使用了自定义的 ESLint Lint 规则。它们为我们捕捉各种不良模式,帮助我们废除旧代码,并保持我们的 pull request(拉取请求)评论不跑题,没有吹毛求疵。如果它很重要,我们将尝试为其编写一个 Lint 规则。我们发现,有一个地方可以利用 Lint 规则的机会,那就是强化类型特异性,我一般用这个词来表示“类型与所描述的事物之间的精确匹配程度”。
举例来说,假设有一个函数接受 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,当 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.foo;
这两种语法特性都允许开发者覆盖 TypeScript 对某物类型的理解。很多情况下,它们都意味着某种类型更深层次问题,需要加以修复。消除这些类型,我们强迫这些类型对于它们所描述得更具体。举例来说,你可以使用“as”将 Element 转换为 HTMLElement,但是你可能首先要使用 HTMLElement。TypeScript 本身无法禁用这些语言特性,但是 Lint 使我们能够识别它们并防止它们被部署。
作为防止人们使用不良模式的工具,Lint 确实非常有用,但是这并不意味着这些模式是普遍不好的:每个规则都有例外。Lint 的好处在于,它提供了合理的逃生通道。在任何时候,如果确实需要使用“as”,我们可以随时添加一次性的 Lint 例外。
// 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 };
我们希望我们的开发者能够编写出有效的 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 类型。在编写和改进 OpenAPI 规范生成器之前,我们已经花费了大量的时间来编写和改进,它可以适用于我们所有的内部 API 端点,然后使用一个名为 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 作为唯一的真相。举例来说,如果我们希望确保支持某个 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 分钟来检查我们的整个代码库,包括未迁移的文件和所有文件。
在反复讨论后,确定我们没有包含超出需求的文件后,他们向我们展示了当时他们刚刚推出的性能跟踪功能。跟踪结果表明,当对未迁移的 JavaScript 文件进行类型检查时,TypeScript 就会对我们的一个类型出现问题。以下是该文件的跟踪(这里的宽度代表时间)。
结果是,类型中存在一个循环依赖关系,它帮助我们创建不可变的对象的内部实用程序。到目前为止,这些类型对于我们处理的所有代码来说都是完美无缺的,但在代码库中尚未迁移的部分,它的一些使用却出现了问题,产生了一个无限的类型循环。如果有人打开了代码库的这些部分文件,或者在我们对所有代码运行类型检查器时,就会花很多时间来尝试理解该类型,然后放弃并记录类型错误。修复了这个类型之后,检查一个文件的时间从将近 46 秒减少到了不到 1 秒。
这种类型在其他地方也会产生问题。当进行修正之后,检查整个代码库的时间大约为此前的三分之一,并且减少了整整 1GB 的内存使用。
如果我们没有发现这个问题,那么它最终将导致我们的测试(以及生产部署)速度更慢。它还会使每个人在编写 TypeScript 的时候非常非常不愉快。
采用 TypeScript 的最大障碍,无疑是让大家学习 TypeScript。类型越多的 TypeScript 就越好。假如工程师对编写 TypeScript 代码感到不适应,那么完全采用这种语言将是一场艰难的斗争。就像我在上面提到的,我们决定逐个团队推广是建立某种制度化的 TypeScript 的最佳方式。
我们通过直接与少数团队合作来开始我们的推广工作。我们寻找那些即将开始新项目并且时间相对灵活的团队,并询问他们是否对用 TypeScript 编写项目感兴趣。在他们工作的时候,我们唯一的工作就是审查他们的拉取请求,为他们需要的模块实现类型,并在他们学习时与他们配对。
在此期间,我们可以完善自己的类型,并且为 Etsy 代码库中难以处理的部分开发专门的文档。由于只有少数工程师正在编写 TypeScript,所以很容易从他们那里得到直接的反馈,并迅速解决他们遇到的问题。这些早期的团队为我们提供了很多 Lint 规则,这可以确保我们的文档清晰、有用。它还为我们提供了足够的时间来完成迁移的技术部分,比如向 API 添加类型。
当我们感觉大多数问题已经解决后,我们决定让任何有兴趣和准备好的团队加入。为使团队能够编写 TypeScript,我们要求他们先完成一些培训。我们从 ExecuteProgram 找到了一门课程,我们认为这门课程以互动和有效的方式,很好地教授了 TypeScript 的基础知识。当我们认为团队的所有成员都需要完成这门课程(或具有一定的同等经验),我们才能认为他们准备好了。
我们努力使人们对 TypeScript 非常感兴趣,以吸引更多的人参加互联网上的课程。我们与 Dan Vanderkam 取得了联系,他是《Effective TypeScript》(暂无中译本)的作者,我们想知道他是否对做一次内部讲座感兴趣(他答应了,他的讲座和书都非常棒)。此外,我还设计了一些非常高质量的虚拟徽章,我们会在课程作业的期中和期末发给大家,以保持他们的积极性(并关注大家学习 TypeScript 的速度)。
然后我们鼓励新加入的团队腾出一些时间迁移他们团队负责的 JS 文件。我们发现,迁移你所熟悉的文件是学习如何使用 TypeScript 的一个好方法。这是一种直接的、亲手操作类型的方式,然后你可以马上在别处使用。实际上,我们决定不使用更复杂的自动迁移工具(比如 Airbnb 写的那个),部分原因是它剥夺了一些学习机会。另外,一个有一点背景的工程师迁移文件的效率比脚本要高。
一次一个团队的适职意味着我们必须防止个别工程师在其团队其他成员准备就绪之前编写 TypeScript。这种情况比你想象的要多;TypeScript 是一种非常酷的语言,人们都渴望去尝试它,尤其是在看到代码库中使用它后。为了避免这种过早地采用,我们编写了一个简单的 git 提交钩子,禁止不属于安全列表的用户修改 TypeScript。当一个团队准备好时,我们只需将他们加入到安全列表即可。
此外,我们努力与每一个团队的工程师经理发展直接交流。将电子邮件发送到整个工程部门很容易,但是和每一个经理密切合作可以确保没有人对我们的推出感到惊讶。它还给了我们一个机会来解决团队所关心的问题,比如学习一门新语言。尤其在大公司中,强制要求变更可能是一种负担,虽然直接的沟通层很小,但会有很大的帮助(即使它需要一个相当大的电子表格来跟踪所有的团队)。
事实证明,审查 PR 是早期发现问题的一种很好的方法,并为以后 Lint 规则的制定提供了许多参考。为有助于迁移,我们决定对包含 TypeScript 的每个 PR 进行明确的审查,直到推广顺利。我们将审查的范围扩大到语法本身,并随着我们的发展,向那些已经成功适职的工程师寻求帮助。我们将这个小组称为 TypeScript 顾问,他们是新晋 TypeScript 工程师的重要支持来源。
在推广过程中最酷的一个方面就是很多学习过程是如何有机进行的。有些小组举行了大型的结对会议,他们共同解决问题,或者尝试迁移文件,我们并不知道。一些小组甚至建立了读书会来阅读 TypeScript 书籍。这类迁移确实需要付出大量的努力,但是我们很容易忘记,其中有多少工作是由热情的同事和队友完成的。
在今秋早些时候,我们已经开始要求使用 TypeScript 编写所有新文件。大概有 25% 的文件是类型,这个数字还不包括被丢弃的特性、内部工具和死代码。到撰写本文时,每一个团队都已成功地适职 TypeScript。
“完成向 TypeScript 的迁移”并不是一个明确的定义,特别是对于大型代码库而言。尽管我们可能还会有一段时间在我们的仓库中没有类型的 JavaScript 文件,但从现在开始,我们的每一个新特性都将进行类型化。撇开这些不谈,我们的工程师已经在有效地编写和使用 TypeScript,开发自己的工具,就类型展开深思熟虑的讨论,分享他们认为有用的文章和模式。虽然很难说,但是人们似乎很喜欢一种去年这个时候几乎没人用过的语言。对于我们来说,这是一次成功的迁移。
原文链接:
https://codeascraft.com/2021/11/08/etsys-journey-to-typescript/