JS 没有枚举,但 JS 可以创建枚举

大家好,这里是大家的林语冰。欢迎持续关注"前端俱乐部"

每周的星期(周一、周二二、...、周日)、每年的季节(春夏秋冬)和基本方位(东西南北)等等,都是有限值的集合。

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

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

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

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

  • 基于普通对象的枚举
  • 枚举值类型
  • 基于 Object.freeze() 的枚举
  • 基于 Proxy 的枚举
  • 基于类的枚举

免责声明

本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请传送 4 Ways to Create an Enum in JavaScript

基于普通对象的枚举

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

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

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

js 复制代码
const Sizes = {
  Small: '薛定谔',
  Medium: '盯裆猫',
  Large: '龙猫'
}
const mySize = Sizes.Medium

console.log(mySize === Sizes.Medium) // true

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

  • Sizes.Small
  • Sizes.Medium
  • Sizes.Large

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

  • '薛定谔'
  • '盯裆猫'
  • '龙猫'

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

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

利弊

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

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

js 复制代码
const size1 = Sizes.Medium
const size2 = (Sizes.Medium = '柴郡猫') // 意外修改!

console.log(size1 === Sizes.Medium) // false

Sizes.Medium 枚举值被意外修改。

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

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

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

枚举值类型

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

js 复制代码
const Sizes = {
  Small: 0,
  Medium: 1,
  Large: 2
}
const mySize = Sizes.Medium

console.log(mySize === Sizes.Medium) // true

上述示例中的 Sizes 枚举是数字枚举,因为值为数字:012

我们还可以创建 Symbol 枚举:

js 复制代码
const Sizes = {
  Small: Symbol('薛定谔'),
  Medium: Symbol('盯裆猫'),
  Large: Symbol('龙猫')
}
const mySize = Sizes.Medium

console.log(mySize === Sizes.Medium) // true

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

js 复制代码
const mySize = Sizes.Medium

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

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

js 复制代码
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) // '{}'

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

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

基于 Object.freeze() 的枚举

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

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

js 复制代码
const Sizes = Object.freeze({
  Small: '薛定谔',
  Medium: '盯裆猫',
  Large: '龙猫'
})
const mySize = Sizes.Medium

console.log(mySize === Sizes.Medium) // true

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

利弊

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

js 复制代码
const size1 = Sizes.Medium
const size2 = (Sizes.Medium = 'foo') // 报错

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

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

md 复制代码
TypeError: Cannot assign to read only property 'Medium' of object <Object>

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

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

js 复制代码
console.log(Sizes.Med1um) // undefined

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

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

基于 Proxy 的枚举

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

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

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

  • 访问不存在的枚举值时会报错
  • 变更枚举对象属性时会报错

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

js 复制代码
// 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('无法向此枚举中添加新的枚举值')
    }
  })
}

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

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

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

js 复制代码
import { Enum } from './enum'

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

console.log(mySize === Sizes.Medium) // true

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

利弊

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

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

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

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

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

基于类的枚举

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

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

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

js 复制代码
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 是代表枚举的类。枚举常量是类中的静态字段,比如 static Small = new Sizes('薛定谔')

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

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

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

js 复制代码
const mySize = Sizes.Small

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

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

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

利弊

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

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

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

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

总结

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

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

js 复制代码
const MyEnum = {
  Option1: 'option1',
  Option2: 'option2',
  Option3: 'option3'
}

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

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

js 复制代码
const MyEnum = Object.freeze({
  Option1: 'option1',
  Option2: 'option2',
  Option3: 'option3'
})

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

第三种选择是 Proxy

js 复制代码
// 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'
})

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

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

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

js 复制代码
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
  }
}

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

欢迎持续关注"前端俱乐部"!坚持阅读,自律打卡,每天一次,进步一点。

谢谢大家的点赞,掰掰~

相关推荐
长天一色2 分钟前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_23419 分钟前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河21 分钟前
CSS总结
前端·css
NiNg_1_23422 分钟前
Vue3 Pinia持久化存储
开发语言·javascript·ecmascript
读心悦23 分钟前
如何在 Axios 中封装事件中心EventEmitter
javascript·http
BigYe程普43 分钟前
我开发了一个出海全栈SaaS工具,还写了一套全栈开发教程
开发语言·前端·chrome·chatgpt·reactjs·个人开发
神之王楠1 小时前
如何通过js加载css和html
javascript·css·html
余生H1 小时前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈
程序员-珍1 小时前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
axihaihai1 小时前
网站开发的发展(后端路由/前后端分离/前端路由)
前端