Type script 入门实战笔记 - 快手资深前端技术专家,快手轻雀协作前端负责人 - 拉勾教育

08 讲我们介绍了联合和交叉类型,其中有一个使用字面量联合类型来列举可能的类型(间接列举值)的场景,比如说表示星期的类型:

  1. type Day = 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY';
  2. const SUNDAY: Day = 'SUNDAY';
  3. const SATURDAY: Day = 'SATURDAY';

通过这些有着明确含义的单词来定义表示星期几的状态,使得我们的代码更具备可读性。

当然,为了更简洁和高效,我们也可以使用纯数值表示星期几,比如使用 0 到 1 表示从’SUNDAY’ 到’MONDAY’。因为我们真正关注的是星期几这个状态,而不是具体的值,如下代码所示:

  1. type Day = 0 | 1 | 2 | 3 | 4 | 5 | 6;

那有没有一种兼具语义化和简洁值优点的类型呢?在 C/C++/C# 中有能满足这个诉求的类型,它就是枚举(Enums),用来表示一个被命名的整型常数的集合。

学习建议:请使用 VS Code 新建一个 09.ts 文件,尝试这一讲中的所有示例。

枚举类型

在 JavaScript 原生语言中并没有与枚举匹配的概念,而 TypeScript 中实现了枚举类型(Enums),这就意味着枚举也是 TypeScript 特有的语法(相对于 JavaScript)。

在 TypeScript 中,我们可以使用枚举定义包含被命名的常量的集合,比如 TypeScript 支持数字、字符两种常量值的枚举类型。

我们也可以使用 enum 关键字定义枚举类型,格式是 enum + 枚举名字 + 一对花括弧,花括弧里则是被命名了的常量成员。

下面我们把前边表示星期的联合类型示例使用枚举类型实现一遍,如下代码所示:

  1. enum Day {
  2. SUNDAY,
  3. MONDAY,
  4. TUESDAY,
  5. WEDNESDAY,
  6. THURSDAY,
  7. FRIDAY,
  8. SATURDAY
  9. }

注意:相对于其他类型,enum 也是一种比较特殊的类型,因为它兼具值和类型于一体,有点类似 class(在定义 class 结构时, 其实我们也自动定义了 class 实例的类型)。

在上述示例中,Day 既可以表示集合,也可以表示集合的类型,所有成员(enum member)的类型都是 Day 的子类型。

前边我们说过,JavaScript 中其实并没有与枚举类型对应的原始实现,而 TypeScript 转译器会把枚举类型转译为一个属性为常量、命名值从 0 开始递增数字映射的对象,在功能层面达到与枚举一致的效果(然而不是所有的特性在 JavaScript 中都有对应的实现)。

下面我们通过如下所示示例看看将如上示例转译为 JavaScript 后的效果。

  1. var Day = void 0;
  2. (function (Day) {
  3. Day[Day["SUNDAY"] = 0] = "SUNDAY";
  4. Day[Day["MONDAY"] = 1] = "MONDAY";
  5. Day[Day["TUESDAY"] = 2] = "TUESDAY";
  6. Day[Day["WEDNESDAY"] = 3] = "WEDNESDAY";
  7. Day[Day["THURSDAY"] = 4] = "THURSDAY";
  8. Day[Day["FRIDAY"] = 5] = "FRIDAY";
  9. Day[Day["SATURDAY"] = 6] = "SATURDAY";
  10. })(Day || (Day = {}));

我们可以看到 Day.SUNDAY 被赋予 0 作为值,Day.SATURDAY 被赋予 6 作为值。

在 TypeScript 中,我们可以通过 “枚举名字. 常量命名” 的格式获取枚举集合里的成员,如下代码所示:

  1. function work(d: Day) {
  2. switch (d) {
  3. case Day.SUNDAY:
  4. case Day.SATURDAY:
  5. return 'take a rest';
  6. case Day.MONDAY:
  7. case Day.TUESDAY:
  8. case Day.WEDNESDAY:
  9. case Day.THURSDAY:
  10. case Day.FRIDAY:
  11. return 'work hard';
  12. }
  13. }

