JavaScript for impatient programmers (ES2021 版)
請支持這本書:購買捐款
(廣告,請不要阻擋。)

28 單一物件



在本書中,JavaScript 的物件導向程式 (OOP) 風格分為四個步驟介紹。本章涵蓋步驟 1;下一章 涵蓋步驟 2-4。這些步驟如下(圖 8

  1. 單一物件(本章):JavaScript 的基本 OOP 建構區塊物件如何獨立運作?
  2. 原型鏈(下一章):每個物件都有零個或多個原型物件的鏈。原型是 JavaScript 的核心繼承機制。
  3. 類別(下一章):JavaScript 的類別是物件的工廠。類別與其執行個體之間的關係基於原型繼承。
  4. 子類別(下一章):子類別與其超類別之間的關係也基於原型繼承。
Figure 8: This book introduces object-oriented programming in JavaScript in four steps.

28.1 什麼是物件?

在 JavaScript 中

28.1.1 物件的角色:記錄與字典

物件在 JavaScript 中扮演兩個角色

這些角色會影響物件在本章節中的說明方式

28.2 物件作為記錄

讓我們首先探討物件的記錄角色。

28.2.1 物件文字:屬性

物件文字是建立物件作為記錄的一種方式。它們是 JavaScript 的突出特點:我們可以直接建立物件,無需類別!以下是一個範例

const jane = {
  first: 'Jane',
  last: 'Doe', // optional trailing comma
};

在範例中,我們透過物件文字建立了一個物件,其以大括號 {} 開始和結束。在其中,我們定義了兩個屬性(鍵值對)

自 ES5 以來,物件文字中允許使用尾隨逗號。

我們稍後將看到指定屬性鍵的其他方式,但使用這種指定方式時,它們必須遵循 JavaScript 變數名稱的規則。例如,我們可以使用 first_name 作為屬性鍵,但不能使用 first-name)。但是,允許使用保留字

const obj = {
  if: true,
  const: true,
};

為了檢查各種操作對物件的影響,我們偶爾會在本章節的這一部分中使用 Object.keys()。它列出屬性鍵

> Object.keys({a:1, b:2})
[ 'a', 'b' ]

28.2.2 物件文字:屬性值簡寫

只要屬性的值是透過變數名稱定義的,而且該名稱與鍵相同,我們就可以省略該鍵。

function createPoint(x, y) {
  return {x, y};
}
assert.deepEqual(
  createPoint(9, 2),
  { x: 9, y: 2 }
);

28.2.3 取得屬性

以下是我們取得(讀取)屬性的方式(A 行)

const jane = {
  first: 'Jane',
  last: 'Doe',
};

// Get property .first
assert.equal(jane.first, 'Jane'); // (A)

取得未知屬性會產生 undefined

assert.equal(jane.unknownProperty, undefined);

28.2.4 設定屬性

以下是我們設定(寫入)屬性的方式

const obj = {
  prop: 1,
};
assert.equal(obj.prop, 1);
obj.prop = 2; // (A)
assert.equal(obj.prop, 2);

我們剛剛透過設定變更了現有屬性。如果我們設定未知屬性,我們將建立一個新條目

const obj = {}; // empty object
assert.deepEqual(
  Object.keys(obj), []);

obj.unknownProperty = 'abc';
assert.deepEqual(
  Object.keys(obj), ['unknownProperty']);

28.2.5 物件文字:方法

以下程式碼顯示如何透過物件文字建立方法 .says()

const jane = {
  first: 'Jane', // data property
  says(text) {   // method
    return `${this.first} says “${text}”`; // (A)
  }, // comma as separator (optional at end)
};
assert.equal(jane.says('hello'), 'Jane says “hello”');

在方法呼叫 jane.says('hello') 期間,jane 被稱為方法呼叫的接收者,並指定給特殊變數 this(有關 this 的更多資訊,請參閱 §28.4「方法和特殊變數 this)。這使方法 .says() 能夠在 A 行中存取同層屬性 .first

28.2.6 物件文字:存取器

JavaScript 中有兩種存取器

28.2.6.1 取得器

取得器是透過在方法定義前加上修飾詞 get 來建立的

const jane = {
  first: 'Jane',
  last: 'Doe',
  get full() {
    return `${this.first} ${this.last}`;
  },
};

assert.equal(jane.full, 'Jane Doe');
jane.first = 'John';
assert.equal(jane.full, 'John Doe');
28.2.6.2 設定器

