TypeScript 枚举:从基础语法到项目实战

262 阅读13分钟

在开发实践中,我们经常需要处理一组固定集合的常量值,如状态或类型标识等。很多时候,接口返回的数据并不会直接返回文本,而是使用数字来表示。例如,任务状态 taskStatus 有 "进行中"、"已完成" 和 "已终止" 这 3 种类型,后端接口通过数字 0、1、2 来表示这三种状态,如果前端直接使用这些数字值做一些逻辑判断,就会导致"硬编码",降低代码的可读性并增加维护成本。

具体来讲:

if (taskStatus === 0) {
  // 显示按钮
}

在上述代码中,0 被直接作为任务状态 "进行中" 的标识,这就是硬编码。

这样的做法使得代码的含义变得不清晰,尤其是当业务逻辑复杂时。如果代码中到处都充斥着硬编码,后续再看到这段逻辑时会变得不易理解。而且,如果任务状态的值发生变化,比如将 "进行中" 的状态改为 10,开发者就需要手动查找并修改所有出现 0 的地方,增加了维护的难度和成本。

为了提高代码的可读性和可维护性,我们通常希望用有意义的名称来替代这些常量值。TypeScript 枚举可以帮助我们解决这个问题。

1. 枚举简介

枚举(Enum)是 TypeScript 中的一种特殊数据类型,用于表示一组命名的常量。它允许我们将常量值与易于理解的名称进行关联,使得代码更加语义化,减少硬编码,从而提高代码的可读性。除此之外,还能有效提升代码的可维护性,当需要修改常量值时只需在枚举定义处进行统一调整即可实现全局更新。

2. 基本语法

TypeScript 通过 enum 关键字来定义枚举类型,开发者可以为每个枚举成员指定一个数字、字符串甚至其他类型的值。举一个简单的例子,定义一个表示任务状态的枚举类型 TaskStatus

enum TaskStatus {
  Pending,      // 等待中
  InProgress,   // 进行中
  Completed,    // 已完成
  Terminated,   // 已终止
  Cancelled     // 已取消
}

我们可以通过点号或方括号的形式来访问对应的枚举值:

console.log(Days.Pending); 
console.log(Days["Pending"]); 

2.1. 数字枚举

数字枚举是 TypeScript 中最常见的一种枚举类型,它的枚举成员的值都是数字。

2.1.1. 枚举值自增长

数字枚举具有枚举值自增长的行为,其枚举成员的值可以通过数字自动递增,因此可以不用显示的手动赋值。

比如,在枚举类型 TaskStatus 这个例子中,每个枚举成员默认会被赋予从 0 开始递增的数字值,如果按顺序输出成员 Pending、InProgress、Completed、Terminated、Cancelled 的值,得到的结果是 0 - 4 这 5 个数字。这就是枚举值的自增长行为:

  1. 当没有为第一个枚举成员显式指定值时,第一个成员默认是 0,后续依次递增。
enum TaskStatus {
  Pending,      // 默认值为 0
  InProgress,   // 默认值为 1
  Completed,    // 默认值为 2
  Terminated,   // 默认值为 3
  Cancelled		  // 默认值为 4
}

console.log(TaskStatus.Pending);     // 0
console.log(TaskStatus.InProgress);  // 1
console.log(TaskStatus.Completed);   // 2
console.log(TaskStatus.Terminated);  // 3
console.log(TaskStatus.Cancelled);   // 4
  1. 如果枚举成员显式赋值为数字类型,那么后续成员会在此基础上递增。
enum TaskStatus {
  Pending = 10,    // 显式指定为 10
  InProgress,      // 自动递增为 11
  Completed = 20,  // 显式指定为 20
  Terminated,      // 自动递增为 21
  Cancelled				 // 自动递增为 22
}

console.log(TaskStatus.Pending);     // 10
console.log(TaskStatus.InProgress);  // 11
console.log(TaskStatus.Completed);   // 20
console.log(TaskStatus.Terminated);  // 21
console.log(TaskStatus.Cancelled);   // 22

