[2022]TypeScript 尽量避免的语法[译文]

作者:Hugo

链接: zhuanlan.zhihu.com/p/460581100

原链接:Execute Program

原作者:execute program

全文:63344 字,阅读时间 20 分钟。

转发请标明译文作者 Hugo 和知乎出处链接。

这个文章列举了我们建议尽量避免的 TypeScript 语法。但是因为你的项目的情况,有可能使用这些特性也是合理的,但是我们仍然建议,在默认情况下,尽量避免使用这些特性。

随着时间的发展,TypeScript 已经是一门复杂的语言。在早期的时候,TypeScript 研发团队增加了一些不兼容 JavaScript 的语法。但是随着发展,新的版本已经不会这么做了,会非常保守地和严格地遵循 JavaScript 的语法特性。(译者注,使用和 JavaScript 严格兼容的语法带来的好处不计其数。)

就像其他成熟的语言,我们在考虑 TypeScript 的语法使用哪些,避免哪些,并不是一个容易的决定。我们经验主要来自于Execute Program 的后端和前端的建设经验,以及创建我们的 TypeScript 课程时的经验。

避免枚举(课程

枚举提供了一组常量。在下面的例子里,HttpMethod.Get 是字符串 ‘Get’ 的名字。HttpMethod 类型和一个联合类型是一样的,如 'GET' | 'POST'。

enum HttpMethod {
  Get = 'GET',
  Post = 'POST',
}
const method: HttpMethod = HttpMethod.Post;
method; // Evaluates to 'POST'

复制代码

下面是支持使用枚举的原因:

假设,我们最终要替换 ‘POST’ 为 ‘post’。我们只要替换枚举的值就能达成这一目的。我们其他的代码因为引用的是 HttpMethod.Post ,所以完全不用改。

现在假设,如果我们用联合类型来实现这个场景。我们定义了联合类型 'GET' | 'POST',然后我们决定把它们改为小写的 'get' | 'post'。现在如果使用 'GET' 或者 'POST' 作为 HttpMethod 的代码就会报类型错误。我们需要把所有的代码手动改一遍。从这个例子来说,使用枚举能简单一些。

这个支持使用枚举的例子可能不是那么有说服力。当我们增加了一个枚举和联合类型的时候,实际上在创建以后是很少更改的。使用联合类型,确实会带来更多的更改成本,但是其实不是一个问题,因为实际上是很少更改的。即便要更改,因为有类型错误,我们并不害怕少改了。

使用枚举的坏处是:

我们需要适应 TypeScript 的语法。TypeScript 应该是 JavaScript,但是增加了静态类型。如果我们去掉 TypeScript 的类型,我们就应该得到一份完整有效的 JavaScript 的代码。(译者注:这个原因是整篇文章的核心,核心好处之一就是,你可以通过 esbuild 而不是 tsc 完成你的 ts 代码到 js 代码的转换,这个速度差距可能是 10-1000倍。。并且不引入 tsc,代表着少了一个可能出问题的地方。)在 TypeScript 的官方文档中,之前描述 TypeScript 的文档是 “类型级别的扩展”:即 TypeScript 是 JavaScript 类型级别的扩展,所有 TypeScript 的特性不改变运行时的行为。

下面是一个类型级别扩展的例子, TypeScript 的例子:

function add(x: number, y: number): number {
  return x + y;
}
add(1, 2); // Evaluates to 3

复制代码

TypeScript 的编译器检查了代码的类型。然后生成了 JavaScript 的代码。很幸运,这个过程很简单:编译器只要把类型标注去掉就好了。在这个例子里,只要把 :number 去掉,下面就是完美的 JavaScript 代码:

function add(x, y) {
  return x + y;
}
add(1, 2); // Evaluates to 3

复制代码

绝大部分 TypeScript 的特性都有这个特性,遵循了类型级别扩展的法则。要得到 JavaScript 代码,只需要去掉类型标准即可以。

然而,枚举打破了这个法则。HttpMethod 和 HttpMethod.Post 是一部分的类型。他们应该被去除。然而,如果编译器去除这些代码,就会有问题,因为我们实际上在把 HttpMethod.Post 当成值类型在使用。如果编译器简单删除这些代码,这些代码就不能跑了。

/* This is compiled JavaScript code referencing a TypeScript enum. But if the
 * TypeScript compiler simply removes the enum, then there's nothing to
 * reference!
 *
 * This code fails at runtime:
 *   Uncaught ReferenceError: HttpMethod is not defined */
const method = HttpMethod.Post;

复制代码

TypeScript 的解决方案,就是打破自己的规则。当编译一个枚举的时候,编译器会自己生成一些 JavaScript 代码。其实很少 TypeScript 特性会这样做,这个其实让 TypeScript 的编译模型变得复杂了。因为这些原因,我们建议避免使用枚举,而用联合类型来取代它。

为什么类型级别扩展这个规则这么重要呢?

让我们来看,这个法则在和 JavaScript 和 TypeScript 的工具链生态互动时,会发生什么。TypeScript 的项目都是从 JavaScript 项目继承而来的,所以使用打包工具和编译工具,例如 webpack 和 babel 是很正常的。这些工具都是为了 JavaScript 设计的,即便在今天,依然是关注在 JavaScript 上。每一个工具都有自己的生态。这里有无数的 Babel 和 Webpack 自有的生态的插件。

有可能让所有 Babel 和 Webpack 以及他们的生态插件支持 TypeScript 么?对于大部分 TypeScript 语言来说,实际上类型扩展规则让这些内容支持 TypeScript 很简单。工具只要去掉类型标准,然后对其余的 JavaScript 做剩下的工具就好了。

当对于像枚举这样的特性(包括名字空间 namespaces),这个事儿要复杂一些。不能简单移除枚举。工具需要把 enum HttpMethod { ... } 转译 成合适的 JavaScript 代码,因为 JavaScript 并没有 enum 关键字。

这会带来一些实际的工作量,来处理 TypeScript 自己打破自己的类型扩展法则的问题。像 Babel、webpack 以及他们的生态插件,都是先对 JavaScript 作为设计对象,TypeScript 一般来说只是他们支持的一个功能。很多时候,TypeScript 的支持并不能收到像 JavaScript 一样的支持,就会有很多 Bug。(译者注:考虑 JavaScript 实际上让这些工具和插件的难度小很多,考虑 TypeScript,很多问题其实变复杂了,而且这个复杂度的提升不一定是有价值的。时至今日,依然是 JavaScript 的代码和需求远远大于 TypeScript。即便出于降低这些工具的复杂度的目的,也不应该为了解决 TypeScript 的问题而引入 这些问题。最核心的运行时,依然,以及必然是 JavaScript。)

很多工具的工作主要是在处理变量声明和函数声明,这些事情其实相对都是比较容易做的。但是牵扯枚举和名字空间,就不能仅仅去掉类型标注开始做逻辑了。你当然可以信赖 TypeScript 的编译器,但是很多不常用的工具可不一定考虑这个问题了。

当你的编译器、打包器、压缩器、linter、代码格式器(译者注:其实代码格式器很容易造成 bug,尤其对于 TypeScript)只要发生了一个对于上述的事儿处理有问题,是非常难进行 debug 的。编译器的 Bug 是非常非常难找的(译者注:当出现一个 bug,你会第几直觉认为是编译器的错误呢?其实不使用这些特性,你的代码是不依赖 TypeScript 编译器的,这一点至关重要。)。主要这篇文章的这些文字:经历了几周以后,在我的同事的帮助下,我们对于这个bug牵扯的范畴有了更深的认识。(注意加粗字体)(译者注:我本来花了大约两个月的时间去研究 TypeScript 的装饰器以及装饰器元数据,然后计划把他们加入到我自己的框架里。但是最后沮丧的发现,如果我引入他们,我就没有办法用 esbuild 了,原因是 esbuild 不计划支持 TypeScript 的装饰器元数据,但是支持了装饰器,但是这个支持其实也很新,而我的整个框架其实是以 esbuild 为基石的。我很沮丧,放弃了 TypeScript 的装饰器)(译者注:引入 tsc 是不明智的,因为 tsc 非常非常复杂。实际上,你只用类型的话,在代码编写阶段基本也就完成了绝大部分 tsc 的事情。在最后用 esbuild 一去类型,就可以继续了。)

避免名字空间(课程

名字空间类似 module,但是一个文件里可以有多个名字空间。例如,我们在一个文件里引入了不同名字空间的导出代码,以及它们对应的测试。(我们不建议这样使用名字空间,这里只是作为一个探讨的例子。)

namespace Util {
  export function wordCount(s: string) {
    return s.split(/\b\w+\b/g).length - 1;
  }
}

namespace Tests {
  export function testWordCount() {
    if (Util.wordCount('hello there') !== 2) {
      throw new Error("Expected word count for 'hello there' to be 2");
    }
  }
}

Tests.testWordCount();

复制代码

名字空间在实践上会造成一些问题。在上面的枚举的例子里,我们看到了 TypeScript 的类型扩展法则。通常,TypeScript 去除类型标注,留下的就是 JavaScript 的代码。

名字空间自然也打破了这一设定。在 namespace Util { export function wordCount ... } 代码里,我们不能仅仅靠去除类型标注就获得 JavaScript 的代码。整个名字空间就是一个 TypeScript 的类型定义!在其他的代码里使用 Util.wordCount(...) 会发生什么呢?如果我们删除 Util 名字空间,然后生成 JavaScript 代码,Util 就没有了。所以 Util.wordCount(...) 也不能工作。

就和枚举一样,TypeScript 也不能仅仅删除名字空间定义,而要生成一些 JavaScript 的代码。

对于枚举,我们建议用联合类型来取代。对于名字空间,我们建议就用 ESM 取代就好了。虽然创建很多文件很麻烦。但是两者能达成的效果是完全一样的。

避免装饰器(对于现在而言)

装饰器是一个可以修改和取代其他函数或者类的方法。这里是一个从 TypeScript 官方文档里找到的装饰器的例子:

// This is the decorator.
@sealed
class BugReport {
  type = "report";
  title: string;

  constructor(t: string) {
    this.title = t;
  }
}

复制代码

@sealed 装饰器暗示了C# 的 sealed 装饰器。这个装饰器可以防止其他类继承这个类。我们可以实现一个 sealed 的函数,然后接受一个类,修改它,让它不能继承这个类。

装饰器一开始是首先在 TypeScript 添加的,然后 JavaScript(ECMAScript)才开始了标准化进程。在 2022 年1月,装饰器依然是一个 ECMAScript 提案阶段 2 的提案。阶段 2 代表在 “draft”(起草)阶段。装饰器提案似乎一直停滞在委员会中:实际上,这个提案是在 2019 年 2 月到达阶段 2 的。

我们建议在 stage 3 前,避免使用装饰器。stage 3 指 “candidate” 阶段,或者 stage 4 “finished” 阶段。

有这样的可能,即 ECMAScript 永远不完成装饰器提案。如果这个提案不完成,装饰器的处境就和枚举、名字空间一样。使用装饰器,就代表着,打破了 TypeScript 的类型扩展规则,并且使用这个特性,很多打包工具,可能都是有问题的。我们不知道多会能让装饰器通过,但是装饰器带来的好处并没有那么大,所以我们选择等待。

一些开源的库,例如有名的 TypeORM,非常重的使用了装饰器。我们承认,如果遵守我们的建议,就不能使用 TypeORM。当然使用 TypeORM 和装饰器有时候是好的选择,但是你应该明白这么做带来的问题,你要知道,目前装饰器的提案的标准化过程可能永远不会结束。(译者注:如果你想享受 esbuild 带来的好处,装饰器用的深就可能是个问题。当然,如果你的业务可以自闭在一套装饰器写的框架里,可能也不是非常大的问题。但是,如果 JS 的装饰器出现,现有的装饰器框架可能就有问题了。)

避免 Private 关键字(课程

TypeScript 有两种方式让一个类型属性私有。老的方法是 private 关键字,这个是 TypeScript 独有的。目前还有一个新的方式:#somePrivateField,这个是 JavaScript 的方式。下面是一个例子:

class MyClass {
  private field1: string;
  #field2: string;
  ...
}

复制代码

我们建议 #somePrivateField 字段。但是这两个方法基本是等同的。但是我们建议更多使用 JavaScript 的特性。

来总结一些我们的四个建议:

  1. 避免 enum 枚举
  2. 避免名字空间 namespace
  3. 避免装饰器,尽量等到这个语法标准化完成。如果你需要一个库用装饰器,要考虑它的标准化状态。
  4. 尽量用 #somePrivateField而不是private somePrivateField.

尽管我们建议大家避免使用这些特性,但是学习这些特性的知识还是大有裨益的。因为在遗产代码里,你还是会大量看到这些东西,甚至一些新的代码。我们相信,肯定不是所有人都认同这些建议。我们的课程也教授这些特性,原因是:Teaching the Unfortunate Parts.

猜你喜欢

转载自juejin.im/post/7055875519962693662