設定器是透過在方法定義前加上修飾詞 set 來建立的

const jane = {
  first: 'Jane',
  last: 'Doe',
  set full(fullName) {
    const parts = fullName.split(' ');
    this.first = parts[0];
    this.last = parts[1];
  },
};

jane.full = 'Richard Roe';
assert.equal(jane.first, 'Richard');
assert.equal(jane.last, 'Roe');

  練習:透過物件文字建立物件

exercises/single-objects/color_point_object_test.mjs

28.3 擴散到物件文字(...)[ES2018]

在函式呼叫內,擴散(...)會將 可迭代 物件 的迭代值轉換為參數。

在物件文字內,擴散屬性會將另一個物件的屬性新增到目前的物件

> const obj = {foo: 1, bar: 2};
> {...obj, baz: 3}
{ foo: 1, bar: 2, baz: 3 }

如果屬性金鑰發生衝突,最後提到的屬性會「獲勝」

> const obj = {foo: 1, bar: 2, baz: 3};
> {...obj, foo: true}
{ foo: true, bar: 2, baz: 3 }
> {foo: true, ...obj}
{ foo: 1, bar: 2, baz: 3 }

所有值都可以擴散,甚至包括 undefinednull

> {...undefined}
{}
> {...null}
{}
> {...123}
{}
> {...'abc'}
{ '0': 'a', '1': 'b', '2': 'c' }
> {...['a', 'b']}
{ '0': 'a', '1': 'b' }

字串和陣列的屬性 .length 會隱藏在這種運算中(它不是 可列舉的;有關更多資訊,請參閱 §28.8.3「屬性屬性和屬性描述符 [ES5]」)。

28.3.1 擴散的使用案例:複製物件

我們可以使用擴散來建立物件 original 的副本

const copy = {...original};

注意事項 – 複製是淺層的copy 是個新的物件,包含 original 所有屬性(金鑰值條目)的複製品。但是,如果屬性值是物件,則不會複製它們本身;它們會在 originalcopy 之間共用。我們來看個範例

const original = { a: 1, b: {foo: true} };
const copy = {...original};

copy 的第一層真的是副本:如果我們變更該層級的任何屬性,它不會影響原始檔

copy.a = 2;
assert.deepEqual(
  original, { a: 1, b: {foo: true} }); // no change

但是,更深層的層級不會被複製。例如,.b 的值會在 original 和 copy 之間共用。在 copy 中變更 .b 也會在 original 中變更它。

copy.b.foo = false;
assert.deepEqual(
  original, { a: 1, b: {foo: false} });

  JavaScript 沒有內建支援深度複製

深度複製 物件(所有層級都會被複製)出了名的難以以通用方式執行。因此,JavaScript 目前沒有內建運算來執行它們。如果我們需要這種運算,我們必須自己實作。

28.3.2 擴散的使用案例:遺失屬性的預設值

如果我們程式碼的其中一個輸入是一個包含資料的物件,我們可以透過指定預設值來讓屬性變成可選的,這些預設值會在這些屬性不存在時使用。一種這樣做的方法是透過一個物件,其屬性包含預設值。在以下範例中,該物件是 DEFAULTS

const DEFAULTS = {foo: 'a', bar: 'b'};
const providedData = {foo: 1};

const allData = {...DEFAULTS, ...providedData};
assert.deepEqual(allData, {foo: 1, bar: 'b'});

結果,物件 allData 是透過複製 DEFAULTS 並使用 providedData 的屬性覆寫其屬性而建立的。

但是我們不需要一個物件來指定預設值;我們也可以個別在物件字面上指定它們

const providedData = {foo: 1};

const allData = {foo: 'a', bar: 'b', ...providedData};
assert.deepEqual(allData, {foo: 1, bar: 'b'});

28.3.3 擴散的用例:非破壞性變更屬性

到目前為止,我們已經遇到一種變更物件屬性 .foo 的方法:我們將其 設定(A 行)並變異物件。也就是說,這種變更屬性的方法是 破壞性的

const obj = {foo: 'a', bar: 'b'};
obj.foo = 1; // (A)
assert.deepEqual(obj, {foo: 1, bar: 'b'});

透過擴散,我們可以 非破壞性 地變更 .foo – 我們製作一個 obj 的副本,其中 .foo 有不同的值

