TypeScript Enum (枚举):基础、类型、用法与最佳实践 – wiki基地


TypeScript Enum (枚举):基础、类型、用法与最佳实践

在软件开发中,我们经常需要使用一组具有特定含义的命名常量来表示某个状态、选项或类别。例如,表示一周的七天、交通信号灯的状态(红、黄、绿)、用户权限级别等。在传统的 JavaScript 中,我们通常使用字面量(字符串或数字)或者简单的对象来定义这些常量。然而,这种方式缺乏类型安全,容易引入“魔术字符串”(magic strings)或“魔术数字”(magic numbers),降低了代码的可读性和可维护性。

为了解决这个问题,TypeScript 引入了 enum(枚举)类型。枚举是一种为一组数值赋予友好名称的方式。它允许我们定义一个包含命名常量的集合,使代码更具表现力、更易于理解,并提供了编译时的类型检查。

本文将深入探讨 TypeScript 枚举的各个方面,包括其基础语法、不同类型的枚举(数字枚举、字符串枚举、常量枚举)、常见用法、编译后的 JavaScript 输出,以及在实际开发中的最佳实践和与联合类型等替代方案的比较。

1. 枚举的基础:什么是 Enum?

枚举(Enum),全称 enumerated type,是一种由用户定义的数据类型,它由一组命名常量组成。在 TypeScript 中,枚举提供了一种组织代码中相关常量的方式。

为什么使用枚举?

  1. 提高可读性: 使用具有描述性的枚举成员名称代替原始的数字或字符串字面量,代码意图更清晰。例如,TrafficLight.Red0"RED" 更容易理解。
  2. 增强可维护性: 如果需要修改常量的值,只需在一处更改枚举定义即可,无需在整个代码库中查找和替换。
  3. 提供类型安全: TypeScript 的类型系统可以检查使用枚举的地方,确保只使用了枚举中定义的有效值,避免传入错误的常量。

2. 枚举的声明与基础用法

声明一个枚举非常简单,使用 enum 关键字后跟枚举名称和花括号括起来的成员列表。

“`typescript
// 定义一个表示一周的天的枚举
enum Day {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}

// 使用枚举成员
let today: Day = Day.Wednesday;

console.log(today); // 输出:3
console.log(Day.Friday); // 输出:5
“`

默认行为(数字枚举)

在上面的例子中,我们没有给枚举成员赋值。默认情况下,TypeScript 枚举是基于数字的,第一个成员的默认值是 0,之后每个成员的值会自动递增 1

Day 枚举的成员值默认如下:

  • Day.Monday 对应 0
  • Day.Tuesday 对应 1
  • Day.Wednesday 对应 2
  • Day.Sunday 对应 6

我们可以通过成员名称访问成员的值:Day.Wednesday 返回 2

自定义起始值

你可以为枚举的第一个成员指定一个起始数值,后续成员的值会基于此自动递增。

“`typescript
enum StatusCode {
NotFound = 404,
InternalServerError = 500,
OK = 200, // OK 在最后,但其值是明确指定的
BadRequest, // BadRequest 会在 OK 之后,其值是 200 + 1 = 201 (如果前面是数字且未赋值,则递增)
}

console.log(StatusCode.NotFound); // 输出:404
console.log(StatusCode.BadRequest); // 输出:201
“`

注意:如果在一个数字枚举中,你为某个成员赋值,后续未赋值的成员会从该成员的值继续递增。但在上面的 StatusCode 例子中,我们在中间指定了 OK = 200,而 BadRequest 在它 之后 且未赋值,所以 BadRequest 会是 201。这是一个容易混淆的点,通常建议要么全都不赋值让它从 0 开始,要么为每个成员都指定一个明确的数字值,或者使用字符串枚举避免这种隐式递增。

为所有成员指定值

为了代码的清晰和避免隐式递增带来的潜在问题,尤其是在非连续的场景下,通常建议为每个成员都指定一个明确的值。

“`typescript
enum FileState {
Pending = 1,
Processing = 2,
Completed = 4,
Failed = 8,
}

console.log(FileState.Completed); // 输出:4
“`

3. 枚举的类型

