从字符串满天飞到优雅枚举:JavaScript 常量管理的几种姿势

重构老项目的时候,看到代码里到处都是这样的判断:

ini 复制代码
if (status === 'pending') { }
if (status === 'processing') { }
if (orderStatus === 'pending') { }  // 等等,这个 pending 跟上面那个一样吗?

字符串满天飞,每次都要小心翼翼地打字,生怕打错一个字母。想用枚举来管理这些常量,但 JavaScript 又没有原生的 enum 关键字。

网上搜了一圈,发现大家的做法五花八门:

  • 有人用普通对象:const Status = { PENDING: 'pending' }
  • 有人用 Object.freeze()const Status = Object.freeze({ PENDING: 'pending' })
  • 还有人干脆直接用字符串,说"够用就行"

那到底哪种方式好?有没有更优雅的写法?

最近看到 Dr. Axel Rauschmayer 的一篇文章,介绍了用 Proxy 实现枚举的技巧。虽然他明确说这是个教学示例,不建议直接用在生产环境,但这个思路确实挺巧妙,能帮我们更好地理解 Proxy 和解构赋值的工作原理。

为什么 JavaScript 需要枚举?

字符串常量的问题

直接用字符串当常量,问题不少:

scss 复制代码
function updateOrder(status) {
  if (status === 'shiped') {  // 拼错了!应该是 shipped
    // ...
  }
}
​
updateOrder('shipped');  // 运行时才发现不匹配

JavaScript 是弱类型语言,拼错字符串编辑器不会报错,只能在运行时才发现问题。而且满屏的字符串字面量,维护起来真的头疼。

魔法数字更糟糕

有些老代码喜欢用数字:

scss 复制代码
if (userLevel === 3) {  // 3 是什么意思?
  // ...
}
​
if (priority === 3) {  // 这个 3 又是什么意思?
  // ...
}

半年后回头看代码,鬼知道这个 3 代表什么。

问题的根源在于:JavaScript 缺少一种原生的、类型安全的方式来定义常量集合

常见的枚举实现方式

方式 1:普通对象

最简单的做法:

arduino 复制代码
const OrderStatus = {
  PENDING: 'pending',
  PROCESSING: 'processing',
  SHIPPED: 'shipped',
  DELIVERED: 'delivered'
};
​
// 使用
if (order.status === OrderStatus.SHIPPED) {
  // ...
}

这种方式的问题是,对象属性可以被修改:

ini 复制代码
OrderStatus.PENDING = 'xxx';  // 能改,但不应该改
OrderStatus.CANCELLED = 'cancelled';  // 能加新属性

方式 2:Object.freeze()

冻结对象,防止修改:

php 复制代码
const OrderStatus = Object.freeze({
  PENDING: 'pending',
  PROCESSING: 'processing',
  SHIPPED: 'shipped',
  DELIVERED: 'delivered'
});
​
OrderStatus.PENDING = 'xxx';  // 严格模式下报错

这个方式好一些,但每次都要写 PENDING: 'pending' 这种重复的键值对,有点啰嗦。特别是当枚举值就是字段名的小写形式时,这种重复更明显。

方式 3:TypeScript 的 enum

如果用 TypeScript,有两种枚举方式:

数字枚举(默认):

arduino 复制代码
enum MyEnum { 
  foo,    // 0
  bar,    // 1
  baz     // 2
}
​
console.log(MyEnum.foo);  // 0
console.log(MyEnum.bar);  // 1

这种方式和很多编程语言(C、Java)的枚举类似,但调试起来不太直观------看到数字 0 你得想一下它代表什么。

字符串枚举(推荐):

ini 复制代码
enum OrderStatus {
  PENDING = 'pending',
  PROCESSING = 'processing',
  SHIPPED = 'shipped',
  DELIVERED = 'delivered'
}
​
console.log(OrderStatus.PENDING);  // 'pending' - 一眼就能看懂

字符串枚举的好处是调试时能直接看到有意义的值,不用去查数字对应什么。但这需要 TypeScript 环境,纯 JavaScript 项目怎么办?

用 Proxy 实现的巧妙方案

这里有个挺有意思的技巧,用 Proxy 可以让枚举定义变得更简洁。理解这个技巧需要先了解两个关键点。