const obj = {foo: 'a', bar: 'b'};
const updatedObj = {...obj, foo: 1};
assert.deepEqual(updatedObj, {foo: 1, bar: 'b'});

  練習:透過擴散非破壞性地更新屬性(固定金鑰)

exercises/single-objects/update_name_test.mjs

28.4 方法和特殊變數 this

28.4.1 方法是其值為函式的屬性

讓我們重新檢視用於介紹方法的範例

const jane = {
  first: 'Jane',
  says(text) {
    return `${this.first} says “${text}”`;
  },
};

有點令人驚訝的是,方法是函式

assert.equal(typeof jane.says, 'function');

為什麼會這樣?我們在 可呼叫值章節 中學到,一般函式扮演多種角色。方法 是其中一種角色。因此,在底層,jane 大致如下所示。

const jane = {
  first: 'Jane',
  says: function (text) {
    return `${this.first} says “${text}”`;
  },
};

28.4.2 特殊變數 this

考慮以下程式碼

const obj = {
  someMethod(x, y) {
    assert.equal(this, obj); // (A)
    assert.equal(x, 'a');
    assert.equal(y, 'b');
  }
};
obj.someMethod('a', 'b'); // (B)

在 B 行中,obj 是方法呼叫的 接收者。它透過一個隱含(隱藏)參數傳遞給儲存在 obj.someMethod 中的函式,其名稱為 this(A 行)。

這一點很重要:瞭解 this 的最佳方式是將其視為一般函式(因此也包括方法)的隱含參數。

28.4.3 方法和 .call()

方法是函式,而在 §25.7 「函式方法:.call().apply().bind() 中,我們看到函式本身有方法。其中一種方法是 .call()。讓我們看一個範例來瞭解這個方法如何運作。

在前一章節中,有這個方法呼叫

obj.someMethod('a', 'b')

此呼叫等同於

obj.someMethod.call(obj, 'a', 'b');

也等同於

const func = obj.someMethod;
func.call(obj, 'a', 'b');

.call() 使通常隱含的參數 this 明確化:透過 .call() 呼叫函式時,第一個參數是 this,接著是常規(明確)函式參數。

順帶一提,這表示實際上共有兩個不同的點運算子

  1. 一個用於存取屬性:obj.prop
  2. 另一個用於呼叫方法:obj.prop()

它們的不同之處在於 (2) 不只是 (1) 後面加上函式呼叫運算子 ()。相反地,(2) 另外提供一個 this 值。

28.4.4 方法和 .bind()

.bind() 是函式物件的另一種方法。在以下程式碼中,我們使用 .bind() 將方法 .says() 轉換為獨立函式 func()

const jane = {
  first: 'Jane',
  says(text) {
    return `${this.first} says “${text}”`; // (A)
  },
};

const func = jane.says.bind(jane, 'hello');
assert.equal(func(), 'Jane says “hello”');

在此透過 .bind()this 設定為 jane 至關重要。否則,func() 無法正常運作,因為 this 用於 A 行。在下一章節中,我們將探討原因。

28.4.5 this 陷阱:擷取方法

我們現在對函式和方法相當了解,準備來看看涉及方法和 this 的最大陷阱:如果我們不小心,函式呼叫從物件擷取的方法可能會失敗。

在以下範例中,我們在擷取方法 jane.says()、將其儲存在變數 func 中,以及函式呼叫 func() 時失敗。

const jane = {
  first: 'Jane',
  says(text) {
    return `${this.first} says “${text}”`;
  },
};
const func = jane.says; // extract the method
assert.throws(
  () => func('hello'), // (A)
  {
    name: 'TypeError',
    message: "Cannot read property 'first' of undefined",
  });

在 A 行中,我們進行一般函式呼叫。而在一般函式呼叫中,thisundefined(如果 嚴格模式 處於啟用狀態,而它幾乎總是啟用的)。因此,A 行等同於

assert.throws(
  () => jane.says.call(undefined, 'hello'), // `this` is undefined!
  {
    name: 'TypeError',
    message: "Cannot read property 'first' of undefined",
  });

我們要如何修正這個問題?我們需要使用 .bind() 來擷取方法 .says()

const func2 = jane.says.bind(jane);
assert.equal(func2('hello'), 'Jane says “hello”');

.bind() 確保當我們呼叫 func() 時,this 永遠是 jane

我們也可以使用箭頭函式來擷取方法