TypeScript 提供了几种不同类型的枚举,以适应不同的使用场景和需求:

  • 数字枚举 (Numeric Enums)
  • 字符串枚举 (String Enums)
  • 异构枚举 (Heterogeneous Enums)
  • 常量枚举 (Const Enums)

3.1 数字枚举 (Numeric Enums)

这是最常见的类型,也是默认类型。成员值是数字。

特点:

  • 默认从 0 开始递增。
  • 可以自定义起始值或为部分/全部成员指定数字值。
  • 支持“反向映射”(Reverse Mapping)。

反向映射 (Reverse Mapping)

数字枚举的一个独特特性是,除了可以通过成员名称访问其值(Day.Monday -> 0),还可以通过成员值访问其名称(0 -> "Monday")。

“`typescript
enum Day {
Monday, // 0
Tuesday, // 1
Wednesday, // 2
}

let dayValue = 1;
let dayName = Day[dayValue]; // 通过值获取名称

console.log(dayName); // 输出: “Tuesday”

let anotherDayName = Day[0];
console.log(anotherDayName); // 输出: “Monday”
“`

这种反向映射在某些场景下很有用,例如,当从后端 API 接收到代表状态的数字代码时,你可能想将其转换为一个可读的名称。

编译后的 JavaScript (数字枚举)

理解枚举在编译后生成的 JavaScript 对于理解其运行时行为和性能非常重要。

考虑这个枚举:

typescript
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}

编译后的 JavaScript 大致如下:

javascript
var Direction;
(function (Direction) {
Direction[Direction["Up"] = 0] = "Up";
Direction[Direction["Down"] = 1] = "Down";
Direction[Direction["Left"] = 2] = "Left";
Direction[Direction["Right"] = 3] = "Right";
})(Direction || (Direction = {}));

可以看到,编译后生成了一个 JavaScript 对象 Direction。这个对象同时存储了名称到值的映射(Direction["Up"] = 0)和值到名称的映射(Direction[0] = "Up")。这就是反向映射的实现原理。

这意味着数字枚举在运行时是真实存在的对象,会占用一定的内存和处理时间来创建。

3.2 字符串枚举 (String Enums)

字符串枚举的成员值必须是字符串字面量或其他字符串枚举成员。

“`typescript
enum Direction {
Up = “UP”,
Down = “DOWN”,
Left = “LEFT”,
Right = “RIGHT”,
}

let move: Direction = Direction.Up;
console.log(move); // 输出: “UP”
“`

特点:

  • 必须为每个成员明确指定字符串值。
  • 不支持反向映射。通过值无法获取成员名称。
  • 在运行时,只创建名称到值的映射对象。

编译后的 JavaScript (字符串枚举)

考虑这个字符串枚举:

typescript
enum Direction {
Up = "UP",
Down = "DOWN",
}

编译后的 JavaScript 大致如下:

javascript
var Direction;
(function (Direction) {
Direction["Up"] = "UP";
Direction["Down"] = "DOWN";
})(Direction || (Direction = {}));

与数字枚举不同,字符串枚举编译后的对象只包含名称到值的映射。这是因为字符串值本身并不适合作为对象的属性名(特别是当它们包含特殊字符或是非法标识符时),而且通过字符串值来查找其对应的名称的场景相对较少,或者可以使用其他数据结构(如 Map)来实现。

字符串枚举的优点:

  • 更好的可读性:在调试或日志中,直接看到有意义的字符串值比数字更直观。
  • 避免魔术字符串:将所有相关的字符串常量集中管理。

3.3 异构枚举 (Heterogeneous Enums)

异构枚举是混合了数字和字符串成员的枚举。

“`typescript
enum Status {
Loading = 0,
Success = “SUCCESS”,
Error = 1, // 允许混合
Idle = “IDLE”
}

console.log(Status.Loading); // 0
console.log(Status.Success); // “SUCCESS”
console.log(Status[0]); // “Loading” (数字成员支持反向映射)
// console.log(Status[“SUCCESS”]); // 错误!字符串成员不支持反向映射
“`

特点:

  • 可以包含数字和字符串成员。
  • 数字成员支持反向映射,字符串成员不支持。

建议:

异构枚举可能会导致代码混淆,降低可读性。在绝大多数情况下,建议避免使用异构枚举,保持枚举成员类型的单一性(要么全数字,要么全字符串)。

3.4 常量枚举 (Const Enums)

