我遍历一个数字枚举想拿到所有选项,结果拿到了双倍的条目、数字和名字混在一起,我盯着这串诡异的结果查了半天才搞懂枚举反向映射的深度复盘

我定义了个数字枚举 enum Status { Active, Inactive, Deleted } 想遍历它拿所有选项做下拉框,只有 3 个选项,Object.keys 却返回了 6 个:

我遍历一个数字枚举想拿到所有选项,结果拿到了双倍的条目、数字和名字混在一起,我盯着这串诡异的结果查了半天才搞懂枚举反向映射的深度复盘

这是一个让我对 TypeScript "枚举编译后长什么样"刻骨铭心的故事。我定义了一个数字枚举,用来表示状态:enum Status { Active, Inactive, Deleted }。然后,我想遍历这个枚举,拿到它所有的选项,做个下拉框之类的。我很自然地,用了 Object.keys(Status)Object.values(Status) 去遍历它。在我朴素的认知里,这个枚举有 3 个选项,那遍历出来,不就该是 3 个嘛。

可结果,把我整懵了:我遍历这个只有 3 个选项的枚举,拿到的,竟然是 6 个条目!而且,这 6 个,是数字和名字混在一起的——Object.keys(Status) 返回的,竟是 ["0", "1", "2", "Active", "Inactive", "Deleted"]!前面三个,是数字索引的字符串,后面三个,才是我定义的选项名字。我那个本该是 3 项的下拉框,变成了诡异的 6 项,一半是名字、一半是数字。我当时百思不得其解:我明明只定义了 3 个枚举值啊!这多出来的 3 个数字,是从哪冒出来的?我反复确认枚举的定义,确实只有 3 个。直到我去看这个枚举编译成 JavaScript 之后的样子,才恍然大悟,补上了关于 TS 枚举最重要的一课:原来,TypeScript 的数字枚举,在编译成 JavaScript 后,会变成一个"双向映射(reverse mapping)"的对象!也就是说,它不只存了"名字 → 数字"(Active: 0),还反过来存了"数字 → 名字"(0: "Active")!所以,我那个 enum Status { Active, Inactive, Deleted },编译后,实际上是一个有 6 个键值对的对象:{ 0:"Active", 1:"Inactive", 2:"Deleted", Active:0, Inactive:1, Deleted:2 }。于是,我用 Object.keys 去遍历它,自然就拿到了全部 6 个键——3 个数字字符串("0"/"1"/"2"),加上 3 个名字。TypeScript 之所以这么设计,是为了一个便利:让你既能Status.Active 拿到数字 0、也能Status[0] 反查出名字 "Active"。可这个为"双向查找"而生的便利,却在我"遍历枚举"的时候,变成了一个意料之外的坑——我以为我在遍历一个有 3 个成员的"枚举",实际上,我在遍历一个有 6 个键的、双向映射的"普通对象"。我把"枚举"这个语法概念,和它编译后那个双向映射的 JS 对象现实,想当然地划上了等号。

故障现场:数字枚举编译成了双向映射的对象

我把这个"双倍条目"的现场,用代码摊开给你看:

// ✗ 灾难: 遍历数字枚举, 拿到双倍的条目
enum Status { Active, Inactive, Deleted }   // 我以为就 3 个选项

console.log(Object.keys(Status));
// ✗ ["0", "1", "2", "Active", "Inactive", "Deleted"]  —— 6 个! 数字+名字混在一起

console.log(Object.values(Status));
// ✗ ["Active", "Inactive", "Deleted", 0, 1, 2]  —— 也是 6 个!

// 想做个下拉框, 遍历它 → 出来 6 项, 一半是数字, 一半是名字, 全乱了。

// 为什么? 因为数字枚举编译成 JS 后, 是个"双向映射"的对象:
// 编译后的 JS 大致是:
//   var Status;
//   (function (Status) {
//       Status[Status["Active"] = 0] = "Active";      // 同时存 Active:0 和 0:"Active"
//       Status[Status["Inactive"] = 1] = "Inactive";  // 同时存 Inactive:1 和 1:"Inactive"
//       Status[Status["Deleted"] = 2] = "Deleted";    // 同时存 Deleted:2 和 2:"Deleted"
//   })(Status || (Status = {}));
// → 最终 Status = { 0:"Active",1:"Inactive",2:"Deleted",
//                   Active:0,Inactive:1,Deleted:2 }  —— 6 个键值对!