const func3 = text => jane.says(text);
assert.equal(func3('hello'), 'Jane says “hello”');
28.4.5.1 範例:擷取方法

以下是我們可能在實際網頁開發中看到的程式碼的簡化版本

class ClickHandler {
  constructor(id, elem) {
    this.id = id;
    elem.addEventListener('click', this.handleClick); // (A)
  }
  handleClick(event) {
    alert('Clicked ' + this.id);
  }
}

在 A 行中,我們沒有正確擷取方法 .handleClick()。我們應該這樣做

elem.addEventListener('click', this.handleClick.bind(this));
28.4.5.2 如何避免擷取方法的陷阱

唉,沒有簡單的方法可以避免擷取方法的陷阱:每當我們擷取方法時,我們都必須小心並正確執行此操作,例如,透過繫結 this 或使用箭頭函式。

  練習:擷取方法

練習/單一物件/method_extraction_exrc.mjs

28.4.6 this 陷阱:意外遮蔽 this

  意外遮蔽 this 僅是普通函式的問題

箭頭函式不會遮蔽 this

考慮以下問題:當我們在普通函式內部時,我們無法存取周圍範圍的 this,因為普通函式有自己的 this。換句話說,內層範圍中的變數會隱藏外層範圍中的變數。這稱為 遮蔽。以下程式碼是一個範例

const prefixer = {
  prefix: '==> ',
  prefixStringArray(stringArray) {
    return stringArray.map(
      function (x) {
        return this.prefix + x; // (A)
      });
  },
};
assert.throws(
  () => prefixer.prefixStringArray(['a', 'b']),
  /^TypeError: Cannot read property 'prefix' of undefined$/);

在 A 行中,我們想要存取 .prefixStringArray()this。但我們無法存取,因為周圍的普通函式有自己的 this,會遮蔽(封鎖存取)方法的 this。前一個 this 的值為 undefined,因為回呼是函式呼叫的。這說明了錯誤訊息。

解決此問題最簡單的方法是透過箭頭函式,它沒有自己的 this,因此不會遮蔽任何東西

const prefixer = {
  prefix: '==> ',
  prefixStringArray(stringArray) {
    return stringArray.map(
      (x) => {
        return this.prefix + x;
      });
  },
};
assert.deepEqual(
  prefixer.prefixStringArray(['a', 'b']),
  ['==> a', '==> b']);

我們也可以將 this 儲存在不同的變數中(A 行),這樣它就不會被遮蔽

prefixStringArray(stringArray) {
  const that = this; // (A)
  return stringArray.map(
    function (x) {
      return that.prefix + x;
    });
},

另一個選項是透過 .bind() 為回呼指定固定的 this(A 行)

prefixStringArray(stringArray) {
  return stringArray.map(
    function (x) {
      return this.prefix + x;
    }.bind(this)); // (A)
},

最後,.map() 讓我們可以為 this 指定一個值(A 行),它在呼叫回呼時會使用該值

prefixStringArray(stringArray) {
  return stringArray.map(
    function (x) {
      return this.prefix + x;
    },
    this); // (A)
},
28.4.6.1 避免意外遮蔽 this 的陷阱

如果您遵循 §25.3.4「建議:優先使用專門函式而非普通函式」 中的建議,您可以避免意外遮蔽 this 的陷阱。以下是摘要

28.4.7 this 在各種情境中的值(進階)

this 在各種情境中的值為何?

在可呼叫實體內,this 的值取決於可呼叫實體如何被呼叫以及它是哪種類型的可呼叫實體

我們也可以在所有常見頂層範圍中存取 this

  提示:假裝 this 在頂層範圍中不存在

我喜歡這麼做,因為頂層 this 很混淆,而且很少有用。

28.5 屬性存取和方法呼叫的選擇性鏈結 [ES2020](進階)

存在下列種類的選擇性鏈接操作

obj?.prop     // optional static property access
obj?.[«expr»] // optional dynamic property access
func?.(«arg0», «arg1») // optional function or method call

大致概念是

28.5.1 範例:選擇性靜態屬性存取

考慮下列資料

const persons = [
  {
    surname: 'Zoe',
    address: {
      street: {
        name: 'Sesame Street',
        number: '123',
      },
    },
  },
  {
    surname: 'Mariner',
  },
  {
    surname: 'Carmen',
    address: {
    },
  },
];