常量枚举是 TypeScript 提供的一种特殊的枚举类型,它以 const enum 关键字声明。

“`typescript
const enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}

let go: Direction = Direction.Up;

console.log(go); // 在编译后会被替换为实际值 0
console.log(Direction.Left); // 在编译后会被替换为实际值 2
“`

特点:

  • 在编译阶段会被完全移除。
  • 枚举成员的使用会被其对应的实际值(数字或字符串字面量)直接替换(内联)。
  • 不支持反向映射
  • 不能包含计算成员(后面会讲到)。
  • 不能在运行时动态访问(例如 Direction[key]Object.keys(Direction) 都会报错)。

编译后的 JavaScript (常量枚举)

考虑这个常量枚举:

“`typescript
const enum Direction {
Up,
Down,
}

let goUp = Direction.Up;
let goDown = Direction.Down;
“`

编译后的 JavaScript 会非常简洁:

javascript
var goUp = 0 /* Direction.Up */;
var goDown = 1 /* Direction.Down */;

可以看到,const enum Direction 本身并没有生成任何 JavaScript 代码,对 Direction.UpDirection.Down 的引用直接被它们的字面量值 01 替换了。注释 /* Direction.Up */ 是 TypeScript 编译器为了帮助调试而添加的。

常量枚举的优点:

  • 性能最优: 由于编译后被完全移除,运行时没有额外的对象创建和查找开销。
  • 生成代码量最小: 特别是在多次引用枚举成员时,生成的 JS 代码会比普通枚举小。

常量枚举的缺点:

  • 失去运行时对象: 如果需要在运行时通过枚举对象进行迭代(例如 Object.keys(Direction))或通过值查找名称(反向映射),则常量枚举无法做到。
  • 兼容性: 某些较旧的 TypeScript 工具链或打包工具可能对常量枚举的支持不够完善(虽然现在大部分工具都支持了)。

何时使用常量枚举?

当你只需要在编译时利用枚举进行类型检查和常量替换,而不需要在运行时访问枚举对象或进行反向映射时,常量枚举是最佳选择。这通常是大多数简单常量集的场景。

4. 进阶用法与注意事项

4.1 计算成员 (Computed Members)

枚举成员的值可以是常量表达式(编译时可以确定的值),也可以是计算得出的值。

“`typescript
enum FileAccess {
// 常量成员
None, // 0
Read = 1 << 1, // 2
Write = 1 << 2, // 4
ReadWrite = Read | Write, // 2 | 4 = 6
// 计算成员 (需要调用函数或其他运行时操作)
GUESSED = 3 + 1, // 4 – 这是一个常量表达式
Folder = “folder”.length, // 6 – 这也是一个常量表达式
// 这是一个真正的计算成员
// 但是如果Folder是computed,后面的成员若未赋值,则也必须赋值
// Folder = getFolderLength(), // 假设 getFolderLength 是一个运行时函数
}

// 注意:计算成员必须显式赋值。
// 如果枚举中包含计算成员,则其后的所有成员都必须显式赋值,直到遇到另一个常量成员。

function getComputedValue(): number {
return Math.random() > 0.5 ? 10 : 20;
}

enum ComputedExample {
A = 1, // 常量成员
B = A * 2, // 常量表达式 (B=2)
C = getComputedValue(), // 计算成员 – 需要运行时计算
D = 40, // C 是计算成员,所以 D 必须显式赋值
E, // 错误!C 是计算成员,E 未显式赋值
}
“`

常量枚举与计算成员:

常量枚举不允许包含计算成员。所有成员的值必须是编译时可确定的常量表达式。

typescript
const enum InvalidConstEnum {
A = getComputedValue(), // 错误!常量枚举不能包含计算成员
}

4.2 在运行时访问枚举成员

如前所述,数字和字符串枚举在编译后会生成一个 JavaScript 对象,因此可以在运行时进行访问。

“`typescript
enum Status {
Loading = “LOADING”,
Success = “SUCCESS”,
Error = “ERROR”,
}

// 获取所有成员名称 (key)
let statusKeys = Object.keys(Status);
console.log(statusKeys); // 输出: [“Loading”, “Success”, “Error”]

// 获取所有成员值 (value)
// 对于数字枚举,需要过滤掉反向映射生成的数字键
function getEnumValues>(e: T): (T[keyof T])[] {
return Object.values(e).filter(v => typeof v !== ‘number’); // 过滤掉数字键(反向映射)
}

function getNumericEnumValues>(e: T): number[] {
return Object.values(e).filter(v => typeof v === ‘number’ && Object.keys(e).map(k => e[k]).includes(v)) as number[];
}

function getEnumNames>(e: T): string[] {
return Object.keys(e).filter(key => isNaN(Number(key))); // 过滤掉数字键名(反向映射)
}

// 字符串枚举
let statusValues = getEnumValues(Status);
console.log(statusValues); // 输出: [“LOADING”, “SUCCESS”, “ERROR”]

// 数字枚举
enum Code {
OK = 200,
BadRequest = 400
}
let codeNames = getEnumNames(Code);
console.log(codeNames); // 输出: [“OK”, “BadRequest”]

let codeValues = getNumericEnumValues(Code); // 或者使用 Object.values().filter(…)
console.log(codeValues); // 输出: [200, 400]
“`

常量枚举无法进行上述操作,因为在运行时不存在对应的 JavaScript 对象。

5. 最佳实践与替代方案

虽然枚举非常有用,但并非所有场景都适合使用它。在 TypeScript 中,联合类型 (Union Types) 和字面量类型 (Literal Types) 结合 const 对象是另一种表示固定常量集合的强大方式,而且在某些方面优于枚举。

5.1 枚举 vs. 联合类型 + 字面量类型

考虑表示交通信号灯状态的需求:

使用 Enum:

“`typescript
enum TrafficLight {
Red = “RED”,
Yellow = “YELLOW”,
Green = “GREEN”,
}

function changeLight(status: TrafficLight) {
console.log(Changing light to: ${status});
}

changeLight(TrafficLight.Red); // OK
// changeLight(“BLUE”); // 错误!提供了字符串,但需要 TrafficLight 类型
“`

使用 Union Type + Literal Type + const 对象:

“`typescript
const TrafficLightStatus = {
Red: “RED”,
Yellow: “YELLOW”,
Green: “GREEN”,
} as const; // ‘as const’ 是关键,它告诉 TS 编译器将这个对象及其属性视为字面量类型

// 定义一个联合类型,包含所有可能的状态值
type TrafficLight = typeof TrafficLightStatus[keyof typeof TrafficLightStatus];
// 等价于 type TrafficLight = “RED” | “YELLOW” | “GREEN”;

function changeLight(status: TrafficLight) {
console.log(Changing light to: ${status});
}

changeLight(TrafficLightStatus.Yellow); // OK
// changeLight(“BLUE”); // 错误!”BLUE” 不是 “RED” | “YELLOW” | “GREEN” 类型

// 可以直接使用字面量
changeLight(“GREEN”); // OK
“`

比较:

特性 普通 Enum (数字/字符串) 常量 Enum (const enum) Union Type + Literal Type + const Object
运行时 存在一个 JS 对象 被编译时替换为字面量,无运行时对象 const 对象存在于运行时,类型在编译时
编译产物 生成一个 JS 对象(含反向映射对于数字) 无额外代码,直接内联字面量 生成一个 JS 对象
反向映射 数字枚举支持,字符串枚举不支持 不支持 不支持 (可以通过 const 对象查找)
动态访问 支持 (Object.keys, Enum[value]) 不支持 支持 (Object.keys, Object.values)
类型安全
代码体积 较大 (生成 JS 对象) 最小 (直接替换) 较小 (只生成一个对象)
计算成员 支持 (后面的成员需显式赋值) 不支持 不适用 (对象属性值可以是计算结果)
使用方式 Enum.Member Enum.Member ConstObject.Member 或 直接字面量
灵活性 较好 (反向映射等) 简单常量集最优 优秀 (可以组合更复杂的类型,如 { status: "LOADING", data: ... })
可读性 使用命名常量,高 使用命名常量,高 使用命名常量,高,字面量本身也更直观

