深入探討 JavaScript
請支持這本書:購買捐款
(廣告,請不要封鎖。)

12 屬性的可列舉性



可列舉性是物件屬性的屬性。在本章中,我們將深入探討如何使用它,以及它如何影響 Object.keys()Object.assign() 等操作。

  必備知識:屬性屬性

在本章中,您應熟悉屬性屬性。如果您不熟悉,請查看 §9「屬性屬性:簡介」

12.1 可列舉性如何影響屬性迭代結構

為了展示各種操作如何受到可列舉性的影響,我們使用以下原型為 proto 的物件 obj

const protoEnumSymbolKey = Symbol('protoEnumSymbolKey');
const protoNonEnumSymbolKey = Symbol('protoNonEnumSymbolKey');
const proto = Object.defineProperties({}, {
  protoEnumStringKey: {
    value: 'protoEnumStringKeyValue',
    enumerable: true,
  },
  [protoEnumSymbolKey]: {
    value: 'protoEnumSymbolKeyValue',
    enumerable: true,
  },
  protoNonEnumStringKey: {
    value: 'protoNonEnumStringKeyValue',
    enumerable: false,
  },
  [protoNonEnumSymbolKey]: {
    value: 'protoNonEnumSymbolKeyValue',
    enumerable: false,
  },
});

const objEnumSymbolKey = Symbol('objEnumSymbolKey');
const objNonEnumSymbolKey = Symbol('objNonEnumSymbolKey');
const obj = Object.create(proto, {
  objEnumStringKey: {
    value: 'objEnumStringKeyValue',
    enumerable: true,
  },
  [objEnumSymbolKey]: {
    value: 'objEnumSymbolKeyValue',
    enumerable: true,
  },
  objNonEnumStringKey: {
    value: 'objNonEnumStringKeyValue',
    enumerable: false,
  },
  [objNonEnumSymbolKey]: {
    value: 'objNonEnumSymbolKeyValue',
    enumerable: false,
  },
});

12.1.1 僅考慮可列舉屬性的操作

表 2:忽略不可列舉屬性的操作。
操作 字串鍵 符號鍵 繼承
Object.keys() ES5
Object.values() ES2017
Object.entries() ES2017
展開 {...x} ES2018
Object.assign() ES6
JSON.stringify() ES5
for-in ES1

以下操作(總結於表 2)僅考慮可列舉的屬性

for-in 是唯一內建操作,其中繼承屬性的可列舉性很重要。所有其他操作僅適用於自身屬性。

12.1.2 同時考慮可列舉和不可列舉屬性的操作

表 3:同時考慮可列舉和不可列舉屬性的操作。
操作 字串鍵值 符號鍵值 繼承
Object.getOwnPropertyNames() ES5
Object.getOwnPropertySymbols() ES6
Reflect.ownKeys() ES6
Object.getOwnPropertyDescriptors() ES2017

以下操作(總結於表 3)同時考慮可列舉和不可列舉的屬性

12.1.3 內省操作的命名規則

內省讓程式可以在執行階段檢查值的結構。它是元程式設計:一般程式設計是撰寫程式;元程式設計是檢查和/或變更程式。

在 JavaScript 中,常見的內省操作有簡短的名稱,而較少使用的操作則有較長的名稱。忽略不可列舉的屬性是常態,這就是為什麼執行此操作的操作有簡短的名稱,而沒有執行此操作的操作有較長的名稱

然而,Reflect 方法(例如 Reflect.ownKeys())偏離此規則,因為 Reflect 提供更多「元」且與代理相關的操作。

此外,做出以下區別(自引入符號的 ES6 以來)

因此,Object.keys() 的更好名稱現在應該是 Object.names()

12.2 預定義和建立屬性的可列舉性

在本節中,我們將簡寫 Object.getOwnPropertyDescriptor() 如下

const desc = Object.getOwnPropertyDescriptor.bind(Object);

大多數資料屬性都使用以下屬性建立

{
  writable: true,
  enumerable: false,
  configurable: true,
}

其中包括

最重要的不可列舉屬性為

我們將在接下來探討可列舉性的使用案例,這將告訴我們為什麼有些屬性可列舉而有些則不可列舉。

12.3 可列舉性的使用案例

可列舉性是不一致的功能。它確實有使用案例,但總有一些警告。在本節中,我們將探討使用案例和警告。

12.3.1 使用案例:隱藏 for-in 迴圈的屬性

for-in 迴圈會遍歷物件的所有可列舉字串金鑰屬性,包括自有和繼承的屬性。因此,屬性 enumerable 用於隱藏不應遍歷的屬性。這是 ECMAScript 1 中引入可列舉性的原因。

一般來說,最好避免使用 for-in。接下來的兩個小節將說明原因。以下函式將幫助我們展示 for-in 的運作方式。

function listPropertiesViaForIn(obj) {
  const result = [];
  for (const key in obj) {
    result.push(key);
  }
  return result;
}
12.3.1.1 使用 for-in 處理物件的警告

for-in 會反覆處理所有屬性,包括繼承的屬性

const proto = {enumerableProtoProp: 1};
const obj = {
  __proto__: proto,
  enumerableObjProp: 2,
};
assert.deepEqual(
  listPropertiesViaForIn(obj),
  ['enumerableObjProp', 'enumerableProtoProp']);

對於一般的純粹物件,for-in 看不到繼承的方法,例如 Object.prototype.toString(),因為它們都是不可列舉的

const obj = {};
assert.deepEqual(
  listPropertiesViaForIn(obj),
  []);

在使用者定義的類別中,所有繼承的屬性也都是不可列舉的,因此會被忽略