我們可以使用選擇性鏈接安全地萃取街道名稱

const streetNames = persons.map(
  p => p.address?.street?.name);
assert.deepEqual(
  streetNames, ['Sesame Street', undefined, undefined]
);
28.5.1.1 透過 nullish 合併處理預設值

nullish 合併運算子 讓我們可以使用預設值 '(no street)',而不是 undefined

const streetNames = persons.map(
  p => p.address?.street?.name ?? '(no name)');
assert.deepEqual(
  streetNames, ['Sesame Street', '(no name)', '(no name)']
);

28.5.2 更詳細的運算子(進階)

28.5.2.1 選擇性靜態屬性存取

下列兩個表達式是等效的

o?.prop
(o !== undefined && o !== null) ? o.prop : undefined

範例

assert.equal(undefined?.prop, undefined);
assert.equal(null?.prop,      undefined);
assert.equal({prop:1}?.prop,  1);
28.5.2.2 選擇性動態屬性存取

下列兩個表達式是等效的

o?.[«expr»]
(o !== undefined && o !== null) ? o[«expr»] : undefined

範例

const key = 'prop';
assert.equal(undefined?.[key], undefined);
assert.equal(null?.[key], undefined);
assert.equal({prop:1}?.[key], 1);
28.5.2.3 選擇性函式或方法呼叫

下列兩個表達式是等效的

f?.(arg0, arg1)
(f !== undefined && f !== null) ? f(arg0, arg1) : undefined

範例

assert.equal(undefined?.(123), undefined);
assert.equal(null?.(123), undefined);
assert.equal(String?.(123), '123');

請注意,如果此運算子的左手邊不可呼叫,則會產生錯誤

assert.throws(
  () => true?.(123),
  TypeError);

為什麼?概念是,此運算子只容忍故意的遺漏。不可呼叫的值(除了 undefinednull)可能是錯誤,而且應該報告,而不是解決。

28.5.3 短路(進階)

在屬性存取和函式/方法呼叫的鏈中,一旦第一個選擇性運算子在左手邊遇到 undefinednull,評估就會停止

function isInvoked(obj) {
  let invoked = false;
  obj?.a.b.m(invoked = true);
  return invoked;
}

assert.equal(
  isInvoked({a: {b: {m() {}}}}), true);
  
// The left-hand side of ?. is undefined
// and the assignment is not executed
assert.equal(
  isInvoked(undefined), false);

此行為不同於一般的運算子/函式,其中 JavaScript 總是在評估運算子/函式之前評估所有運算元/引數。這稱為短路。其他短路運算子

28.5.4 常見問題

28.5.4.1 為什麼 o?.[x]f?.() 中有句點?

下列兩個選擇性運算子的語法並非理想

obj?.[«expr»]          // better: obj?[«expr»]
func?.(«arg0», «arg1») // better: func?(«arg0», «arg1»)

唉,較不優雅的語法是必要的,因為區分理想語法(第一個表達式)和條件運算子(第二個表達式)過於複雜

obj?['a', 'b', 'c'].map(x => x+x)
obj ? ['a', 'b', 'c'].map(x => x+x) : []
28.5.4.2 為什麼 null?.prop 會評估為 undefined,而不是 null

運算子 ?. 主要與其右手邊有關:屬性 .prop 是否存在?如果不存在,則提早停止。因此,保留有關其左手邊的資訊很少有用。但是,只有一個「提早終止」值確實簡化了事情。

28.6 物件作為字典(進階)

物件最適合用作記錄。但在 ES6 之前,JavaScript 沒有字典的資料結構(ES6 帶來了 Map)。因此,物件必須用作字典,這施加了一個重要的限制:金鑰必須是字串(ES6 也引入了符號)。

我們首先檢視與字典相關,但對於物件作為記錄也很有用的物件特徵。本節最後會提供實際使用物件作為字典的提示(劇透:如果可以,請使用 Maps)。

28.6.1 任意固定字串作為屬性金鑰

到目前為止,我們一直將物件用作記錄。屬性金鑰是必須為有效識別碼且在內部會變成字串的固定代碼

const obj = {
  mustBeAnIdentifier: 123,
};

// Get property
assert.equal(obj.mustBeAnIdentifier, 123);

// Set property
obj.mustBeAnIdentifier = 'abc';
assert.equal(obj.mustBeAnIdentifier, 'abc');