// 为什么这么设计? 为了"双向查找"的便利:
//   Status.Active  → 0       (名字 → 数字)
//   Status[0]      → "Active"(数字 → 名字, 反向映射)
//   ↑ 这个便利, 却让"遍历枚举"拿到了双倍条目。

// 根因: 把"枚举"这个语法概念, 当成了"只有N个成员的纯净集合";
//   而它编译后, 其实是个"有 2N 个键的双向映射对象"。
//   遍历它(Object.keys/values), 就拿到了正反两份。

看着这段代码和它编译后的样子,我才算真正理解了这个"双倍条目"的根源。问题的核心,是我把 TypeScript 的"枚举",当成了一个纯粹的、只有 N 个成员的语法概念;可它在编译成 JavaScript 之后,真实的身份,是一个有 2N 个键的"双向映射(reverse mapping)"对象具体来说:TypeScript 的数字枚举,编译后,不只存了"名字 → 数字"的正向映射(Active: 0),还额外反过来存了"数字 → 名字"的反向映射(0: "Active")。所以,我那个看起来只有 3 个成员的 enum Status,编译后,实际上是一个有 6 个键值对的对象:{ 0:"Active", 1:"Inactive", 2:"Deleted", Active:0, Inactive:1, Deleted:2 }于是,当我用 Object.keys(Status) 去遍历它时,它忠实地返回了这个对象的全部 6 个键——3 个数字字符串("0"/"1"/"2"),加上 3 个名字("Active"/"Inactive"/"Deleted")。这就是我那个本该 3 项的下拉框,变成诡异的 6 项的原因。而 TypeScript 之所以这么设计,是为了一个便利:让你既能Status.Active 拿到数字 0(正向),也能Status[0] 反查出名字 "Active"(反向)——这个"双向查找"的能力,在某些场景下确实有用(比如根据存到数据库的数字,反查出它的名字)。这个为"双向查找"而生的便利,却在我"遍历枚举"的时候,变成了一个意料之外的坑:我以为我在遍历一个有 3 个成员的"枚举",实际上,我在遍历的,是一个有 6 个键的、双向映射的"普通 JS 对象"归根结底:我犯的错,是把"枚举"这个 TypeScript 的语法糖概念,和它编译成 JS 后那个双向映射对象的现实,想当然地划上了等号;我只看到了"枚举"这个抽象的表象,却没看到它落地成 JavaScript 之后,那个会"正反各存一份"的、真实的样子。

第一件事:搞懂数字枚举编译成"双向映射对象"

定位到根源,我必须把"数字枚举编译后的真实样子"彻底搞清楚:

数字枚举编译后 = 双向映射(reverse mapping)的对象

# 数字枚举:
enum Status { Active, Inactive, Deleted }
# 编译成 JS 后, Status 是这样一个对象:
#   {
#     0: "Active", 1: "Inactive", 2: "Deleted",   ← 反向: 数字 → 名字
#     Active: 0, Inactive: 1, Deleted: 2          ← 正向: 名字 → 数字
#   }
# → 一个 N 成员的数字枚举, 编译后有 2N 个键!

# 为什么有反向映射? 为了能"双向查":
#   Status.Active → 0        (正向)
#   Status[0]     → "Active" (反向, 方便"拿到数字, 反查名字")

# 后果(遍历时的坑):
#   Object.keys(Status)   → ["0","1","2","Active","Inactive","Deleted"]  (6个)
#   Object.values(Status) → ["Active","Inactive","Deleted",0,1,2]        (6个)
#   for...in 也一样, 拿到正反两份。
#   → 直接遍历数字枚举, 会拿到"双倍"的、数字名字混杂的条目!

# 重要区别: "字符串枚举"没有反向映射!
enum Color { Red = "RED", Green = "GREEN" }
# 编译后: { Red: "RED", Green: "GREEN" }  ← 只有正向, 没有反向!
#   Object.keys(Color)   → ["Red", "Green"]   (干净, 2个)
#   → 因为字符串值没法安全地做反向键, TS 就不生成反向映射。