class Person {
  constructor(first, last) {
    this.first = first;
    this.last = last;
  }
  getName() {
    return this.first + ' ' + this.last;
  }
}
const jane = new Person('Jane', 'Doe');
assert.deepEqual(
  listPropertiesViaForIn(jane),
  ['first', 'last']);

結論:在物件中,for-in 會考量繼承的屬性,而我們通常希望略過這些屬性。因此,最好將 for-of 迴圈與 Object.keys()Object.entries() 等結合使用。

12.3.1.2 使用 for-in 處理陣列的注意事項

陣列和字串中的自有屬性 .length 不可列舉,因此會被 for-in 略過

> listPropertiesViaForIn(['a', 'b'])
[ '0', '1' ]
> listPropertiesViaForIn('ab')
[ '0', '1' ]

然而,使用 for-in 迭代陣列索引通常並不安全,因為它會考量繼承的屬性以及不是索引的自有屬性。以下範例說明如果陣列有自有的非索引屬性會發生什麼情況

const arr1 = ['a', 'b'];
assert.deepEqual(
  listPropertiesViaForIn(arr1),
  ['0', '1']);

const arr2 = ['a', 'b'];
arr2.nonIndexProp = 'yes';
assert.deepEqual(
  listPropertiesViaForIn(arr2),
  ['0', '1', 'nonIndexProp']);

結論:for-in 不應使用於迭代陣列索引,因為它會考量索引屬性和非索引屬性

12.3.2 使用案例:標記屬性為不可複製

透過讓屬性不可列舉,我們可以將它們隱藏在某些複製作業中。在繼續進行更現代的複製作業之前,讓我們先檢查兩個歷史性的複製作業。

12.3.2.1 歷史性複製作業:Prototype 的 Object.extend()

Prototype 是由 Sam Stephenson 於 2005 年 2 月建立的 JavaScript 框架,作為 Ruby on Rails 中 Ajax 支援的基礎。

Prototype 的 Object.extend(destination, source) 會將 source 中所有可列舉的自有和繼承屬性複製到 destination 的自有屬性中。它的實作方式如下

function extend(destination, source) {
  for (var property in source)
    destination[property] = source[property];
  return destination;
}

如果我們對物件使用 Object.extend(),我們可以看到它會將繼承的屬性複製到自有屬性中,並略過不可列舉的屬性(它也會略過符號鍵的屬性)。這一切都歸因於 for-in 的運作方式。

const proto = Object.defineProperties({}, {
  enumProtoProp: {
    value: 1,
    enumerable: true,
  },
  nonEnumProtoProp: {
    value: 2,
    enumerable: false,
  },
});
const obj = Object.create(proto, {
  enumObjProp: {
    value: 3,
    enumerable: true,
  },
  nonEnumObjProp: {
    value: 4,
    enumerable: false,
  },
});

assert.deepEqual(
  extend({}, obj),
  {enumObjProp: 3, enumProtoProp: 1});
12.3.2.2 歷史性複製作業:jQuery 的 $.extend()

jQuery 的 $.extend(target, source1, source2, ···) 運作方式類似於 Object.extend()

12.3.2.3 可列舉性驅動複製的缺點

以可列舉性為基礎的複製有幾個缺點

12.3.2.4 Object.assign() [ES5]

在 ES6 中,Object.assign(target, source_1, source_2, ···) 可用於將來源合併到目標中。會考慮來源的所有自身可列舉屬性(使用字串金鑰或符號金鑰)。Object.assign() 使用「取得」操作從來源讀取值,並使用「設定」操作將值寫入目標。

關於可列舉性,Object.assign() 延續了 Object.extend()$.extend() 的傳統。引用 Yehuda Katz

Object.assign 將為所有已經流通的 extend() API 鋪平道路。我們認為,在這些情況下不複製可列舉方法的先例足以讓 Object.assign 具有這種行為。

換句話說:Object.assign() 是在考慮從 $.extend()(和類似函式)升級的途徑下建立的。它的方法比 $.extend 更簡潔,因為它忽略了繼承的屬性。

12.3.2.5 在複製時非可列舉性有用的罕見範例

非可列舉性有幫助的情況很少。一個罕見的範例是 函式庫 fs-extra 最近遇到的問題

12.3.3 將屬性標記為私人

如果我們將屬性設定為不可列舉,它將無法再透過 Object.keys()for-in 迴圈等方式看到。對於這些機制而言,該屬性是私人的。

然而,這種方法有幾個問題

12.3.4 將自己的屬性隱藏在 JSON.stringify() 之外

JSON.stringify() 輸出中不包含不可列舉的屬性。因此,我們可以使用可列舉性來決定哪些自己的屬性應匯出到 JSON。此用例類似於前一個用例,將屬性標記為私人。但它也有所不同,因為這更多與匯出有關,而且適用於稍微不同的考量。例如:物件可以從 JSON 完全重建嗎?

作為可列舉性的替代方案,物件可以實作方法 .toJSON(),而 JSON.stringify() 會字串化該方法傳回的內容,而不是物件本身。以下範例說明了它的運作方式。

class Point {
  static fromJSON(json) {
    return new Point(json[0], json[1]);
  }
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  toJSON() {
    return [this.x, this.y];
  }
}
assert.equal(
  JSON.stringify(new Point(8, -3)),
  '[8,-3]'
);

我發現 toJSON() 比可列舉性更簡潔。它也給予我們更多自由度,讓我們可以決定儲存格式應該是什麼樣子。

12.4 結論

我們已經看到,幾乎所有非可列舉性的應用程式都是權宜之計,現在有其他更好的解決方案。

對於我們自己的程式碼,我們通常可以假裝可列舉性不存在

也就是說,我們會自動遵循最佳實務。