重构老项目的时候,看到代码里到处都是这样的判断:
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'
两个要素结合就产生了这个效果:
- Proxy 让属性访问返回属性名
- 解构赋值让我们同时定义多个常量
一行代码搞定所有枚举值的定义。
实际使用
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 做点什么有趣的事",这个例子正好能派上用场。
代码不是越巧妙越好,够清楚、好维护才是王道。
相关文档
- Enums via proxies (原文) - Dr. Axel Rauschmayer 的原文
- Proxy - MDN - Proxy 的详细用法
- Symbol - MDN - Symbol 类型说明
- 解构赋值 - MDN - 解构赋值的详细说明
- Object.freeze() - MDN - 冻结对象的方法
- TypeScript Enums - TypeScript 的枚举实现