# 所以:
#   - 数字枚举: 有反向映射, 遍历有"双倍"坑。
#   - 字符串枚举: 无反向映射, 遍历干净。

# 核心: 数字枚举编译后是"双向映射对象"(2N个键)。
#   遍历它要小心"双倍条目"; 要干净遍历, 用字符串枚举, 或过滤, 或用别的方式。

原理终于刻进脑子里了。一个 N 个成员的数字枚举,编译成 JavaScript 后,是一个有 2N 个键的"双向映射"对象:它存了"数字 → 名字"的反向映射(0: "Active"),存了"名字 → 数字"的正向映射(Active: 0)。它之所以要存这份反向映射,是为了让你能"双向查":Status.Active 拿数字(正向)、Status[0] 反查名字(反向)。而它带来的后果,就是遍历时的那个坑:Object.keys(Status) 会返回全部 6 个键(["0","1","2","Active","Inactive","Deleted"])、Object.values 同理、for...in 也一样——直接遍历数字枚举,会拿到"双倍"的、数字名字混杂的条目而这里有一个极其重要的区别:"字符串枚举"是没有反向映射的!enum Color { Red = "RED" } 编译后,只是 { Red: "RED" }(只有正向),所以 Object.keys(Color) 返回的是干净的 ["Red"]——因为字符串值没法安全地做反向键,TS 就不为字符串枚举生成反向映射所以,一个清晰的对比是:数字枚举,有反向映射,遍历时有"双倍"坑;字符串枚举,无反向映射,遍历是干净的由此,我得出了那个本该一开始就掌握的结论:数字枚举,编译后是一个"双向映射对象"(2N 个键);遍历它,要小心"双倍条目";如果你想要干净地遍历枚举,可以用字符串枚举、或者过滤掉数字键、或者干脆用别的方式(如 as const 对象)来表达枚举——这,是我用一个"双倍下拉框",补上的、关于 TS 枚举最关键的一课。

第二件事:正解——用字符串枚举,或干脆用 as const 对象

搞懂了根因——"数字枚举有反向映射、遍历出双倍"——正解就清晰了:要干净地遍历,优先用字符串枚举(它没有反向映射);或者更现代地,干脆as const 对象来代替枚举(很多场景下它更简单、更安全)。如果非要遍历数字枚举,就过滤掉那些数字键

// 正解1: 用字符串枚举(没有反向映射, 遍历干净)
enum Status { Active = "ACTIVE", Inactive = "INACTIVE", Deleted = "DELETED" }
console.log(Object.keys(Status));    // ✓ ["Active","Inactive","Deleted"]  干净!
console.log(Object.values(Status));  // ✓ ["ACTIVE","INACTIVE","DELETED"]
// 额外好处: 存到 DB/日志里是有意义的字符串("ACTIVE"), 而不是含义不明的数字 0。

// 正解2(更现代/推荐): 用 as const 对象, 代替 enum
const Status = {
    Active: "ACTIVE",
    Inactive: "INACTIVE",
    Deleted: "DELETED",
} as const;
type Status = typeof Status[keyof typeof Status];   // 联合类型 "ACTIVE"|"INACTIVE"|...
console.log(Object.values(Status));  // ✓ ["ACTIVE","INACTIVE","DELETED"]  干净!
// 优点: 就是个普通对象, 行为可预测(没有 enum 的编译魔法/反向映射),
//   遍历干净, 也不会有 enum 的一堆坑。很多团队/Lint 推荐用它替代 enum。

// 正解3: 非要遍历数字枚举 → 过滤掉数字键
enum Num { A, B, C }
const keys = Object.keys(Num).filter(k => isNaN(Number(k)));   // 过滤掉 "0","1","2"
console.log(keys);   // ✓ ["A","B","C"]
// 或拿值: Object.values(Num).filter(v => typeof v === "number")  → [0,1,2]

// 选择建议:
//   - 需要遍历/序列化/可读性 → 字符串枚举 或 as const 对象(推荐后者)。
//   - 就要数字、且要双向查(数字反查名字) → 数字枚举(但遍历记得过滤)。
//   - 拿不准 → as const 对象, 最简单、最可预测、坑最少。

// 核心: 数字枚举遍历有双倍坑; 用字符串枚举/as const 对象避开它。
//   优先选"行为可预测"的方式来表达一组常量。