关键点 1:Proxy 拦截器

核心思路是:创建一个特殊的对象,读取它的任何属性时,都返回属性名本身。

javascript 复制代码
const keyProxy = new Proxy({}, {
  get(_target, propKey, _receiver) {
    return propKey;
  }
});
​
console.log(keyProxy.foo);    // 'foo'
console.log(keyProxy.bar);    // 'bar'
console.log(keyProxy.hello);  // 'hello'

看到了吗?不管你访问什么属性,都能得到属性名的字符串。

关键点 2:解构赋值的属性简写

接下来,配合 ES6 的解构赋值:

arduino 复制代码
const { PENDING, PROCESSING, SHIPPED, DELIVERED } = keyProxy;
​
console.log(PENDING);     // 'PENDING'
console.log(PROCESSING);  // 'PROCESSING'
console.log(SHIPPED);     // 'SHIPPED'

这里利用了解构赋值的属性简写(property value shorthand)特性。

{ foo } 其实是 { foo: foo } 的简写。这两种写法是完全等价的

arduino 复制代码
// 简写形式
const { foo, bar, baz } = keyProxy;
​
// 完整形式(等价)
const { foo: foo, bar: bar, baz: baz } = keyProxy;

意思是:

  • keyProxy 对象中读取 foo 属性
  • 把读到的值赋给变量 foo

所以上面的代码实际执行的是:

ini 复制代码
const PENDING = keyProxy.PENDING;      // 'PENDING'
const PROCESSING = keyProxy.PROCESSING; // 'PROCESSING'
const SHIPPED = keyProxy.SHIPPED;      // 'SHIPPED'

两个要素结合就产生了这个效果:

  1. Proxy 让属性访问返回属性名
  2. 解构赋值让我们同时定义多个常量

一行代码搞定所有枚举值的定义。

实际使用

scss 复制代码
// 定义枚举
const { PENDING, PROCESSING, SHIPPED, DELIVERED } = keyProxy;
​
// 使用
function updateOrder(status) {
  if (status === SHIPPED) {
    console.log('订单已发货');
  }
}
​
updateOrder(SHIPPED);

看起来是不是简洁多了?不用重复写键值对,也不用每次都写 Status.PENDING 这样的前缀。

进阶:用 Symbol 提高类型安全

如果想要更强的类型安全性,可以把字符串换成 Symbol。

切换到 Symbol 版本只需要改动一行代码:

javascript 复制代码
const symbolProxy = new Proxy({}, {
  get(_target, propKey, _receiver) {
    return Symbol(propKey);  // 只改这一行
  }
});
​
const { PENDING, PROCESSING, SHIPPED } = symbolProxy;
​
console.log(typeof PENDING);  // 'symbol'
console.log(String(PENDING)); // 'Symbol(PENDING)'

其他代码完全不用动,只是把返回值从字符串改成 Symbol。

Symbol 的好处

防止误用字符串

scss 复制代码
// 用字符串的情况
const { PENDING } = keyProxy;
updateOrder('PENDING');  // 能工作,但可能不是你想要的
updateOrder(PENDING);    // 也能工作
​
// 用 Symbol 的情况
const { PENDING } = symbolProxy;
updateOrder('PENDING');  // 不会匹配,因为类型不对
updateOrder(PENDING);    // 只有这个才能匹配

Symbol 是唯一的,即使描述相同,两个 Symbol 也不相等:

javascript 复制代码
Symbol('foo') === Symbol('foo')  // false

所以用 Symbol 做枚举值,基本不可能出现误匹配的情况。

更好的调试体验

arduino 复制代码
console.log(PENDING);  // Symbol(PENDING) - 一眼就能看出是什么

这个方案的优缺点

优势

简洁

  • 不用重复写键值对
  • 一行代码搞定所有常量定义
arduino 复制代码
// 传统方式
const Status = {
  PENDING: 'PENDING',
  PROCESSING: 'PROCESSING',
  SHIPPED: 'SHIPPED',
  DELIVERED: 'DELIVERED'
};
​
// Proxy 方式
const { PENDING, PROCESSING, SHIPPED, DELIVERED } = keyProxy;

