JavaScript中的四種列舉方式

chuck發表於2023-05-08

字串和數字具有無數個值,而其他型別如布林值則是有限的集合。

一週的日子(星期一,星期二,...,星期日),一年的季節(冬季,春季,夏季,秋季)和基本方向(北,東,南,西)都是具有有限值集合的例子。

當一個變數有一個來自有限的預定義常量的值時,使用列舉是很方便的。列舉使你不必使用魔法數字和字串(這被認為是一種反模式)。

讓我們看看在JavaScript中建立列舉的四種好方法(及其優缺點)。

基於物件的列舉

列舉是一種資料結構,它定義了一個有限的具名常量集。每個常量都可以透過其名稱來訪問。

讓我們來考慮一件T恤衫的尺寸:SmallMedium,和Large

在JavaScript中建立列舉的一個簡單方法(雖然不是最理想的)是使用一個普通的JavaScript物件。

const Sizes = {
  Small: 'small',
  Medium: 'medium',
  Large: 'large',
}

const mySize = Sizes.Medium

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

Sizes是一個基於JavaScript物件的列舉,它有三個具名常量:Sizes.SmallSizes.Medium以及Sizes.Large

Sizes也是一個字串列舉,因為具名常量的值是字串:'small''medium',以及 'large'

image.png

要訪問具名常量值,請使用屬性訪問器。例如,Sizes.Medium的值是'medium'

列舉的可讀性更強,更明確,並消除了對魔法字串或數字的使用。

優缺點

普通的物件列舉之所以吸引人,是因為它很簡單:只要定義一個帶有鍵和值的物件,列舉就可以了。

但是在一個大的程式碼庫中,有人可能會意外地修改列舉物件,這將影響應用程式的執行。

const Sizes = {
  Small: 'small',
  Medium: 'medium',
  Large: 'large',
}

const size1 = Sizes.Medium
const size2 = Sizes.Medium = 'foo' // Changed!

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

Sizes.Medium 列舉值被意外地改變。

size1,雖然被初始化為Sizes.Medium,但不再等同於Sizes.Medium

普通物件的實現沒有受到保護,因此無法避免這種意外的改變。

讓我們仔細看看字串和symbol列舉。以及如何凍結列舉物件以避免意外改變的問題。

列舉值型別

除了字串型別,列舉值可以是一個數字:

const Sizes = {
  Small: 0,
  Medium: 1,
  Large: 2
}

const mySize = Sizes.Medium

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

上述例子中,Sizes列舉是數值列舉,因為值都是數字:0,1,2。

你也可以建立symbol列舉:

const Sizes = {
  Small: Symbol('small'),
  Medium: Symbol('medium'),
  Large: Symbol('large')
}

const mySize = Sizes.Medium

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

使用symbol的好處是,每個symbol都是唯一的。這意味著,你總是要透過使用列舉本身來比較列舉:

const Sizes = {
  Small: Symbol('small'),
  Medium: Symbol('medium'),
  Large: Symbol('large')
}

const mySize = Sizes.Medium

console.log(mySize === Sizes.Medium)     // logs true
console.log(mySize === Symbol('medium')) // logs false

使用symbol列舉的缺點是JSON.stringify()symbol字串化為nullundefined,或者跳過有symbol作為值的屬性:

const Sizes = {
  Small: Symbol('small'),
  Medium: Symbol('medium'),
  Large: Symbol('large')
}

const str1 = JSON.stringify(Sizes.Small)
console.log(str1) // logs undefined

const str2 = JSON.stringify([Sizes.Small])
console.log(str2) // logs '[null]'

const str3 = JSON.stringify({ size: Sizes.Small })
console.log(str3) // logs '{}'

在下面的例子中,我將使用字串列舉。但是你可以自由地使用你需要的任何值型別。

如果你可以自由選擇列舉值型別,就用字串吧。字串比數字和symbol更容易進行除錯。

基於Object.freeze()列舉

保護列舉物件不被修改的一個好方法是凍結它。當一個物件被凍結時,你不能修改或向該物件新增新的屬性。換句話說,這個物件變成了只讀。

在JavaScript中,Object.freeze()工具函式可以凍結一個物件。讓我們來凍結Sizes列舉:

const Sizes = Object.freeze({
  Small: 'small',
  Medium: 'medium',
  Large: 'large',
})

const mySize = Sizes.Medium

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

const Sizes = Object.freeze({ ... }) 建立一個凍結的物件。即使被凍結,你也可以自由地訪問列舉值: const mySize = Sizes.Medium

優缺點

如果一個列舉屬性被意外地改變了,JavaScript會丟擲一個錯誤(在嚴格模式下):

const Sizes = Object.freeze({
  Small: 'Small',
  Medium: 'Medium',
  Large: 'Large',
})

const size1 = Sizes.Medium
const size2 = Sizes.Medium = 'foo' // throws TypeError

語句const size2 = Sizes.Medium = 'foo'Sizes.Medium 屬性進行了意外的賦值。