这套正解,核心是用"行为可预测、没有反向映射"的方式,来表达一组常量。正解1(字符串枚举):把枚举的值,显式地写成字符串(Active = "ACTIVE")——字符串枚举没有反向映射,所以 Object.keys/Object.values 遍历出来是干净的;而且它还有个额外的好处:存到数据库、打到日志里时,是有意义的字符串("ACTIVE"),而不是含义不明的数字 0(数字枚举存的是 0,过段时间你都不知道 0 是啥)。正解2(as const 对象,更现代、更推荐):干脆不用 enum,而用一个 as const 的普通对象来表达枚举,再用 typeof + keyof 推导出对应的联合类型;它的优点是——它就是一个普通对象,行为完全可预测(没有 enum 那些编译期的魔法和反向映射),遍历干净,也不会有 enum 的一堆其它坑;很多团队和 Lint 规则,都推荐用它来替代 enum正解3(非要遍历数字枚举 → 过滤):如果你就是要用数字枚举,那遍历时,记得过滤掉那些数字键(Object.keys(Num).filter(k => isNaN(Number(k)))),或者拿值时过滤出数字。选择建议是:需要遍历、序列化、可读性的,用字符串枚举或 as const 对象(推荐后者);就要数字、且要双向查的,才用数字枚举(但遍历记得过滤);拿不准时,as const 对象——它最简单、最可预测、坑最少。归根结底:数字枚举遍历有"双倍"坑,用字符串枚举或 as const 对象就能避开它;优先选择"行为可预测"的方式,来表达一组常量。我那次的错误,正是用了行为有"魔法"的数字枚举、又直接遍历它;而正解,就是换成一个行为可预测、没有反向映射的表达方式。

下面这张图,对比了"数字枚举"和"字符串枚举/as const"两条路径:

这张图的对比很清楚:左边红色那条,用数字枚举,编译成有 2N 个键的双向映射对象,Object.keys 拿到数字+名字的双倍条目,遍历/下拉框就乱了;右边绿色那条,用字符串枚举或 as const 对象,没有反向映射、就是个普通对象,遍历干净(N 个)、而且值还可读。两条路的根本分野,在于你用的,是一个有"编译魔法"的数字枚举,还是一个行为可预测的表达方式。

第三件事:枚举的其它坑

填平了反向映射这个坑,我系统排查了一遍 TS 枚举的其它常见坑:

// TypeScript 枚举的其它坑:

// 1. 数字枚举反向映射(本文): 遍历出双倍条目。

// 2. 数字枚举的"值"不安全: 任意数字都能赋给它(没穷尽检查保护)
enum Dir { Up, Down }
const d: Dir = 99;       // ✗ 不报错! 99 也被当成合法的 Dir(数字枚举太宽松)
// → 字符串枚举/联合类型更安全, 只能是定义的那几个值。

// 3. const enum: 编译后被"内联", 不生成对象(没运行时对象!)
const enum E { A, B }
// console.log(E);       // ✗ 报错/没东西! const enum 运行时不存在, 不能遍历/反射。
// (它把 E.A 直接替换成 0, 性能好, 但有跨模块/隔离编译的坑, 慎用)

// 4. enum 既是"类型"又是"值", 容易混淆作用域。

// 5. 数字枚举存 DB 的坑: 存的是数字(0,1,2)。
//    如果以后调整了枚举顺序/插入了新成员, 数字含义就错位了!→ 历史数据全乱。
//    → 存 DB 用"字符串枚举"或显式固定的数字, 别依赖"自动递增的隐式数字"。

// 6. 跨项目/序列化: 数字枚举的数字, 脱离代码就没有含义(0 是啥?)。
//    字符串枚举("ACTIVE")自解释, 更适合序列化/接口/存储。

// 共同点: 很多坑源于"数字枚举的隐式数字 + 编译魔法(反向映射/内联)"。
// 原则: 优先用字符串枚举 或 as const 对象; 数字枚举仅在确有需要时用,
//   且用时清楚它的反向映射、值不安全、存DB错位等坑。