2.1.2. 反向映射

数字枚举还有一个独特的特性:反向映射。这意味着不仅可以通过枚举的名字查找对应的值,还可以通过枚举的值查找对应的名称。这是因为对于数字枚举,TypeScript 在编译时会生成一个双向映射对象。例如:

enum Direction {
  Up = 1,
  Down,
  Left,
  Right
}

编译后的 JavaScript 代码如下:

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

编译后的 Direction 对象实际上是这样的:

{
  1: "Up",
  2: "Down",
  3: "Left",
  4: "Right",
  Up: 1,
  Down: 2,
  Left: 3,
  Right: 4
}

因此,可以通过值反向查找名称:

console.log(Direction[1]); // 输出 "Up"
console.log(Direction[3]); // 输出 "Left"

⚠️ 注意事项:

如果枚举中同时包含数字和字符串值,只有数字部分支持反向映射:

enum MixedEnum {
  A = 1,
  B = "B"
}

console.log(MixedEnum[1]); // 输出 "A"
console.log(MixedEnum["B"]); // 输出 undefined

2.1.3. 手动赋值

在 TypeScript 中,手动赋值是指开发者显式地为某个枚举成员指定一个特定的值,而不是依赖于枚举值自动增长赋值机制,这对于需要特定常量值的场景或需要与外部协议规则兼容的情况非常有用。

例如,在处理接口返回的 HTTP 状态码时,通过手动赋值,可以确保枚举值与实际的 HTTP 状态码精确匹配,避免因值的自动递增而产生额外的映射逻辑,从而使代码更加简洁和易于维护。

enum HttpStatusCode {
  // 2xx Success
  OK = 200,
  Created = 201,
  Accepted = 202,

  // 4xx Client Errors
  BadRequest = 400,
  Unauthorized = 401,
  Forbidden = 403,
  NotFound = 404,

  // 5xx Server Errors
  InternalServerError = 500,
  ServiceUnavailable = 503
}

// 使用示例
const handleResponse = (code: HttpStatusCode) => {
  if (code === HttpStatusCode.OK) {
    console.log("请求成功");
  } else if (code === HttpStatusCode.NotFound) {
    console.log("资源未找到");
  }
};

// 反向映射特性(仅数字枚举有效)
console.log(HttpStatusCode[404]);  // 输出 "NotFound"

⚠️ 注意事项:

如果手动指定枚举值时发生重复,TypeScript 不会抛出错误,可能导致枚举值被覆盖。例如:

enum Days {
  Sun = 3,
  Mon = 1,
  Tue,
  Wed,
  Thu,
  Fri,
  Sat
}

console.log(Days["Sun"] === 3); // true
console.log(Days["Wed"] === 3); // true
console.log(Days[3] === "Sun"); // false
console.log(Days[3] === "Wed"); // true

在这个例子中,Sun 的值为 3,但 Wed 的值也被赋予了 3,导致了重复值,Days[3] 最终对应的是 Wed,而不是 Sun。因此,在使用枚举时,最好避免值重复,以免造成意外的覆盖。

2.2. 字符串枚举

同理可得,字符串枚举则是枚举成员的值都为字符串,而不是数字。字符串枚举示例:

enum Status {
  InProgress = "IN_PROGRESS",
  Completed = "COMPLETED",
  Canceled = "CANCELED"
}

显示赋值

字符串枚举成员的值必须显式指定,不能像数字枚举那样依赖自动递增的行为。

如果没有显式为某个枚举成员赋值,字符串枚举会自动将该成员的值设为 undefined,而不像数字枚举那样自动赋予一个数字值。

enum Status {
  InProgress = "IN_PROGRESS",
  Completed = "COMPLETED",
  Canceled  // 未赋值,默认为 undefined
}

console.log(Status.InProgress);  // "IN_PROGRESS"
console.log(Status.Completed);   // "COMPLETED"
console.log(Status.Canceled);    // undefined