接下來,我們將超越屬性金鑰的這個限制:在本節中,我們將使用任意固定字串作為金鑰。在下一個小節中,我們將動態計算金鑰。

有兩種技術可讓我們使用任意字串作為屬性金鑰。

首先,在透過物件文字建立屬性金鑰時,我們可以引用屬性金鑰(使用單引號或雙引號)

const obj = {
  'Can be any string!': 123,
};

其次,在取得或設定屬性時,我們可以在其中使用方括號和字串

// Get property
assert.equal(obj['Can be any string!'], 123);

// Set property
obj['Can be any string!'] = 'abc';
assert.equal(obj['Can be any string!'], 'abc');

我們也可以將這些技術用於方法

const obj = {
  'A nice method'() {
    return 'Yes!';
  },
};

assert.equal(obj['A nice method'](), 'Yes!');

28.6.2 計算屬性金鑰

到目前為止,屬性金鑰始終是物件文字中的固定字串。在本節中,我們將學習如何動態計算屬性金鑰。這讓我們可以使用任意字串或符號。

物件文字中動態計算屬性金鑰的語法靈感來自動態存取屬性。也就是說,我們可以使用方括號來包裝表達式

const obj = {
  ['Hello world!']: true,
  ['f'+'o'+'o']: 123,
  [Symbol.toStringTag]: 'Goodbye', // (A)
};

assert.equal(obj['Hello world!'], true);
assert.equal(obj.foo, 123);
assert.equal(obj[Symbol.toStringTag], 'Goodbye');

計算金鑰的主要使用案例是將符號作為屬性金鑰(A 行)。

請注意,用於取得和設定屬性的方括號運算子適用於任意表達式

assert.equal(obj['f'+'o'+'o'], 123);
assert.equal(obj['==> foo'.slice(-3)], 123);

方法也可以有計算屬性金鑰

const methodKey = Symbol();
const obj = {
  [methodKey]() {
    return 'Yes!';
  },
};

assert.equal(obj[methodKey](), 'Yes!');

在本章的其餘部分,我們將再次主要使用固定屬性金鑰(因為它們在語法上更方便)。但所有功能也適用於任意字串和符號。

  練習:透過擴散(計算金鑰)非破壞性地更新屬性

exercises/single-objects/update_property_test.mjs

28.6.3 in 運算子:是否存在具有給定金鑰的屬性?

in 運算子檢查物件是否具有具有給定金鑰的屬性

const obj = {
  foo: 'abc',
  bar: false,
};

assert.equal('foo' in obj, true);
assert.equal('unknownKey' in obj, false);
28.6.3.1 透過真值檢查屬性是否存在

我們也可以使用真值檢查來判斷屬性是否存在

assert.equal(
  obj.foo ? 'exists' : 'does not exist',
  'exists');
assert.equal(
  obj.unknownKey ? 'exists' : 'does not exist',
  'does not exist');

前述檢查之所以有效,是因為 obj.foo 為真值,而且讀取不存在的屬性會傳回 undefined(為假值)。

不過,有一個重要的警告:如果屬性存在,但具有假值(undefinednullfalse0"" 等),真值檢查將會失敗。

assert.equal(
  obj.bar ? 'exists' : 'does not exist',
  'does not exist'); // should be: 'exists'

28.6.4 刪除屬性

我們可以使用 delete 算子來刪除屬性

const obj = {
  foo: 123,
};
assert.deepEqual(Object.keys(obj), ['foo']);

delete obj.foo;

assert.deepEqual(Object.keys(obj), []);

28.6.5 列出屬性鍵

表 19:列出自有(非繼承)屬性鍵的標準函式庫方法。它們都會傳回包含字串和/或符號的陣列。
可列舉 不可列舉 字串 符號
Object.keys()
Object.getOwnPropertyNames()
Object.getOwnPropertySymbols()
Reflect.ownKeys()

19 中的每個方法都會傳回一個陣列,其中包含參數的自有屬性鍵。從這些方法的名稱中,我們可以看到以下區別

下一個區段會說明可列舉這個術語,並示範每個方法。

28.6.5.1 可列舉性

可列舉性是屬性的屬性。某些操作會忽略不可列舉的屬性,例如 Object.keys()(請參閱表 19)和擴散屬性。預設情況下,大部分屬性都是可列舉的。以下範例顯示如何變更此設定。它也示範了列出屬性鍵的各種方式。