这一排查,让我对 TS 枚举的"雷区",有了全面的认识。除了反向映射(本文),枚举还有几个坑:数字枚举的值不安全(const d: Dir = 99 竟然不报错!任意数字都能赋给数字枚举,它太宽松了——而字符串枚举/联合类型更安全,只能是定义的那几个值);const enum 的坑(它编译后会被"内联"、不生成运行时对象,所以你不能在运行时遍历或反射它,而且有跨模块、隔离编译的坑,要慎用);枚举既是类型又是值(容易混淆作用域);数字枚举存数据库的坑(存的是数字 0/1/2,一旦以后调整了枚举顺序、或中间插入了新成员,数字的含义就错位了,历史数据全乱——存 DB 要用字符串枚举、或显式固定的数字,别依赖那个"自动递增的隐式数字");跨项目/序列化的坑(数字枚举的数字,脱离了代码就没有含义了,"0 是啥?"——而字符串枚举 "ACTIVE" 是自解释的,更适合序列化、接口、存储)。这些坑的共同点,大多源于"数字枚举的隐式数字 + 编译期的魔法(反向映射/内联)"。所以,核心原则就是:优先用字符串枚举、或 as const 对象;数字枚举仅在确有需要(比如真的要双向查、或对接已有的数字协议)时才用,且用的时候,要清楚它的反向映射、值不安全、存 DB 会错位等一系列坑。把这些都搞清楚,枚举,才能用得安心。

第四件事:enum vs 联合类型 vs as const,到底该用哪个

这次踩坑,逼我把"表达一组常量"的几种方式,系统地对比了一遍,搞清楚了到底该用哪个:

// 表达"一组常量/选项"的三种方式, 对比:

// 方式1: 字面量联合类型(最轻量)
type Status = "active" | "inactive" | "deleted";
const s: Status = "active";   // ✓ 类型安全, 只能是这几个值
// 优点: 零运行时开销(纯类型, 编译后消失)、简单、类型安全。
// 缺点: 没有"运行时的值集合"可遍历(它编译后不存在)。

// 方式2: as const 对象(推荐, 兼顾类型和运行时)
const Status = { Active: "active", Inactive: "inactive" } as const;
type Status = typeof Status[keyof typeof Status];
// 优点: 既有"运行时对象"(可遍历 Object.values, 干净)、又有类型安全。
//   行为可预测(就是普通对象, 没 enum 的魔法)。坑最少。

// 方式3: enum(TS 特有的语法)
enum Status { Active = "active", Inactive = "inactive" }
// 优点: 语法直观、自带命名空间。
// 缺点: 数字枚举有反向映射/值不安全; const enum 有内联坑; 是 TS 特有,
//   编译有"魔法", 行为不如普通对象可预测。

// 怎么选?
//   - 只需要"类型约束"(不需要运行时遍历值) → 字面量联合类型(最轻)。
//   - 既要类型、又要运行时遍历值/可读字符串 → as const 对象(推荐)。
//   - 团队习惯用 enum / 要双向查 → 用 enum, 但优先字符串枚举, 慎用数字/const enum。

// 业界趋势: 很多团队和规范(如一些 lint 规则)倾向于
//   "用 字面量联合类型 + as const 对象, 替代 enum"——
//   因为它们更贴近 JS 原生、行为更可预测、坑更少。

// 核心: 表达一组常量, 优先考虑"字面量联合 / as const 对象";
//   enum 不是唯一选择, 且数字 enum 的"魔法"会带来本文这类坑。

这一对比,让我对"表达一组常量"这件事,有了清晰的选型认识。方式1(字面量联合类型,最轻量):type Status = "active" | "inactive"——它是纯类型,零运行时开销(编译后就消失了)、简单、类型安全;但缺点是,它没有一个运行时的值集合可供遍历(因为它编译后根本不存在)。方式2(as const 对象,推荐):用一个 as const 对象,再推导出类型——它兼顾了两者:既有一个运行时的对象(可以干净地 Object.values 遍历)、又有类型安全;而且它行为完全可预测(就是个普通对象,没有 enum 的魔法),坑最少方式3(enum):它语法直观、自带命名空间;但缺点是,数字枚举有反向映射和值不安全的问题、const enum 有内联的坑,而且它是 TS 特有的、编译有"魔法",行为不如普通对象可预测。那么怎么选?——只需要"类型约束"(不需要运行时遍历值)的,用字面量联合类型(最轻);既要类型、又要运行时遍历值或可读字符串的,用 as const 对象(推荐);团队习惯用 enum、或确实要双向查的,才用 enum,但要优先字符串枚举、慎用数字枚举和 const enum而且,有一个业界趋势值得了解:很多团队和代码规范(如一些 lint 规则),都倾向于"用字面量联合类型 + as const 对象,来替代 enum"——因为它们更贴近 JavaScript 原生、行为更可预测、坑更少。归根结底:表达一组常量,优先考虑"字面量联合类型 / as const 对象";enum 不是唯一的选择,而且数字 enum 的那些"魔法",会带来像本文这样意料之外的坑。把这三种方式的对比,整理成一张表:

方式 运行时可遍历 类型安全 坑/适用
字面量联合类型 否(编译后消失) 最轻,只需类型约束时
as const 对象 是(干净) 推荐,坑最少,要遍历值时
字符串枚举 是(干净) 可用,值可读
数字枚举 是(有双倍坑) 较弱(值不安全) 慎用,要双向查时

第五件事:用高层语法糖,要看穿它编译后的样子

这次踩坑,在认知层面给了我最大的纠偏——它让我警惕"只看语法糖、不看它编译成什么"。我把这层反思,沉淀了下来:

认知纠偏: 用高层语法糖, 要看穿它"编译/降级后"的真实样子

# 我的误解(错误的):
#   我把 enum 当成一个"纯粹的、N 个成员的枚举概念", 凭这个直觉去遍历它。
#   → 我只看了 enum 的"语法表象", 没看它"编译成 JS 后"是个双向映射对象。

# 真相: 高层语法糖, 最终都要"编译/降级"成底层代码, 而那才是它真实的行为
#   - TS enum → 编译成 JS 的双向映射对象(本文)。
#   - async/await → 编译成状态机/Promise 链。
#   - JSX → 编译成 createElement 调用。
#   - 解构/扩展/可选链 → 编译成一堆判断和赋值。
#   → 语法糖让你"写得爽", 但它"实际是什么、怎么运行的", 在编译产物里。

# 不懂编译产物的代价:
#   - 行为意外(本文: 遍历出双倍)。
#   - 性能意外(某语法糖编译后开销很大)。
#   - 调试困难(报错指向编译后的代码, 看不懂)。

# 正确的习惯:
#   1. 用一个高层特性(尤其有"编译魔法"的), 了解它"编译成什么"。
#   2. 行为反直觉时, 去看编译产物(TS Playground 能看 JS 输出)——真相在那。
#   3. 优先选"编译后行为可预测、贴近底层"的写法(如 as const vs enum)。

# 核心: 语法糖是"给人看的便利", 编译产物是"机器跑的真相"。
#   理解一个语法糖编译后的样子, 才能预判它的行为、避开它的魔法带来的坑。

这层反思,是这次踩坑给我最高维度的收获。复盘我的误解,根源是:我把 enum,当成了一个"纯粹的、N 个成员的枚举概念",凭着这个直觉去遍历它;我只看了 enum 的"语法表象",却没看它"编译成 JS 之后"那个双向映射对象的真实样子。可真相是:高层的语法糖,最终都要"编译/降级"成底层的代码,而那,才是它真实的行为在 TypeScript/JavaScript 里,这样的例子比比皆是:TS 的 enum 编译成 JS 的双向映射对象(本文);async/await 编译成状态机/Promise 链;JSX 编译成 createElement 调用;解构、扩展、可选链,编译成一堆底层的判断和赋值——语法糖,让你"写得爽",但它"实际是什么、怎么运行的",藏在编译产物里不懂编译产物的代价是:行为意外(像本文遍历出双倍)、性能意外(某些语法糖编译后开销很大)、调试困难(报错指向你看不懂的编译后代码)。由此,我立下了几条习惯:第一,用一个高层特性(尤其是有"编译魔法"的),要了解它编译成什么;第二,当行为反直觉时,去看它的编译产物(TS Playground 就能看到对应的 JS 输出)——真相,就在那里;第三,优先选择"编译后行为可预测、贴近底层"的写法(就像用 as const 对象,而不是有魔法的 enum)。归根结底:语法糖,是"给人看的便利";而编译产物,才是"机器跑的真相"。理解一个语法糖编译后的样子,你才能预判它的行为、避开它那些"魔法"带来的、像本文这样意料之外的坑。把"只看语法糖"和"看穿编译产物"两种状态对比成一张表:

维度 只看语法糖(踩坑) 看穿编译产物(掌握)
对 enum 当纯净的 N 成员枚举 知道编译成双向映射对象
认知依据 语法表象 编译后的真实代码
行为反直觉时 百思不得其解 去看编译产物找真相
选型 凭语法直观 选编译后可预测的写法
典型受害 enum/async/JSX 的魔法 提前预判规避

一套"表达一组常量该用什么"的决策流程

把这次踩坑的全部教训,我浓缩成了一张"要表达一组常量/选项、该用什么"的决策图,贴在了团队的 TS 规范里:

这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:要表达一组常量,先问运行时需不需要遍历这些值——不需要、只要类型约束的,用字面量联合类型(最轻量);需要遍历/序列化的,优先用 as const 对象(行为可预测、无魔法);如果非要用 enum,优先字符串枚举(无反向映射、遍历干净),慎用数字枚举(有双倍坑、遍历要过滤、存 DB 会错位,仅在确实要双向查时才用)。这条"按是否遍历、再按可预测性选型"的决策链,现在是我们团队定义每一组常量时的准则。

我立下的几条 TypeScript 枚举与常量规矩

这次"枚举遍历出双倍"的踩坑,让我把 TS 枚举和常量表达的注意事项,认真地立成了几条规矩:

  1. 记牢数字枚举有反向映射。编译成有 2N 个键的双向映射对象,直接遍历会拿到数字+名字的双倍条目。
  2. 表达常量优先 as const 对象 / 字面量联合。行为可预测、坑最少,而非默认用 enum。
  3. 要用 enum 就用字符串枚举。无反向映射、遍历干净、值可读,别用数字枚举。
  4. 遍历数字枚举要过滤。过滤掉 isNaN(Number(k)) 的数字键,只留名字。
  5. 存 DB/序列化别用数字枚举的隐式数字。顺序一变含义就错位;用字符串值,自解释。
  6. 慎用 const enum。运行时不存在、不能遍历,有隔离编译的坑。
  7. 看穿语法糖编译后的样子。enum/async/JSX 都有编译魔法;行为反直觉时去看编译产物。

写在最后

这次"我遍历数字枚举、拿到双倍条目"的经历,是我在 TypeScript 路上,一次很经典、也很受用的成长。它教给我的,远不止"数字枚举有反向映射"这一条具体的技术经验,更是一个关于使用高层语言特性的根本认知——语法糖,是"给人看的便利";而它编译后的产物,才是"机器跑的真相"。我那个双倍的下拉框,根源就在于,我只看到了 enum 这个语法糖"纯净的 N 个成员"的表象,却没看到它编译成 JavaScript 后,那个会"正反各存一份"、有 2N 个键的双向映射对象的真实样子。

所以,当你使用任何一个高层的语法特性、尤其是那些带"编译魔法"的(enumasync/await、JSX、装饰器……)时,请别只满足于"会写这个语法、知道它大概的作用",而要带着好奇,去看一眼它编译/降级之后,到底变成了什么就像 TS 的数字枚举,你只要在 Playground 里看一眼它编译出的那个双向映射对象,就再也不会被那个"双倍条目"绕晕,反而会觉得"原来如此,理所当然"。透过语法糖的便利、看穿它编译后的真实样子,优先选择那些"行为可预测"的写法,是从一个"会用语法"的开发,走向一个"懂底层、能预判行为"的工程师,必经的修炼。愿你写的每一组常量,都行为如你所料、遍历清清爽爽;也愿你我,在享受每一份语法糖的便利时,都拎得清它背后,机器真正跑着的那份真相。共勉。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我以为 LINQ 查询在定义那一行就执行完了,结果它每次遍历都重新查一遍数据库,还因为延迟执行读到了中途变化后的数据的深度复盘

2026-6-1 23:33:51

技术教程

我的 Agent 跑着跑着就开始胡言乱语、还动不动报上下文超限,最后发现是每一步的工具结果都被原样塞进了上下文、把窗口活活撑爆的深度复盘

2026-6-1 23:47:15

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索