何时选择哪种方式?

  • 使用 Enum (特别是数字或字符串枚举):
    • 当你需要反向映射功能时(通常是数字枚举)。
    • 当你希望将相关常量组织成一个单一的命名空间对象,并在运行时访问它(例如,遍历所有可能的枚举值)。
    • 当你从其他语言(如 C#、Java)背景转来,习惯使用枚举的概念时。
  • 使用 Const Enum:
    • 当你只需要利用枚举进行类型检查和编译时常量替换,追求极致的代码体积和运行时性能,且不需要运行时访问枚举对象或反向映射时。这是表示简单固定常量集的常见且高效方式。
  • 使用 Union Type + Literal Type + const Object:
    • 当你不关心反向映射或运行时枚举对象。
    • 当你更喜欢使用字面量值本身,或者需要将这些字面量值与其他类型结合使用(例如在联合类型中)。
    • 当你需要更灵活的类型定义,例如 { status: "LOADING" } | { status: "SUCCESS", data: any } 这种区分不同状态下数据结构的场景,枚举难以表达这种复杂性。
    • 当你追求最小的运行时开销(与 const enum 类似,但保留了运行时对象用于其他目的)。
    • 当你在构建库时,希望暴露的类型是字面量联合,而不是一个枚举对象(避免消费者依赖你的枚举对象结构)。

在许多现代 TypeScript 项目中,倾向于使用 Union Type + Literal Type + const Object 的模式来代替简单的字符串枚举,因为它提供了类似的类型安全和可读性,同时没有运行时枚举对象的开销(除非你需要该对象),并且与 TypeScript 的其他高级类型特性(如联合类型、判别联合)结合得更好。但数字枚举的反向映射功能依然是其独特的优势。

5.2 命名规范

  • 枚举名称通常使用单数 PascalCase (驼峰命名法),如 DirectionFileState
  • 枚举成员名称通常使用PascalCase,如 Monday, Up, NotFound。虽然历史上也常使用 UPPER_CASE (大写蛇形命名法) 来表示常量,如 MONDAY, UP, NOT_FOUND,但在 TypeScript 的官方文档和许多现代项目中,PascalCase 更常见。选择一种风格并在项目中保持一致即可。

5.3 避免常见陷阱

  • 数字枚举的隐式递增: 要么不指定值让它从 0 开始连续递增,要么为所有成员指定明确的值,避免在中间指定值然后依赖后面的隐式递增,这容易出错。
  • 数字枚举的反向映射: 意识到反向映射是存在的,并且会在编译后的 JS 对象中增加额外的属性,这会增加代码体积和少量运行时开销。如果不需要反向映射,考虑使用字符串枚举或 const enum
  • 常量枚举的限制: 记住 const enum 在运行时不存在,不能动态访问,也不能包含计算成员。不要试图对常量枚举执行 Object.keys() 等操作。
  • 异构枚举的混淆: 尽量避免使用异构枚举,保持枚举成员类型的统一。

6. 总结

TypeScript 的 enum 类型为我们提供了一种优雅的方式来定义和使用命名常量,显著提高了代码的可读性、可维护性和类型安全性。

  • 数字枚举是默认类型,支持反向映射,在编译时生成一个包含正向和反向映射的 JS 对象。
  • 字符串枚举成员值必须是字符串,不支持反向映射,编译时生成一个只包含正向映射的 JS 对象。提供更好的运行时可读性。
  • 异构枚举混合数字和字符串,不推荐使用。
  • 常量枚举使用 const enum 声明,在编译时被完全移除,成员使用被内联替换为字面量。提供最优的性能和代码体积,但不支持运行时访问和反向映射,也不能有计算成员。

在选择使用哪种枚举类型或是否使用枚举(转而使用联合类型+字面量+const对象)时,需要权衡它们各自的特点、编译行为以及你的具体需求:

  • 需要反向映射 -> 数字枚举
  • 需要运行时对象访问 (迭代、动态查找) -> 数字枚举字符串枚举
  • 需要最简洁的编译输出和最高性能,且不需要运行时对象 -> 常量枚举
  • 需要字符串值在运行时更直观,且不需要反向映射 -> 字符串枚举Union Type + Literal Type + const Object
  • 需要更灵活的类型组合或偏好使用字面量本身 -> Union Type + Literal Type + const Object

理解枚举的编译原理是正确使用它的关键。通过合理地选择和使用不同类型的枚举或其替代方案,我们可以编写出更健壮、更易于理解和维护的 TypeScript 代码。


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部