因為Sizes是一個凍結的物件,JavaScript(在嚴格模式下)會丟擲錯誤:

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

凍結的物件列舉被保護起來,不會被意外地改變。

不過,還有一個問題。如果你不小心把列舉常量拼錯了,那麼結果將是未undefined

const Sizes = Object.freeze({
  Small: 'small',
  Medium: 'medium',
  Large: 'large',
})

console.log(Sizes.Med1um) // logs undefined

Sizes.Med1um表示式(Med1umMedium的錯誤拼寫版本)結果為未定義,而不是丟擲一個關於不存在的列舉常量的錯誤。

讓我們看看基於代理的列舉如何解決這個問題。

基於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')
    }
  })
}

代理的get()方法攔截讀取操作,如果屬性名稱不存在,則丟擲一個錯誤。

set()方法攔截寫操作,但只是丟擲一個錯誤。這是為保護列舉物件不被寫入操作而設計的。

讓我們把sizes物件列舉包裝成一個代理:

import { Enum } from './enum'

const Sizes = Enum({
  Small: 'small',
  Medium: 'medium',
  Large: 'large',
})

const mySize = Sizes.Medium

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

代理列舉的工作方式與普通物件列舉完全一樣。

優缺點

然而,代理列舉受到保護,以防止意外覆蓋或訪問不存在的列舉常量:

import { Enum } from './enum'

const Sizes = Enum({
  Small: 'small',
  Medium: 'medium',
  Large: 'large',
})

const size1 = Sizes.Med1um         // throws Error: non-existing constant
const size2 = Sizes.Medium = 'foo' // throws Error: changing the enum

Sizes.Med1um丟擲一個錯誤,因為Med1um常量名稱在列舉中不存在。

Sizes.Medium = 'foo' 丟擲一個錯誤,因為列舉屬性已被改變。

代理列舉的缺點是,你總是要匯入列舉工廠函式,並將你的列舉物件包裹在其中。

基於類的列舉

另一種有趣的建立列舉的方法是使用一個JavaScript類。

一個基於類的列舉包含一組靜態欄位,其中每個靜態欄位代表一個列舉的常量。每個列舉常量的值本身就是該類的一個例項。

讓我們用一個Sizes類來實現sizes列舉:

class Sizes {
  static Small = new Sizes('small')
  static Medium = new Sizes('medium')
  static Large = new Sizes('large')
  #value

  constructor(value) {
    this.#value = value
  }

  toString() {
    return this.#value
  }
}

const mySize = Sizes.Small

console.log(mySize === Sizes.Small)  // logs true
console.log(mySize instanceof Sizes) // logs true

Sizes是一個代表列舉的類。列舉常量是該類的靜態欄位,例如,static Small = new Sizes('small')

Sizes類的每個例項也有一個私有欄位#value,它代表列舉的原始值。

基於類的列舉的一個很好的優點是能夠在執行時使用instanceof操作來確定值是否是列舉。例如,mySize instanceof Sizes結果為真,因為mySize是一個列舉值。

基於類的列舉比較是基於例項的(而不是在普通、凍結或代理列舉的情況下的原始比較):

class Sizes {
  static Small = new Sizes('small')
  static Medium = new Sizes('medium')
  static Large = new Sizes('large')
  #value

  constructor(value) {
    this.#value = value
  }

  toString() {
    return this.#value
  }
}

const mySize = Sizes.Small

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

mySize(即Sizes.Small)不等於new Sizes('small')

Sizes.Smallnew Sizes('small'),即使具有相同的#value,也是不同的物件例項。

優缺點

基於類的列舉不能受到保護,以防止覆蓋或訪問不存在的列舉具名常量。

class Sizes {
  static Small = new Sizes('small')
  static Medium = new Sizes('medium')
  static Large = new Sizes('large')
  #value

  constructor(value) {
    this.#value = value
  }

  toString() {
    return this.#value
  }
}

const size1 = Sizes.medium         // a non-existing enum value can be accessed
const size2 = Sizes.Medium = 'foo' // enum value can be overwritten accidentally

但你可以控制新例項的建立,例如,透過計算在建構函式內建立了多少個例項。然後在建立超過3個例項時丟擲一個錯誤。

當然,最好讓你的列舉實現儘可能的簡單。列舉的目的是為了成為普通的資料結構。

總結

在JavaScript中,有4種建立列舉的好方法。

最簡單的方法是使用一個普通的JavaScript物件:

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

普通的物件列舉適合小型專案或快速演示。

第二種選擇,如果你想保護列舉物件不被意外覆蓋,則可以使用凍結的物件:

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

凍結的物件列舉適合於中型或大型專案,你要確保列舉不會被意外地改變。

第三種選擇是代理方法:

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')
    }
  })
}
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
  }
}

如果你喜歡類的話,基於類的列舉是可行的。然而,基於類的列舉比凍結的或代理的列舉保護得更少。

你還知道哪些在JavaScript中建立列舉的方法?

以上就是本文的全部內容,如果對你有所幫助,歡迎點贊、收藏、轉發~

相關文章