在这个例子中,Canceled 成员没有赋值,所以它的值是 undefined,这通常不是我们期望的行为,因此需要确保为每个枚举成员显式指定值。

不支持反向映射

与数字枚举不同,字符串枚举不支持通过值来反向获取键名(枚举成员的名称)。

当尝试通过字符串值访问枚举成员时,会返回 undefined

console.log(Status["IN_PROGRESS"]);  // 输出 undefined

这是因为字符串枚举没有反向映射机制,无法像数字枚举那样通过值来查找对应的键名。

2.3. 异构枚举

从技术的角度来说,枚举可以混合字符串和数字成员,比如:

enum BooleanLikeHeterogeneousEnum {
  No = 0,
  Yes = "YES",
}

像这种枚举值混合使用多种类型的被称为异构枚举,这种情况在实际开发中是非常少见的。大多数情况下,枚举的成员值要么是统一的数字类型,要么是统一的字符串类型,而不是混合使用多种类型。

2.4. 常数项和计算所得项

在 TypeScript 中,枚举成员可以分为两种类型:常数项计算所得项。它们之间的区别主要体现在初始化值是否在编译时能够确定。

2.4.1. 常数项

常数项是指在编译时就能确定值的枚举成员。通常情况下,常数项是通过直接赋值(数字或字符串)或者通过常量表达式来计算得出,而这些计算会在编译时完成。具体来讲,当满足以下条件之一时,枚举成员被当作是常量:

  1. 它是枚举的第一个成员且没有初始化器。
  2. 它不带有初始化器且它之前的枚举成员是一个数字常量。
  3. 使用常量枚举表达式初始化。