灵活

  • 想要字符串就用字符串版本的 Proxy
  • 想要 Symbol 就用 Symbol 版本的 Proxy
  • 随用随取,不需要预先定义所有常量

缺点

理解成本

这个技巧本质上是个教学示例,主要用来展示 Proxy 和解构赋值的灵活性。对于不熟悉这些特性的同事来说,代码可能不够直观。

没有命名空间

传统方式的枚举有明确的命名空间:

ini 复制代码
const OrderStatus = { PENDING: 'pending' };
const UserStatus = { PENDING: 'pending' };
​
// 两个 PENDING 不会混淆

但 Proxy 方式定义的常量是独立的变量:

arduino 复制代码
const { PENDING: OrderPending } = keyProxy;
const { PENDING: UserPending } = keyProxy;
​
// 需要手动区分变量名

IDE 支持有限

传统对象方式,IDE 能提示你有哪些可用的枚举值:

arduino 复制代码
OrderStatus.  // IDE 会列出 PENDING, PROCESSING 等

但 Proxy 方式定义的是普通变量,IDE 不知道你定义了哪些常量。

实际使用建议

这个 Proxy 技巧的价值主要在于学习和理解,而不是直接应用到生产环境。就像算法题一样,重点是理解思路,而不是把巧妙的解法用在工作代码里。

适合的场景

临时常量和快速原型

scss 复制代码
// 写测试代码时快速定义一些常量
const { SUCCESS, FAILURE, PENDING } = keyProxy;
​
test('should handle all states', () => {
  expect(handler(SUCCESS)).toBe(true);
  expect(handler(FAILURE)).toBe(false);
  expect(handler(PENDING)).toBe(null);
});

在快速开发阶段,不想花时间写繁琐的枚举定义,这个方式很方便。

生产环境的建议

对于业务核心的枚举(订单状态、用户角色等),建议还是用传统方式:

php 复制代码
// 清晰、有命名空间、IDE 友好
const OrderStatus = Object.freeze({
  PENDING: 'pending',
  PROCESSING: 'processing',
  SHIPPED: 'shipped',
  DELIVERED: 'delivered'
});

或者如果项目使用 TypeScript,直接用 enum

ini 复制代码
enum OrderStatus {
  PENDING = 'pending',
  PROCESSING = 'processing',
  SHIPPED = 'shipped',
  DELIVERED = 'delivered'
}

如果真要用,可以封装

javascript 复制代码
// utils/enum.js
export const stringEnum = new Proxy({}, {
  get(_target, propKey) {
    return propKey;
  }
});
​
export const symbolEnum = new Proxy({}, {
  get(_target, propKey) {
    return Symbol(propKey);
  }
});
​
// 使用
import { stringEnum } from './utils/enum';
const { PENDING, PROCESSING } = stringEnum;

这样至少让代码意图更明确一些。

写在最后

这个 Proxy 技巧确实挺巧妙的,理解它的原理也挺有意思。用来学习和理解 JavaScript 的高级特性很有价值。

就像健身房里练单手俯卧撑,不是为了以后干活只用一只手,而是为了提升整体力量。这个技巧也一样:

  • 加深对 Proxy 的理解
  • 学会解构赋值的更多玩法
  • 知道"简单特性组合"能产生的效果

实际写枚举的时候,老老实实用 Object.freeze() 或者 TypeScript 的 enum 比较好。但如果哪天面试被问到"你能用 Proxy 做点什么有趣的事",这个例子正好能派上用场。

代码不是越巧妙越好,够清楚、好维护才是王道。


相关文档

  1. Enums via proxies (原文) - Dr. Axel Rauschmayer 的原文
  2. Proxy - MDN - Proxy 的详细用法
  3. Symbol - MDN - Symbol 类型说明
  4. 解构赋值 - MDN - 解构赋值的详细说明
  5. Object.freeze() - MDN - 冻结对象的方法
  6. TypeScript Enums - TypeScript 的枚举实现
相关推荐
崔庆才丨静觅15 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606116 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了16 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅16 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅16 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅17 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment17 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅17 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊17 小时前
jwt介绍
前端
爱敲代码的小鱼17 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax