当变量的值来自一组有限的预定义常量时,此乃枚举的用武之地。枚举使我们规避“魔术数字”和“魔术字符串”等反模式。

大多数编程语言都原生支持枚举数据类型。虽然目前 JS 自己并不支持object.freeze(),但好在 TS 内置了枚举。

有趣的是,当我们将 TS 编译为 JS 之后,就会发现 TS 的枚举其实也是用原生 JS 来模拟的。

本文共享的是,在 JS 中创建枚举的若干方案及其利弊:

object.freeze()_object.freeze()_objectfreeze原理

基于普通对象的枚举

枚举是一种定义一组有限命名常量的数据结构。每个常量都可以通过其名称读写。

让我们考虑一下猫猫的体积:Small、 和 Large。

在 JS 中创建枚举的一种简单方法(尽管不是最佳实践),是使用普通 JS 对象。

const Sizes = {
  Small: '薛定谔',
  Medium: '盯裆猫',
  Large: '龙猫'
}
const mySize = Sizes.Medium
console.log(mySize === Sizes.Medium) // true

Sizes 是一个基于普通 JS 对象的枚举,它有 3 个命名常量:

Sizes 也是一个字符串枚举,因为命名常量的值是字符串:

要读写命名常量的值,请使用对象属性操作符。举个栗子,Sizes. 的值是 '盯裆猫'。

枚举更具可读性、更直观,且消除了“魔术字符串”或“魔术数字”的滥用。

利弊

普通对象枚举之所以有吸引力,是因为它十分简单:只需定义一个键值对象,枚举就欧了。

但在大型代码库中,我们可能会意外修改枚举对象,这会影响 App 的运行时。

const size1 = Sizes.Medium
const size2 = (Sizes.Medium = '柴郡猫') // 意外修改!
console.log(size1 === Sizes.Medium) // false

Sizes. 枚举值被意外修改。

size1 使用 Sizes. 初始化时,不再等于 Sizes.!

基于普通对象的枚举无法规避此类意外修改。

让我们瞄一下字符串和 枚举,以及如何冻结枚举对象,从而规避意外修改。

枚举值类型

除了字符串类型之外,枚举的值还可以是数字:

const Sizes = {
  Small: 0,
  Medium: 1,
  Large: 2
}
const mySize = Sizes.Medium
console.log(mySize === Sizes.Medium) // true

上述示例中的 Sizes 枚举是数字枚举,因为值为数字:0、1、2。

我们还可以创建 枚举:

const Sizes = {
  Small: Symbol('薛定谔'),
  Medium: Symbol('盯裆猫'),
  Large: Symbol('龙猫')
}
const mySize = Sizes.Medium
console.log(mySize === Sizes.Medium) // true

使用 的福利在于, 都独一无二。这意味着,我们必须使用枚举本身来比较枚举:

const mySize = Sizes.Medium
console.log(mySize === Sizes.Medium) // true
console.log(mySize === Symbol('盯裆猫')) // false

使用 枚举的短板在于,JSON.() 会将 序列化为 null、,或者跳过 值的属性:

const str1 = JSON.stringify(Sizes.Small)
console.log(str1) // undefined
const str2 = JSON.stringify([Sizes.Small])
console.log(str2) // '[null]'
const str3 = JSON.stringify({ size: Sizes.Small })
console.log(str3) // '{}'

下述示例中,我会使用字符串枚举。但大家可以按需使用任意值类型。

如果大家不受限于枚举值类型,那么优先选择字符串即可。字符串比数字和 更易调试。

基于 .() 的枚举

保护枚举对象免遭修改的优秀方案之一是冻结它。当对象被冻结时,您无法修改该对象,或者向该对象添加新属性。换而言之,该对象变为只读对象。

在 JS 中,.() 工具函数可以冻结对象。让我们冻结 Sizes 枚举:

const Sizes = Object.freeze({
  Small: '薛定谔',
  Medium: '盯裆猫',
  Large: '龙猫'
})
const mySize = Sizes.Medium
console.log(mySize === Sizes.Medium) // true

const Sizes = .({ ... }) 创建一个冻结对象。即使对象被冻结,我们也可以自由读写枚举值:const = Sizes.。

利弊

如果枚举属性被意外修改,那么 JS 会报错(严格模式下):

const size1 = Sizes.Medium
const size2 = (Sizes.Medium = 'foo') // 报错

语句 const size2 = Sizes. = 'foo' 对 Sizes. 属性意外赋值。

因为 Sizes 是一个冻结对象,JS(严格模式下)会报错:

TypeError: Cannot assign to read only property 'Medium' of object 

冻结对象枚举可以规避意外修改。

不过,还有一个问题。如果我们不小心拼错了枚举常量,那么结果会变成 :

console.log(Sizes.Med1um) // undefined

是 的错误拼写,Sizes. 表达式结果为 ,而不是抛出有关不存在的枚举常量的错误。

让我们瞄一下基于 Proxy 的枚举如何解决此问题。

基于 Proxy 的枚举

一个有趣的、也是我最爱的实现是基于 Proxy 的枚举。

Proxy 是一种特殊对象,它包装一个对象,修改对原始对象的操作行为。Proxy 不会改变原始对象的结构。

枚举代​理拦截枚举对象上的读写操作,并且:

下面是一个工厂函数的实现,它接受一个普通的枚举对象,并返回一个 Proxy 对象:

// enum.js
export function Enum(baseEnum) {
  return new Proxy(baseEnum, {
    get(target, name) {
      if (!baseEnum.hasOwnProperty(name)) {
        throw new Error(`此枚举中不存在 ${name} 枚举值`)
      }
      return baseEnum[name]
    },
    set(target, name, value) {
      throw new Error('无法向此枚举中添加新的枚举值')
    }
  })
}

Proxy 的 get() 方法会拦截读取操作,如果属性不存在就会报错。

set() 方法拦截存写操作,且只是为了报错。它旨在保护枚举对象规避存写操作的影响。

让我们将 Sizes 对象枚举包装到 Proxy 中:

import { Enum } from './enum'
const Sizes = Enum({
  Small: '薛定谔',
  Medium: '盯裆猫',
  Large: '龙猫'
})
const mySize = Sizes.Medium
console.log(mySize === Sizes.Medium) // true

代理枚举的工作方式与普通对象枚举一毛一样。

利弊

虽然但是object.freeze(),代理枚举不会被意外重写,或读写不存在的枚举常量:

const size1 = Sizes.Med1um // 报错:常量不存在
const size2 = (Sizes.Medium = '柴郡猫') // 报错:只读枚举

Sizes. 会报错,因为枚举中不存在 常量名。

Sizes. = '柴郡猫' 会报错,因为枚举属性被修改。

代理枚举的短板在于,我们必须导入 Enum 工厂函数,并将枚举对象包装进去。

基于类的枚举

创建枚举的另一种有趣方法是使用 class。

基于类的枚举包含一组静态字段,其中每个静态字段代表一个常量名枚举。每个枚举常量的值本身就是该类的一个实例。

我们使用 Sizes 类来实现枚举:

class Sizes {
  static Small = new Sizes('薛定谔')
  static Medium = new Sizes('盯裆猫')
  static Large = new Sizes('龙猫')
  #value
  constructor(value) {
    this.#value = value
  }
  toString() {
    return this.#value
  }
}
const mySize = Sizes.Small
console.log(mySize === Sizes.Small) // true
console.log(mySize instanceof Sizes) // true

Sizes 是代表枚举的类。枚举常量是类中的静态字段,比如 Small = new Sizes('薛定谔')。

Sizes 类的每个实例还有一个私有字段 #value,它表示枚举的原始值。

基于类的枚举的福利之一在于,能够在运行时使用 操作符确定该值是否为枚举。举个栗子, Sizes 的计算结果为 true,因为 是一个枚举值。

基于类的枚举的比较是基于实例的(普通枚举、冻结枚举或代理枚举则是原始比较):

const mySize = Sizes.Small
console.log(mySize === new Sizes('small')) // false

Sizes.Small 不等于 new Sizes('薛定谔')。

Sizes.Small 和 new Sizes('薛定谔') 即使具有相同的 #value,也是不同的对象实例。

利弊

基于类的枚举无法规避重写,或读写不存在的常量命名枚举。

const size1 = Sizes.medium // 允许读写不存在的枚举值
const size2 = (Sizes.Medium = 'foo') // 枚举值允许意外重写

但我们可以控制新实例的创建,举个栗子,通过计算构造函数内创建的实例数量。如果创建的实例超过 3 个就报错。

当然了,尽量简化枚举的实现。枚举是简单的数据结构。

总结

JS 中创建枚举有 4 种方案。

最简单的方法是使用普通 JS 对象:

const MyEnum = {
  Option1: 'option1',
  Option2: 'option2',
  Option3: 'option3'
}

普通对象枚举适合小型项目或快速演示。

如果想保护枚举对象规避意外重写,第二个选项是冻结普通对象:

const MyEnum = Object.freeze({
  Option1: 'option1',
  Option2: 'option2',
  Option3: 'option3'
})

冻结对象枚举适用于希望确保枚举不会意外修改的中大型项目。

第三种选择是 Proxy:

// enum.js
export function Enum(baseEnum) {
  return new Proxy(baseEnum, {
    get(target, name) {
      if (!baseEnum.hasOwnProperty(name)) {
        throw new Error(`"${name}" value does not exist in the enum`)
      }
      return baseEnum[name]
    },
    set(target, name, value) {
      throw new Error('Cannot add a new value to the enum')
    }
  })
}
// index.js
import { Enum } from './enum'
const MyEnum = Enum({
  Option1: 'option1',
  Option2: 'option2',
  Option3: 'option3'
})

代理枚举适用于中大型项目,更好地保护枚举规避重写,或者读写不存在的命名常量。

代理枚举是我的个人偏好。

第四个选项是使用基于类的枚举,其中每个命名常量都是该类的一个实例,并存储为该类的静态属性:

class MyEnum {
  static Option1 = new MyEnum('option1')
  static Option2 = new MyEnum('option2')
  static Option3 = new MyEnum('option3')
  #value
  constructor(value) {
    this.#value = value
  }
  toString() {
    return this.#value
  }
}

如果您喜欢类,基于类的枚举也能奏效。虽然但是,基于类的枚举的鲁棒性低于冻结或代理枚举。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注