前两种条件很好理解都非常直观,就是数字枚举的自增长行为,符合我们通常对数字枚举的理解——在编译时能确定每个成员的值。使用常量枚举表达式来初始化枚举成员是一种更复杂的情况。常量枚举表达式是 TypeScript 表达式的一个子集,能够在编译时就求值。常量枚举表达式的条件包括:

  • 枚举表达式字面量(主要是字符串字面量或数字字面量)
  • 引用先前定义的常量枚举成员(可在不同的枚举中)
  • 带括号的常量枚举表达式(例如 (1 + 2)
  • 一元运算符(如 -1+2
  • 使用二元运算符的常量枚举表达式(如 1 + 22 * 3

例如:

enum Color {
  Red = 1,           // 数字字面量
  Green = Red + 1,   // 引用之前的常量
  Blue = 10 * 2,     // 数字运算
}

⚠️ 注意事项:

若常量枚举表达式求值后为NaNInfinity,则会在编译阶段报错。

2.4.2. 计算所得项

计算所得项的值在编译时是无法确定的,需要通过运行时表达式动态计算。常数项以外的所有其它情况被当作是需要计算得出的值。例如:

enum FileAccess {
  // constant members
  None,
  Read = 1 << 1,
  Write = 1 << 2,
  ReadWrite  = Read | Write,
  // computed member
  G = "123".length
}

enum MathOperations {
  // computed member
  Multiply = Math.random(),
}

enum Colors {
  // constant members
  Red = 1, 
  Blue = Green * 2 
  // computed member
  Green = getGreenValue(), 
}

function getGreenValue(): number {
    return 2;
}

⚠️ 注意事项:

如果枚举中包含计算所得项,那么它后面的所有成员必须显式初始化,否则会报错。

enum Colors {
  Red = 1,
  Green = getGreenValue(),
  Blue, // 错误:必须显式初始化
}

2.5. 常量枚举

常量枚举是 TypeScript 中的一种特殊的枚举类型,它在编译时会被完全内联,不会生成实际的 JavaScript 对象。主要作用是优化性能,减少生成的代码量,适用于那些枚举值在编译时就能确定且不需要动态访问的场景。

2.5.1. 常量枚举的用法

常量枚举通过在枚举声明前加上 const 关键字来定义,例如:

const enum Color {
  Red = 1,
  Green = 2,
  Blue = 3
}

2.5.2. 和普通枚举的区别

在使用常量枚举时,TypeScript 编译器会将枚举值直接替换为对应的常量值,而不会生成实际的 JavaScript 对象。例如:

// 使用枚举
console.log(Color.Red); 
// 编译结果
console.log(1 /* Red */); // 直接内联值,无对象生成

再来看看普通枚举,普通枚举编译后会生成一个实际的 JavaScript 对象(包含键值对),运行时需要查找对象属性。例如:

enum Color {
  Red = 1,
  Green = 2,
  Blue = 3
}

// 使用枚举
console.log(Color.Red); 
// 编译结果(编译后保留对象,运行时查找 `Color.Red`)
var Color;
(function (Color) {
    Color[Color["Red"] = 1] = "Red";
    Color[Color["Green"] = 2] = "Green";
    Color[Color["Blue"] = 3] = "Blue";
})(Color || (Color = {}));

console.log(Color.Red);    // 输出: 1

总结:

常量枚举在编译时会被完全内联,不会生成实际的 JavaScript 对象,减少了运行时对象的内存占用和属性查找开销,在大型项目或工具库中能显著减少打包后的代码体积。

⚠️ 注意事项:

  1. 由于常量枚举在编译时不会生成实际的 JavaScript 对象,因此无法在运行时动态访问枚举对象;
  2. 常量枚举不允许包含计算成员。

2.5.3. 适用场景建议

  1. 用常量枚举:
    • 对性能敏感的场景(如高频调用的函数)
    • 需要最小化代码体积(如工具库、前端框架)
    • 不需要直接访问枚举对象
  1. 用普通枚举:
    • 需要反向映射(如 Color[1] → "Red"
    • 需要直接访问枚举对象,比如运行时动态访问枚举键名(如 Object.keys(Color)

3. 最佳实践

需求:

  1. 展示一个任务列表,任务列表有任务状态字段,用来展示每个任务的状态。后端接口返回的任务状态数据是数字类型,共有 5 种状态,分别为(未开始:0、进行中:1、已完成:2、已延期:3、已取消:4),前端需要根据后端接口返回的数字将其映射为带有不同颜色文本标签,如上图所示。
  2. 如果任务状态为未开始,在更多操作里面有分配人员的按钮,其他任务状态则没有。

枚举关键代码实现示例:

<template>
  <a-table :columns="columns" :data-source="data">
    <template #bodyCell="{ column, record }">
      <template v-if="column.key === 'action'">
        <span>
          <a>详情</a>
          <a-divider type="vertical" />
          <a-dropdown>
            <a class="ant-dropdown-link" @click.prevent>
              更多
              <DownOutlined />
            </a>
            <template #overlay>
              <a-menu>
                <a-menu-item v-if="record.status === taskStatus.NOT_STARTED">
                  <a href="javascript:;">分配人员</a>
                </a-menu-item>
                <a-menu-item>
                  <a href="javascript:;">操作日志</a>
                </a-menu-item>
              </a-menu>
            </template>
          </a-dropdown>
        </span>
      </template>
    </template>
  </a-table>
</template>
<script lang="ts" setup>
import { DownOutlined } from '@ant-design/icons-vue';
import { Tag } from "ant-design-vue";
import { h } from "vue";

enum taskStatus {
  NOT_STARTED,
  IN_PROGRESS,
  COMPLETED,
  DELAYED,
  CANCELLED,
}

const taskStatusOptions = [
  { label: "未开始", value: taskStatus.NOT_STARTED, color: "default" },
  { label: "进行中", value: taskStatus.IN_PROGRESS, color: "processing" },
  { label: "已完成", value: taskStatus.COMPLETED, color: "success" },
  { label: "已延期", value: taskStatus.DELAYED, color: "warning" },
  { label: "已取消", value: taskStatus.CANCELLED, color: "error" },
];

/**
 * 通用查找函数:根据 value 查找数组中的对象,并返回指定属性的值
 * @param array 目标数组
 * @param value 要查找的值
 * @param property 要返回的属性名
 * @param defaultValue 未找到时的默认值(可选)
 * @returns 对应属性的值,未找到时返回 defaultValue 或 undefined
 */
function getPropertyByValue<T extends { value: any }>(
  array: T[], // 目标数组
  value: T["value"], // 要查找的值
  property: keyof T, // 要返回的属性名
  defaultValue?: any // 未找到时的默认值
) {
  // 查找匹配的项
  const item = array.find((item) => item.value === value);
  // 返回指定属性的值
  return item ? item[property] : defaultValue;
}

const columns = [
  {
    title: "任务编号",
    dataIndex: "taskId",
    key: "taskId",
    width: 120,
    fixed: "left",
  },
  {
    title: "任务类型",
    dataIndex: "type",
    key: "type",
    width: 120,
  },
  {
    title: "任务状态",
    dataIndex: "status",
    key: "status",
    width: 110,
    customRender: ({ value }) => {
      const label = getPropertyByValue(taskStatusOptions, value, "label");
      const color = getPropertyByValue(taskStatusOptions, value, "color");
      return h(Tag, { color }, label);
    },
  },
  {
    title: "分配人员",
    dataIndex: "assignee",
    key: "assignee",
    width: 100,
  },
  {
    title: "任务创建时间",
    width: 180,
    sorter: true,
    dataIndex: "createdAt",
    key: "createdAt",
  },
  {
    title: "操作",
    key: "action",
  },
];

const data = [
  {
    taskId: "TASK-001",
    type: "需求开发",
    status: 2,
    assignee: "John",
    createdAt: "2023-07-30 09:00",
  },
  {
    taskId: "TASK-002",
    type: "功能测试",
    status: 1,
    assignee: "Sarah",
    createdAt: "2023-07-31 14:30",
  },
  {
    taskId: "TASK-003",
    type: "UI设计",
    status: 0,
    assignee: "Mike",
    createdAt: "2023-08-10 11:20",
  },
  {
    taskId: "TASK-004",
    type: "后端优化",
    status: 3,
    assignee: "Emma",
    createdAt: "2023-08-05 16:45",
  },
  {
    taskId: "TASK-005",
    type: "旧版兼容",
    status: 4,
    assignee: "Tom",
    createdAt: "2023-08-08 10:00",
  },
  {
    taskId: "TASK-006",
    type: "安全审计",
    status: 1,
    assignee: "Linda",
    createdAt: "2023-08-10 09:30",
  },
  {
    taskId: "TASK-007",
    type: "用户培训",
    status: 0,
    assignee: "Alex",
    createdAt: "2023-08-15 15:00",
  },
  {
    taskId: "TASK-008",
    type: "性能测试",
    status: 4,
    assignee: "Sarah",
    createdAt: "2023-08-01 13:20",
  },
  {
    taskId: "TASK-009",
    type: "需求评审",
    status: 2,
    assignee: "John",
    createdAt: "2023-07-31 10:00",
  },
  {
    taskId: "TASK-010",
    type: "代码重构",
    status: 4,
    assignee: "Emma",
    createdAt: "2023-08-14 17:00",
  },
];
</script>

如果只需要 value 值和 label 文本的映射,还可以简化:

enum taskStatus {
  NOT_STARTED,
  IN_PROGRESS,
  COMPLETED,
  DELAYED,
  CANCELLED,
}

const taskStatusLabel = {
  [taskStatus.NOT_STARTED]: "未开始",
  [taskStatus.IN_PROGRESS]: "进行中",
  [taskStatus.COMPLETED]: "已完成",
  [taskStatus.DELAYED]: "已延期",
  [taskStatus.CANCELLED]: "已取消",
};

const columns = [
  {
    title: "任务状态",
    dataIndex: "status",
    key: "status",
    width: 110,
    customRender: ({ value }) => {
      return taskStatusLabel[value];
    },
  },
];