示例中的第 3 行到第 10 行,我们通过 Day.SUNDAY 这样的格式就可以访问枚举的所有成员了。 上面示例中的 work 函数转译为 JavaScript 后,里面的 switch 分支运行时的效果实际上等价于如下所示代码:

  1. ...
  2. switch (d) {
  3. case 0:
  4. case 1:
  5. return 'take a rest';
  6. case 2:
  7. case 3:
  8. case 4:
  9. case 5:
  10. case 6:
  11. return 'work hard';
  12. }
  13. ...

这就意味着在 JavaScript 中调用 work 函数时,传递的参数无论是 enum 还是数值,逻辑上将没有区别,当然这也符合 TypeScript 静态类型检测规则,如下代码所示:

  1. work(Day.SUNDAY);
  2. work(0);

这里我们既可以把枚举成员 Day.SUNDAY 作为 work 函数的入参,也可以把数字字面量 0 作为 work 函数的入参。

下面我们就来详细介绍一下 7 种常见的枚举类型:数字类型、字符串类型、异构类型、常量成员和计算(值)成员、枚举成员类型和联合枚举、常量枚举、外部枚举。

数字枚举

从上边示例可知,在仅仅指定常量命名的情况下,我们定义的就是一个默认从 0 开始递增的数字集合,称之为数字枚举。

如果我们希望枚举值从其他值开始递增,则可以通过 “常量命名 = 数值” 的格式显示指定枚举成员的初始值,如下代码所示:

  1. enum Day {
  2. SUNDAY = 1,
  3. MONDAY,
  4. TUESDAY,
  5. WEDNESDAY,
  6. THURSDAY,
  7. FRIDAY,
  8. SATURDAY
  9. }

在上述示例中,我们指定了从 1 开始递增。

事实上,我们可以给 SUNDAY 指定任意类型(比如整数、负数、小数等)、任意起始的数字,其后未显示指定值的成员会递增加 1。上边的示例转译为 JavaScript 之后,则是一个属性值从 1 开始递增的对象,如下代码所示:

  1. var Day = void 0;
  2. (function (MyDay) {
  3. Day[Day["SUNDAY"] = 1] = "SUNDAY";
  4. Day[Day["MONDAY"] = 2] = "MONDAY";
  5. ...
  6. Day[Day["SATURDAY"] = 7] = "SATURDAY";
  7. })(Day || (Day = {}));

这里 Day.SUNDAY 被赋予了 1 作为值,Day.SATURDAY 则被赋予了 7 作为值。

当然我们也可以给任意位置的成员指定值,如下所示示例:

  1. enum Day {
  2. SUNDAY,
  3. MONDAY,
  4. TUESDAY,
  5. WEDNESDAY,
  6. THURSDAY,
  7. FRIDAY,
  8. SATURDAY = 5
  9. }

这里我们给最后一个成员 SATURDAY 指定了初始值 5,但转译后的结果就比较尴尬了,如下代码所示:

  1. ...
  2. Day[Day["FRIDAY"] = 5] = "FRIDAY";
  3. Day[Day["SATURDAY"] = 5] = "SATURDAY";
  4. ...

我们可以看到 MyDay.FRIDAY 和 MyDay.SATURDAY 的值都是数字 5,这就导致使用 Day 枚举作为 switch 分支条件的函数 work,在接收 MyDay.SATURDAY 作为入参时,也会进入 MyDay.FRIDAY 的分支,从而出现逻辑错误。

这个经验告诉我们,由于枚举默认的值自递增且完全无法保证稳定性,所以给部分数字类型的枚举成员显式指定数值或给函数传递数值而不是枚举类型作为入参都属于不明智的行为,如下代码所示:

  1. enum Day {
  2. ...
  3. SATURDAY = 5
  4. }
  5. work(5);

此外,常量命名、结构顺序都一致的两个枚举,即便转译为 JavaScript 后,同名成员的值仍然一样(满足恒等 === )。但在 TypeScript 看来,它们不相同、不满足恒等,如下代码所示:

  1. enum MyDay {
  2. SUNDAY,
  3. ...
  4. }
  5. Day.SUNDAY === MyDay.SUNDAY;
  6. work(MyDay.SUNDAY);

这里的 MyDay 和上边的 Day 看似一样,但是如果我们拿 MyDay 和 Day 的成员进行比较(第 6 行),或者把 MyDay 传值给形参是 Day 类型的 work 函数(第 7 行),就会发现都会提示错误。

不仅仅是数字类型枚举,所有其他枚举都仅和自身兼容,这就消除了由于枚举不稳定性可能造成的风险,所以这是一种极其安全的设计。不过,这可能会使得枚举变得不那么好用,因为不同枚举之间完全不兼容,所以不少 TypeScript 编程人员觉得枚举类型是一种十分鸡肋的类型。而两个结构完全一样的枚举类型如果互相兼容,则会更符合我们的预期,比如说基于 Swagger 自动生成的不同模块中结构相同且描述同一个常量集合的多个同名枚举。

不过,此时我们可能不得不使用类型断言(as)或者重构代码将 “相同 “的枚举类型抽离为同一个公共的枚举(我们更推荐后者)。

字符串枚举

在 TypeScript 中,我们将定义值是字符串字面量的枚举称之为字符串枚举,字符串枚举转译为 JavaScript 之后也将保持这些值,我们来看下如下所示示例:

  1. enum Day {
  2. SUNDAY = 'SUNDAY',
  3. MONDAY = 'MONDAY',
  4. ...
  5. }

这里我们定义了成员 SUNDAY 的值是’SUNDAY’、MONDAY 的值是’MONDAY’。

而上述示例转译为 JavaScript 后,Day.SUNDAY 的值依旧是’SUNDAY’,Day.MONDAY 的值依旧是’MONDAY’,如下代码所示:

  1. var Day = void 0;
  2. (function (Day) {
  3. Day["SUNDAY"] = "SUNDAY";
  4. Day["MONDAY"] = "MONDAY";
  5. })(Day || (Day = {}));

相比于没有明确意义的递增值的数字枚举,字符串枚举的成员在运行和调试阶段,更具备明确的含义和可读性,枚举成员的值就是我们显式指定的字符串字面量。

异构枚举(Heterogeneous enums)

从技术上来讲,TypeScript 支持枚举类型同时拥有数字和字符类型的成员,这样的枚举被称之为异构枚举。

当然,异构枚举也被认为是很 “鸡肋” 的类型。比如如下示例中,我们定义了成员 SUNDAY 是’SUNDAY’、MONDAY 是 2,很抱歉,我也不知道这样的枚举能在哪些有用的场合进行使用。

  1. enum Day {
  2. SUNDAY = 'SUNDAY',
  3. MONDAY = 2,
  4. ...
  5. }

枚举成员的值既可以是数字、字符串这样的常量,也可以是通过表达式所计算出来的值。这就涉及枚举里成员的一个分类,即常量成员和计算成员。

常量成员和计算(值)成员

在前边示例中,涉及的枚举成员的值都是字符串、数字字面量和未指定初始值从 0 递增数字常量,都被称作常量成员。

另外,在转译时,通过被计算的常量枚举表达式定义值的成员,也被称作常量成员,比如如下几种情况:

  • 引用来自预先定义的常量成员,比如来自当前枚举或其他枚举;
  • 圆括弧 () 包裹的常量枚举表达式;
  • 在常量枚举表达式上应用的一元操作符 +、 -、~ ;
  • 操作常量枚举表达式的二元操作符 +、-、*、/、%、<<、>>、>>>、&、|、^。

除以上这些情况之外,其他都被认为是计算(值)成员。

如下所示示例(援引自官方示例)中,除了 G 是计算成员之外,其他都属于常量成员。

  1. enum FileAccess {
  2. None,
  3. Read = 1 << 1,
  4. Write = 1 << 2,
  5. ReadWrite = Read | Write,
  6. G = "123".length,
  7. }

注意:关于常量成员和计算成员的划分其实比较难理解,实际上它们也并没有太大的用处,只是告诉我们通过这些途径可以定义枚举成员的值。因此,我们只需记住缺省值(从 0 递增)、数字字面量、字符串字面量肯定是常量成员就够了。

枚举成员类型和联合枚举

另外,对于不需要计算(值)的常量类型成员,即缺省值(从 0 递增)、数字字面量、字符串字面量这三种情况(这就是为什么我们只需记住这三种情况),被称之为字面量枚举成员。

前面我们提到枚举值和类型是一体的,枚举成员的类型是枚举类型的子类型。

枚举成员和枚举类型之间的关系分两种情况: 如果枚举的成员同时包含字面量和非字面量枚举值,枚举成员的类型就是枚举本身(枚举类型本身也是本身的子类型);如果枚举成员全部是字面量枚举值,则所有枚举成员既是值又是类型,如下代码所示:

  1. enum Day {
  2. SUNDAY,
  3. MONDAY,
  4. }
  5. enum MyDay {
  6. SUNDAY,
  7. MONDAY = Day.MONDAY
  8. }
  9. const mondayIsDay: Day.MONDAY = Day.MONDAY;
  10. const mondayIsSunday = MyDay.SUNDAY;
  11. const mondayIsMyDay2: MyDay.MONDAY = MyDay.MONDAY;

这里因为 Day 的所有成员都是字面量枚举成员,所以 Day.MONDAY 可以同时作为值和类型使用(第 11 行)。但是 MyDay 的成员 MONDAY 是非字面量枚举成员(但是是常量枚举成员),所以 MyDay.MONDAY 仅能作为值使用(第 12 行 ok,第 13 行提示错误)。

另外,如果枚举仅有一个成员且是字面量成员,那么这个成员的类型等于枚举类型,如下代码所示:

  1. enum Day {
  2. MONDAY
  3. }
  4. export const mondayIsDay: Day = Day.MONDAY;
  5. export const mondayIsDay1: Day.MONDAY = mondayIsDay as Day;

因为枚举 Day 仅包含一个字面量成员 MONDAY,所以类型 Day 和 Day.MONDAY 可以互相兼容。比如第 4 行和第 5 行,我们既能把 Day.MONDAY 类型赋值给 Day 类型,也能把 Day 类型赋值给 Day.MONDAY 类型。

此外,回想 04 讲中介绍的字面量类型特性,不同成员的类型就是不同的字面量类型。纯字面量成员枚举类型也具有字面量类型的特性,也就等价于枚举的类型将变成各个成员类型组成的联合(枚举)类型。

联合类型使得 TypeScript 可以更清楚地枚举集合里的确切值,从而检测出一些永远不会成立的条件判断(俗称 Dead Code),如下所示示例(援引自官方恒为真的示例):

  1. enum Day {
  2. SUNDAY,
  3. MONDAY,
  4. }
  5. const work = (x: Day) => {
  6. if (x !== Day.SUNDAY || x !== Day.MONDAY) {
  7. }
  8. }

在上边示例中,TypeScript 确定 x 的值要么是 Day.SUNDAY,要么是 Day.MONDAY。因为 Day 是纯字面量枚举类型,可以等价地看作联合类型 Day.SUNDAY | Day.MONDAY,所以我们判断出第 7 行的条件语句恒为真,于是提示了一个 ts(2367) 错误。

不过,如果枚举包含需要计算(值)的成员情况就不一样了。如下示例中,TypeScript 不能区分枚举 Day 中的每个成员。因为每个成员类型都是 Day,所以无法判断出第 7 行的条件语句恒为真,也就不会提示一个 ts(2367) 错误。

  1. enum Day {
  2. SUNDAY = +'1',
  3. MONDAY = 'aa'.length,
  4. }
  5. const work = (x: Day) => {
  6. if (x !== Day.SUNDAY || x !== Day.MONDAY) {
  7. }
  8. }

此外,字面量类型所具有的类型推断、类型缩小的特性,也同样适用于字面量枚举类型,如下代码所示:

  1. enum Day {
  2. SUNDAY,
  3. MONDAY,
  4. }
  5. let SUNDAY = Day.SUNDAY;
  6. const SUNDAY2 = Day.SUNDAY;
  7. const work = (x: Day) => {
  8. if (x === Day.SUNDAY) {
  9. x;
  10. }
  11. }

在上述代码中,我们在第 5 行通过 let 定义了一个未显式声明类型的变量 SUNDAY,TypeScript 可推断其类型是 Day;在第 6 行通过 const 定义了一个未显式声明类型的变量 SUNDAY2,TypeScript 可推断其类型是 Day.SUNDAY;在第 8 行的 if 条件判断中,变量 x 类型也从 Day 缩小为 Day.SUNDAY。

常量枚举(const enums)

枚举的作用在于定义被命名的常量集合,而 TypeScript 提供了一些途径让枚举更加易用,比如常量枚举。

我们可以通过添加 const 修饰符定义常量枚举,常量枚举定义转译为 JavaScript 之后会被移除,并在使用常量枚举成员的地方被替换为相应的内联值,因此常量枚举的成员都必须是常量成员(字面量 + 转译阶段可计算值的表达式),如下代码所示:

  1. const enum Day {
  2. SUNDAY,
  3. MONDAY
  4. }
  5. const work = (d: Day) => {
  6. switch (d) {
  7. case Day.SUNDAY:
  8. return 'take a rest';
  9. case Day.MONDAY:
  10. return 'work hard';
  11. }
  12. }
  13. }

这里我们定义了常量枚举 Day,它的成员都是值自递增的常量成员,并且在 work 函数的 switch 分支里引用了 Day。

转译为成 JavaScript 后,Day 枚举的定义就被移除了,work 函数中对 Day 的引用也变成了常量值的引用(第 3 行内联了 0、第 5 行内联了 1),如下代码所示:

  1. var work = function (d) {
  2. switch (d) {
  3. case 0 :
  4. return 'take a rest';
  5. case 1 :
  6. return 'work hard';
  7. }
  8. };

从以上示例我们可以看到,使用常量枚举不仅能减少转译后的 JavaScript 代码量(因为抹除了枚举定义),还不需要到上级作用域里查找枚举定义(因为直接内联了枚举值字面量)。

因此,通过定义常量枚举,我们可以以清晰、结构化的形式维护相关联的常量集合,比如 switch case 分支,使得代码更具可读性和易维护性。而且因为转译后抹除了定义、内联成员值,所以在代码的体积和性能方面并不会比直接内联常量值差。

外部枚举(Ambient enums)

在 TypeScript 中,我们可以通过 declare 描述一个在其他地方已经定义过的变量,如下代码所示:

  1. declare let $: any;
  2. $('#id').addClass('show');

第 1 行我们使用 declare 描述类型是 any 的外部变量 $,在第 2 行则立即使用 $ ,此时并不会提示一个找不到 $ 变量的错误。

同样,我们也可以使用 declare 描述一个在其他地方已经定义过的枚举类型,通过这种方式定义出来的枚举类型,被称之为外部枚举,如下代码所示:

  1. declare enum Day {
  2. SUNDAY,
  3. MONDAY,
  4. }
  5. const work = (x: Day) => {
  6. if (x === Day.SUNDAY) {
  7. x;
  8. }
  9. }

这里我们认定在其他地方已经定义了一个 Day 这种结构的枚举,且 work 函数中使用了它。

转译为 JavaScript 之后,外部枚举的定义也会像常量枚举一样被抹除,但是对枚举成员的引用会被保留(第 2 行保留了对 Day.SUNDAY 的引用),如下代码所示:

  1. var work = function (x) {
  2. if (x === Day.SUNDAY) {
  3. x;
  4. }
  5. };

外部枚举和常规枚举的差异在于以下几点:

  • 在外部枚举中,如果没有指定初始值的成员都被当作计算(值)成员,这跟常规枚举恰好相反;
  • 即便外部枚举只包含字面量成员,这些成员的类型也不会是字面量成员类型,自然完全不具备字面量类型的各种特性。

我们可以一起使用 declare 和 const 定义外部常量枚举,使得它转译为 JavaScript 之后仍像常量枚举一样。在抹除枚举定义的同时,我们可以使用内联枚举值替换对枚举成员的引用。

外部枚举的作用在于为两个不同枚举(实际上是指向了同一个枚举类型)的成员进行兼容、比较、被复用提供了一种途径,这在一定程度上提升了枚举的可用性,让其显得不那么 “鸡肋”。

小结与预告

以上就是 “鸡肋” 枚举的全部内容,下面我们提炼一下核心的几个知识点和建议:

  1. 使用常量枚举管理相关的常量,能提高代码的可读性和易维护性;
  2. 不要使用其他任何类型替换所使用的枚举成员;

下面我们插播一个思考题,也是这一讲的核心点:枚举有什么特性?常量枚举有什么特性?欢迎你在留言区进行互动、交流。

10 讲我们将学习 TypeScript 最有意思的类型——泛型,敬请期待!

另外,如果你觉得本专栏有价值,欢迎分享给更多好友。