const enumerableSymbolKey = Symbol('enumerableSymbolKey');
const nonEnumSymbolKey = Symbol('nonEnumSymbolKey');

// We create enumerable properties via an object literal
const obj = {
  enumerableStringKey: 1,
  [enumerableSymbolKey]: 2,
}

// For non-enumerable properties, we need a more powerful tool
Object.defineProperties(obj, {
  nonEnumStringKey: {
    value: 3,
    enumerable: false,
  },
  [nonEnumSymbolKey]: {
    value: 4,
    enumerable: false,
  },
});

assert.deepEqual(
  Object.keys(obj),
  [ 'enumerableStringKey' ]);
assert.deepEqual(
  Object.getOwnPropertyNames(obj),
  [ 'enumerableStringKey', 'nonEnumStringKey' ]);
assert.deepEqual(
  Object.getOwnPropertySymbols(obj),
  [ enumerableSymbolKey, nonEnumSymbolKey ]);
assert.deepEqual(
  Reflect.ownKeys(obj),
  [
    'enumerableStringKey', 'nonEnumStringKey',
    enumerableSymbolKey, nonEnumSymbolKey,
  ]);

Object.defineProperties() 會在本章稍後說明

28.6.6 使用 Object.values() 列出屬性值

Object.values() 會列出物件所有可列舉屬性的值

const obj = {foo: 1, bar: 2};
assert.deepEqual(
  Object.values(obj),
  [1, 2]);

28.6.7 使用 Object.entries() [ES2017] 列出屬性項目

Object.entries() 會列出可列舉屬性的鍵值配對。每個配對都編碼為一個兩元素陣列

const obj = {foo: 1, bar: 2};
assert.deepEqual(
  Object.entries(obj),
  [
    ['foo', 1],
    ['bar', 2],
  ]);

  練習:Object.entries()

exercises/single-objects/find_key_test.mjs

28.6.8 屬性會以確定性的順序列出

物件的自有(非繼承)屬性總是會以以下順序列出

  1. 包含整數索引的字串鍵屬性(包括陣列索引
    以遞增數值順序排列
  2. 其餘的字串鍵屬性
    以新增順序排列
  3. 符號鍵屬性
    以新增順序排列

以下範例說明屬性金鑰如何根據這些規則進行排序

> Object.keys({b:0,a:0, 10:0,2:0})
[ '2', '10', 'b', 'a' ]

  屬性的順序

ECMAScript 規格 更詳細地描述屬性的順序。

28.6.9 透過 Object.fromEntries() 組合物件 [ES2019]

給定一個 [金鑰、值] 成對的 iterable,Object.fromEntries() 會建立一個物件

assert.deepEqual(
  Object.fromEntries([['foo',1], ['bar',2]]),
  {
    foo: 1,
    bar: 2,
  }
);

Object.fromEntries() 的作用與 Object.entries() 相反。

為了示範兩者,我們會在 下一個小節 中使用它們來實作 Underscore 函式庫中的兩個工具函式。

28.6.9.1 範例:pick(object, ...keys)

pick 會傳回 object 的一份拷貝,其中只包含金鑰有在引數中提及的那些屬性

const address = {
  street: 'Evergreen Terrace',
  number: '742',
  city: 'Springfield',
  state: 'NT',
  zip: '49007',
};
assert.deepEqual(
  pick(address, 'street', 'number'),
  {
    street: 'Evergreen Terrace',
    number: '742',
  }
);

我們可以這樣實作 pick()

function pick(object, ...keys) {
  const filteredEntries = Object.entries(object)
    .filter(([key, _value]) => keys.includes(key));
  return Object.fromEntries(filteredEntries);
}
28.6.9.2 範例:invert(object)

invert 會傳回 object 的一份拷貝,其中所有屬性的金鑰和值互換

assert.deepEqual(
  invert({a: 1, b: 2, c: 3}),
  {1: 'a', 2: 'b', 3: 'c'}
);

我們可以這樣實作 invert()

function invert(object) {
  const reversedEntries = Object.entries(object)
    .map(([key, value]) => [value, key]);
  return Object.fromEntries(reversedEntries);
}
28.6.9.3 Object.fromEntries() 的簡化實作

以下函式是 Object.fromEntries() 的簡化版本

function fromEntries(iterable) {
  const result = {};
  for (const [key, value] of iterable) {
    let coercedKey;
    if (typeof key === 'string' || typeof key === 'symbol') {
      coercedKey = key;
    } else {
      coercedKey = String(key);
    }
    result[coercedKey] = value;
  }
  return result;
}

  練習:Object.entries()Object.fromEntries()

exercises/single-objects/omit_properties_test.mjs

28.6.10 將物件當成字典使用的陷阱

如果我們將一般物件(透過物件文字建立)當成字典使用,我們必須注意兩個陷阱。

第一個陷阱是 in 運算子也會找到繼承的屬性

const dict = {};
assert.equal('toString' in dict, true);

我們希望將 dict 視為空物件,但 in 運算子會偵測到它從原型 Object.prototype 繼承的屬性。

第二個陷阱是我們無法使用屬性金鑰 __proto__,因為它具有特殊功能(會設定物件的原型)

const dict = {};

dict['__proto__'] = 123;
// No property was added to dict:
assert.deepEqual(Object.keys(dict), []);
28.6.10.1 安全地將物件當成字典使用

那麼,如何避免這兩個陷阱?

以下程式碼示範如何將沒有原型的物件當成字典使用

const dict = Object.create(null); // no prototype

assert.equal('toString' in dict, false); // (A)

dict['__proto__'] = 123;
assert.deepEqual(Object.keys(dict), ['__proto__']);

我們避免了這兩個陷阱

  練習:使用物件作為字典

exercises/single-objects/simple_dict_test.mjs

28.7 標準方法(進階)

Object.prototype 定義了幾個標準方法,可以覆寫這些方法來設定物件在語言中的處理方式。兩個重要的標準方法是

28.7.1 .toString()

.toString() 決定如何將物件轉換為字串

> String({toString() { return 'Hello!' }})
'Hello!'
> String({})
'[object Object]'

28.7.2 .valueOf()

.valueOf() 決定如何將物件轉換為數字

> Number({valueOf() { return 123 }})
123
> Number({})
NaN

28.8 進階主題

以下小節簡要概述幾個進階主題。

28.8.1 Object.assign() [ES6]

Object.assign() 是一個工具方法

Object.assign(target, source_1, source_2, ···)

這個表達式會將 source_1 的所有屬性指定給 target,然後將 source_2 的所有屬性指定給 target,依此類推。最後,它會傳回 target,例如

const target = { foo: 1 };

const result = Object.assign(
  target,
  {bar: 2},
  {baz: 3, bar: 4});

assert.deepEqual(
  result, { foo: 1, bar: 4, baz: 3 });
// target was modified and returned:
assert.equal(result, target);

Object.assign() 的使用案例類似於展開屬性。在某種程度上,它會以破壞性的方式展開。

28.8.2 凍結物件 [ES5]

Object.freeze(obj) 會讓 obj 完全不可變:我們無法變更屬性、新增屬性或變更其原型,例如

const frozen = Object.freeze({ x: 2, y: 5 });
assert.throws(
  () => { frozen.x = 7 },
  {
    name: 'TypeError',
    message: /^Cannot assign to read only property 'x'/,
  });

有一個注意事項:Object.freeze(obj) 會淺層凍結。也就是說,只有 obj 的屬性會被凍結,但儲存在屬性中的物件不會被凍結。

  更多資訊

有關凍結和其他鎖定物件方式的更多資訊,請參閱 深入探討 JavaScript

28.8.3 屬性屬性和屬性描述符 [ES5]

就像物件是由屬性組成的一樣,屬性是由屬性組成的。屬性的值只是幾個屬性之一。其他屬性包括

當我們使用其中一個操作來處理屬性時,屬性會透過屬性描述符指定:每個屬性代表一個屬性的物件。例如,這是我們如何讀取屬性 obj.foo 的屬性

const obj = { foo: 123 };
assert.deepEqual(
  Object.getOwnPropertyDescriptor(obj, 'foo'),
  {
    value: 123,
    writable: true,
    enumerable: true,
    configurable: true,
  });

這是我們如何設定屬性 obj.bar 的屬性

const obj = {
  foo: 1,
  bar: 2,
};

assert.deepEqual(Object.keys(obj), ['foo', 'bar']);

// Hide property `bar` from Object.keys()
Object.defineProperty(obj, 'bar', {
  enumerable: false,
});

assert.deepEqual(Object.keys(obj), ['foo']);

進一步閱讀

  測驗

請參閱 測驗應用程式