从字符串满天飞到优雅枚举: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 的枚举实现
相关推荐
qq_415216252 小时前
Vue3+vant4+Webpack+yarn项目创建+vant4使用注意明细
前端·webpack·node.js
李建军2 小时前
ASP.NET Core Web 应用SQLite数据连接显示(1)
前端
耀耀切克闹灬2 小时前
word文档转html(mammoth )
前端
文心快码BaiduComate2 小时前
双十一将至,用Rules玩转电商场景提效
前端·人工智能·后端
用户18729422508392 小时前
告别函数的“两面派”人生:深度剖析箭头函数如何一劳永逸地解决 ‘this’ 的二义性
javascript
拉不动的猪2 小时前
关于scoped样式隔离原理和失效情况&&常见样式隔离方案
前端·javascript·面试
摇滚侠2 小时前
Vue 项目实战《尚医通》,医院详情菜单与子路由,笔记17
前端·vue.js·笔记
有来技术3 小时前
vite-plugin-vue-mcp:在 Vue 3 + Vite 中启用 MCP,让 AI 理解并调试你的应用
前端·vue.js·人工智能
fruge3 小时前
前端本地存储进阶:IndexedDB 封装与离线应用开发
前端