枚举
枚举是 TypeScript 拥有的少数几个特性之一,它不是 JavaScript 的类型层面扩展。
枚举允许开发者定义一组命名常量。 使用枚举可以更容易地记录意图,或者创建一组不同的情况。 TypeScript 提供数字和字符串两种类型的枚举。
数字枚举
我们将首先从数字枚举开始,如果你从其他语言过来,可能会更熟悉它们。 枚举可以使用 enum 关键字来定义。
上面,我们有一个数字枚举,其中 Up 被初始化为 1。 之后的所有成员都会自动递增。 换句话说,Direction.Up 的值是 1,Down 是 2,Left 是 3,Right 是 4。
如果我们愿意,完全可以省略初始化器:
这里,Up 的值是 0,Down 是 1,依此类推。 这种自动递增的行为对于我们不关心成员本身的值,但关心每个值与同一枚举中的其他值不同的情况很有用。
使用枚举很简单:只需将任何成员作为枚举本身的属性来访问,并使用枚举的名称来声明类型:
enum UserResponse {
No = 0,
Yes = 1,
}
function respond(recipient: string, message: UserResponse): void {
// ...
}
respond("Princess Caroline", UserResponse.Yes);Try数字枚举可以与计算成员和常量成员(见下文)混合使用。 简单来说,没有初始化器的枚举要么必须是第一个,要么必须跟在用数字常量或其他常量枚举成员初始化的数字枚举之后。 换句话说,以下是不允许的:
字符串枚举
字符串枚举是一个类似的概念,但有一些细微的运行时差异,如下所述。 在字符串枚举中,每个成员都必须用字符串字面量或另一个字符串枚举成员进行常量初始化。
虽然字符串枚举没有自动递增的行为,但字符串枚举的好处是它们“序列化”得很好。 换句话说,如果你在调试时不得不读取数字枚举的运行时值,该值通常是不透明的——它本身并不传达任何有意义的含义(尽管反向映射通常可以帮助)。字符串枚举允许你在代码运行时提供一个有意义且可读的值,独立于枚举成员本身的名称。
异构枚举
从技术上讲,枚举可以混合字符串和数字成员,但不清楚为什么要这样做:
除非你真的试图以巧妙的方式利用 JavaScript 的运行时行为,否则建议你不要这样做。
计算成员和常量成员
每个枚举成员都有一个与之关联的值,该值可以是常量或计算的。 如果满足以下条件,则枚举成员被视为常量:
它是枚举中的第一个成员且没有初始化器,此时它被赋值为
0:它没有初始化器,并且前一个枚举成员是数字常量。 在这种情况下,当前枚举成员的值将是前一个枚举成员的值加一。
枚举成员使用常量枚举表达式初始化。 常量枚举表达式是可以在编译时完全求值的 TypeScript 表达式的子集。 如果表达式满足以下条件,则它是常量枚举表达式:
- 字面量枚举表达式(基本上是字符串字面量或数字字面量)
- 对先前定义的常量枚举成员的引用(可以来自不同的枚举)
- 带括号的常量枚举表达式
- 应用于常量枚举表达式的
+、-、~一元运算符之一 - 以常量枚举表达式作为操作数的
+、-、*、/、%、<<、>>、>>>、&、|、^二元运算符
如果常量枚举表达式求值为
NaN或Infinity,则是编译时错误。
在所有其他情况下,枚举成员被视为计算成员。
enum FileAccess {
// 常量成员
None,
Read = 1 << 1,
Write = 1 << 2,
ReadWrite = Read | Write,
// 计算成员
G = "123".length,
}Try联合枚举与枚举成员类型
存在一个特殊的常量枚举成员子集,它们不是计算出来的:字面量枚举成员。 字面量枚举成员是没有初始化值的常量枚举成员,或者被初始化为以下值的常量枚举成员:
- 任何字符串字面量(例如
"foo"、"bar"、"baz") - 任何数字字面量(例如
1、100) - 应用于任何数字字面量的一元减号(例如
-1、-100)
当枚举中的所有成员都具有字面量枚举值时,一些特殊的语义就会生效。
首先是枚举成员也成为了类型! 例如,我们可以说某些成员只能具有枚举成员的值:
enum ShapeKind {
Circle,
Square,
}
interface Circle {
kind: ShapeKind.Circle;
radius: number;
}
interface Square {
kind: ShapeKind.Square;
sideLength: number;
}
let c: Circle = {
kind: ShapeKind.Square, radius: 100,
};Try另一个变化是枚举类型本身实际上成为了每个枚举成员的联合。 有了联合枚举,类型系统就能够利用它知道枚举本身中存在的确切值集这一事实。 正因为如此,TypeScript 可以捕获我们可能错误比较值的 bug。 例如:
在该示例中,我们首先检查了 x 不是 E.Foo。 如果该检查成功,那么我们的 || 将短路,if 的代码块将被执行。 但是,如果检查不成功,那么 x 只能是 E.Foo,所以检查它是否不等于 E.Bar 是没有意义的。
运行时的枚举
枚举是运行时存在的真实对象。 例如,以下枚举
实际上可以传递给函数
enum E {
X,
Y,
Z,
}
function f(obj: { X: number }) {
return obj.X;
}
// 有效,因为 'E' 有一个名为 'X' 的属性,其类型为 number。
f(E);Try编译时的枚举
尽管枚举是运行时存在的真实对象,但 keyof 关键字的行为与你对典型对象的期望不同。相反,使用 keyof typeof 来获取表示所有枚举键作为字符串的类型。
enum LogLevel {
ERROR,
WARN,
INFO,
DEBUG,
}
/**
* 等价于:
* type LogLevelStrings = 'ERROR' | 'WARN' | 'INFO' | 'DEBUG';
*/
type LogLevelStrings = keyof typeof LogLevel;
function printImportant(key: LogLevelStrings, message: string) {
const num = LogLevel[key];
if (num <= LogLevel.WARN) {
console.log("Log level key is:", key);
console.log("Log level value is:", num);
console.log("Log level message is:", message);
}
}
printImportant("ERROR", "This is a message");Try反向映射
除了创建具有成员属性名称的对象之外,数字枚举成员还会获得从枚举值到枚举名称的反向映射。 例如,在此示例中:
TypeScript 将其编译为以下 JavaScript:
"use strict";
var Enum;
(function (Enum) {
Enum[Enum["A"] = 0] = "A";
})(Enum || (Enum = {}));
let a = Enum.A;
let nameOfA = Enum[a]; // "A"
Try在此生成的代码中,枚举被编译成一个对象,该对象存储正向(name -> value)和反向(value -> name)映射。 对其他枚举成员的引用总是作为属性访问发出,永远不会被内联。
请记住,字符串枚举成员根本不会生成反向映射。
const 枚举
在大多数情况下,枚举是一个完全有效的解决方案。 然而,有时要求更严格。 为了避免支付额外生成的代码和在访问枚举值时额外的间接开销,可以使用 const 枚举。 常量枚举是通过在枚举上使用 const 修饰符定义的:
常量枚举只能使用常量枚举表达式,与常规枚举不同,它们在编译期间被完全移除。 常量枚举成员在使用点处被内联。 这是可能的,因为常量枚举不能有计算成员。
const enum Direction {
Up,
Down,
Left,
Right,
}
let directions = [
Direction.Up,
Direction.Down,
Direction.Left,
Direction.Right,
];Try在生成的代码中会变成
"use strict";
let directions = [
0 /* Direction.Up */,
1 /* Direction.Down */,
2 /* Direction.Left */,
3 /* Direction.Right */,
];
Tryconst 枚举的陷阱
内联枚举值一开始很简单,但会带来微妙的后果。 这些陷阱仅与环境常量枚举(基本上是 .d.ts 文件中的常量枚举)以及项目之间共享它们有关,但如果你发布或使用 .d.ts 文件,这些陷阱很可能适用于你,因为 tsc --declaration 将 .ts 文件转换为 .d.ts 文件。
- 由于
isolatedModules文档 中列出的原因,该模式与环境常量枚举根本不相容。 这意味着如果你发布环境常量枚举,下游消费者将无法同时使用isolatedModules和这些枚举值。 - 你很容易在编译时将依赖项 A 版本的值内联,并在运行时导入 B 版本。 如果你不小心,A 和 B 版本的枚举可能具有不同的值,从而导致令人惊讶的 bug,例如走错
if语句的分支。 这些 bug 尤其有害,因为通常在项目构建的同一时间运行自动化测试,使用相同的依赖版本,这完全无法捕获这些 bug。 importsNotUsedAsValues: "preserve"不会删除用作值的常量枚举的导入,但环境常量枚举不保证运行时.js文件存在。 无法解析的导入会导致运行时错误。 目前,通常明确删除导入的方式,类型导入,不允许使用 const 枚举值。
以下是避免这些陷阱的两种方法:
完全不要使用 const 枚举。 你可以借助 linter 轻松禁止 const 枚举。 显然,这避免了任何与 const 枚举相关的问题,但会阻止你的项目内联自己的枚举。 与内联其他项目的枚举不同,内联项目自身的枚举不会产生问题,并且具有性能影响。
不要发布环境常量枚举,而是借助
preserveConstEnums将其去 const 化。 这是 TypeScript 项目本身内部采用的方法。preserveConstEnums为 const 枚举生成的 JavaScript 与普通枚举相同。 然后你可以在构建步骤中安全地从.d.ts文件中去除const修饰符。这样下游消费者不会内联你项目中的枚举,从而避免上述陷阱,而项目仍然可以内联自己的枚举,这与完全禁止 const 枚举不同。
环境枚举
环境枚举用于描述已存在的枚举类型的形状。
环境枚举和非环境枚举之间的一个重要区别是,在常规枚举中,如果前一个枚举成员被认为是常量,则没有初始化器的成员将被视为常量。 相比之下,没有初始化器的环境(且非 const)枚举成员总是被视为计算成员。
对象 vs 枚举
在现代 TypeScript 中,当带有 as const 的对象足够用时,你可能不需要枚举:
const enum EDirection {
Up,
Down,
Left,
Right,
}
const ODirection = {
Up: 0,
Down: 1,
Left: 2,
Right: 3,
} as const;
EDirection.Up;
ODirection.Up;
// 使用枚举作为参数
function walk(dir: EDirection) {}
// 需要额外的一行来提取值
type Direction = typeof ODirection[keyof typeof ODirection];
function run(dir: Direction) {}
walk(EDirection.Left);
run(ODirection.Right);Try支持这种格式而不是 TypeScript 的 enum 的最大理由是,它使你的代码库与 JavaScript 的状态保持一致,并且当/如果枚举被添加到 JavaScript 中时,你可以迁移到额外的语法。