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

28 物件



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

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

28.1 秘笈:物件

28.1.1 單一物件

透過物件文字建立物件(以大括號開始和結束)

const myObject = { // object literal
  myProperty: 1,
  myMethod() {
    return 2;
  }, // comma!
  get myAccessor() {
    return this.myProperty;
  }, // comma!
  set myAccessor(value) {
    this.myProperty = value;
  }, // last comma is optional
};

assert.equal(
  myObject.myProperty, 1
);
assert.equal(
  myObject.myMethod(), 2
);
assert.equal(
  myObject.myAccessor, 1
);
myObject.myAccessor = 3;
assert.equal(
  myObject.myProperty, 3
);

能夠直接建立物件(不使用類別)是 JavaScript 的亮點之一。

散佈到物件中

const original = {
  a: 1,
  b: {
    c: 3,
  },
};

// Spreading (...) copies one object “into” another one:
const modifiedCopy = {
  ...original, // spreading
  d: 4,
};

assert.deepEqual(
  modifiedCopy,
  {
    a: 1,
    b: {
      c: 3,
    },
    d: 4,
  }
);

// Caveat: spreading copies shallowly (property values are shared)
modifiedCopy.a = 5; // does not affect `original`
modifiedCopy.b.c = 6; // affects `original`
assert.deepEqual(
  original,
  {
    a: 1, // unchanged
    b: {
      c: 6, // changed
    },
  },
);

我們也可以使用散佈來建立物件的未修改(淺層)副本

const exactCopy = {...obj};

28.1.2 原型鏈

原型是 JavaScript 基本的繼承機制。即使是類別也是基於原型。每個物件都有 null 或物件作為其原型。後者物件也可以有原型,依此類推。一般來說,我們會得到原型的

原型像這樣管理

// `obj1` has no prototype (its prototype is `null`)
const obj1 = Object.create(null); // (A)
assert.equal(
  Object.getPrototypeOf(obj1), null // (B)
);

// `obj2` has the prototype `proto`
const proto = {
  protoProp: 'protoProp',
};
const obj2 = {
  __proto__: proto, // (C)
  objProp: 'objProp',
}
assert.equal(
  Object.getPrototypeOf(obj2), proto
);

注意事項

每個物件都繼承其原型的所有屬性

// `obj2` inherits .protoProp from `proto`
assert.equal(
  obj2.protoProp, 'protoProp'
);
assert.deepEqual(
  Reflect.ownKeys(obj2),
  ['objProp'] // own properties of `obj2`
);

物件未繼承的屬性稱為其自有屬性。

原型最重要的使用案例是,多個物件可以透過繼承自共用原型的屬性來共用方法。

28.2 什麼是物件?

JavaScript 中的物件

28.2.1 使用物件的兩種方式

在 JavaScript 中有兩種使用物件的方式

請注意,這兩種方式也可以混合:有些物件既是固定配置物件,也是字典物件。

使用物件的方式會影響它們在本章中的說明方式

28.3 固定配置物件

讓我們先探討固定配置物件

28.3.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.3.2 物件文字:屬性值簡寫

只要屬性的值是透過與金鑰同名的變數定義,我們就可以省略金鑰。

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

28.3.3 取得屬性

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

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

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

取得未知屬性會產生 undefined

assert.equal(jane.unknownProperty, undefined);

28.3.4 設定屬性

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

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.3.5 物件文字:方法

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

const jane = {
  first: 'Jane', // value 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.5「方法和特殊變數 this)。這使方法 .says() 能夠在 A 行存取同層屬性 .first

28.3.6 物件文字:存取器

存取器透過物件文字內部類似方法的語法定義:取得器和/或設定器(也就是說,每個存取器都有其中一個或兩個)。

呼叫存取器看起來就像存取值屬性

28.3.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.3.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/objects/color_point_object_test.mjs

28.4 散佈到物件文字中 (...) [ES2018]

在物件文字中,散佈屬性會將另一個物件的屬性加入目前的物件

> const obj = {one: 1, two: 2};
> {...obj, three: 3}
{ one: 1, two: 2, three: 3 }
const obj1 = {one: 1, two: 2};
const obj2 = {three: 3};
assert.deepEqual(
  {...obj1, ...obj2, four: 4},
  {one: 1, two: 2, three: 3,  four: 4}
);

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

> const obj = {one: 1, two: 2, three: 3};
> {...obj, one: true}
{ one: true, two: 2, three: 3 }
> {one: true, ...obj}
{ one: 1, two: 2, three: 3 }

所有值都可以散佈,甚至 undefinednull

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

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

散佈包含金鑰為符號的屬性(Object.keys()Object.values()Object.entries() 會忽略這些屬性)

const symbolKey = Symbol('symbolKey');
const obj = {
  stringKey: 1,
  [symbolKey]: 2,
};
assert.deepEqual(
  {...obj, anotherStringKey: 3},
  {
    stringKey: 1,
    [symbolKey]: 2,
    anotherStringKey: 3,
  }
);

28.4.1 散佈的用例:複製物件

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

const copy = {...original};

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

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

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

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

不過,較深層的層級不會被複製。例如,.b 的值會在原始物件和副本之間共用。在副本中變更 .b 也會在原始物件中變更它。

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

  JavaScript 沒有內建支援深度複製

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

28.4.2 散佈的用例:遺失屬性的預設值

如果我們程式碼的其中一個輸入是包含資料的物件,我們可以透過指定預設值(如果這些屬性遺失,就會使用這些值)來讓屬性變成選用的。執行此操作的一種技巧是透過包含預設值的物件。在以下範例中,該物件是 DEFAULTS

const DEFAULTS = {alpha: 'a', beta: 'b'};
const providedData = {alpha: 1};

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

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

但我們不需要物件來指定預設值;我們也可以在物件文字內個別指定

const providedData = {alpha: 1};

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

28.4.3 散佈的用例:非破壞性地變更屬性

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

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

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

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

  練習:透過散佈非破壞性地更新屬性 (固定鍵)

exercises/objects/update_name_test.mjs

28.4.4 「破壞性散佈」:Object.assign() [ES6]

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

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

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

const target = { a: 1 };

const result = Object.assign(
  target,
  {b: 2},
  {c: 3, b: true});

assert.deepEqual(
  result, { a: 1, b: true, c: 3 });
// target was modified and returned:
assert.equal(result, target);

Object.assign() 的用例與散佈屬性類似。在某種程度上,它是以破壞性的方式散佈。

28.5 方法和特殊變數 this

28.5.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.5.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

了解 this 的最佳方法是將它視為一般函式 (因此也包括方法) 的隱含參數。

28.5.3 方法和 .call()

方法是函式,而函式本身也有方法。其中一種方法是 .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.5.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.5.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 properties of undefined (reading 'first')",
  });

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

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

我們如何修復這個問題?我們需要使用 .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.5.5.1 範例:擷取方法

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

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

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

const listener = this.handleClick.bind(this);
elem.addEventListener('click', listener);

// Later, possibly:
elem.removeEventListener('click', listener);

每次呼叫 .bind() 都會建立一個新函式。這就是為什麼如果我們想要稍後移除它,就需要將結果儲存在某個地方。

28.5.5.2 如何避免擷取方法的陷阱

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

  練習:擷取方法

exercises/objects/method_extraction_exrc.mjs

28.5.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']),
  {
    name: 'TypeError',
    message: "Cannot read properties of undefined (reading 'prefix')",
  }
);

在 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.5.6.1 避免意外遮蔽 this 的陷阱

如果我們遵循 §25.3.4「建議:優先使用特殊函式而非一般函式」 中的建議,我們可以避免意外遮蔽 this 的陷阱。以下是摘要

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

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

在可呼叫實體內部,this 的值取決於可呼叫實體的呼叫方式以及它是哪種類型的可呼叫實體

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

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

我喜歡這麼做,因為頂層 this 很混亂,而且有更好的替代方案可以取代它(少數)的使用案例。

28.6 取得屬性與方法呼叫的選擇性鏈結(進階)[ES2020]

存在以下類型的選擇性鏈結運算

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

大致上的概念是

三個語法的每個部分稍後會詳細說明。以下是一些最早的範例

> null?.prop
undefined
> {prop: 1}?.prop
1

> null?.(123)
undefined
> String?.(123)
'123'

28.6.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.6.1.1 透過空值合併處理預設值

空值合併運算子 讓我們可以使用預設值 '(no name)' 取代 undefined

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

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

28.6.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.6.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.6.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.6.3 透過選擇性取得屬性進行短路

在屬性取得和方法呼叫的鏈結中,一旦第一個選擇性運算子在其左側遇到 undefinednull,評估就會停止

function invokeM(value) {
  return value?.a.b.m(); // (A)
}

const obj = {
  a: {
    b: {
      m() { return 'result' }
    }
  }
};
assert.equal(
  invokeM(obj), 'result'
);
assert.equal(
  invokeM(undefined), undefined // (B)
);

考慮 B 行中的 invokeM(undefined)undefined?.aundefined。因此我們預期 A 行中的 .b 會失敗。但並非如此:?. 運算子遇到值 undefined,整個表達式的評估會立即傳回 undefined

此行為與 JavaScript 在評估運算子之前總是評估所有運算元的正常運算子不同。這稱為短路。其他短路運算子為

28.6.4 選擇性鏈結:缺點與替代方案

選擇性鏈接也有一些缺點

選擇性鏈接的替代方案是在單一位置一次提取資訊

透過任一種方法,如果出現問題,都可以執行檢查並及早失敗。

進一步閱讀

28.6.5 常見問題

28.6.5.1 選擇性鏈接運算子 (?.) 有什麼好的記憶法?

你偶爾不確定選擇性鏈接運算子是以點號 (.?) 還是問號 (?.) 開頭嗎?那麼這個記憶法可能對你有幫助

28.6.5.2 為什麼 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.6.5.3 為什麼 null?.prop 會評估為 undefined 而不是 null

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

28.7 字典物件(進階)

物件最適合當作固定配置物件。但在 ES6 之前,JavaScript 沒有字典的資料結構(ES6 帶來了 Map)。因此,物件必須當作字典使用,這會造成一個重大的限制:字典的鍵必須是字串(ES6 也引入了符號)。

我們先來看看物件中與字典相關,但對固定配置物件也有用的功能。本節最後會提供實際使用物件作為字典的建議。(劇透:如果可以,最好使用 Map。)

28.7.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.7.2 物件文字中的運算鍵

在前一個小節中,屬性鍵是透過物件文字中的固定字串指定的。在本節中,我們將學習如何動態計算屬性鍵。這讓我們可以使用任意字串或符號。

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

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

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

運算鍵的主要使用案例是將符號當作屬性鍵(A 行)。

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

assert.equal(obj['p'+'r'+'o'+'p'], 123);
assert.equal(obj['==> prop'.slice(4)], 123);

方法也可以有運算屬性鍵

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

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

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

  練習:透過展開(運算鍵)非破壞性地更新屬性

exercises/objects/update_property_test.mjs

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

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

const obj = {
  alpha: 'abc',
  beta: false,
};

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

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

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

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

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

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

28.7.4 刪除屬性

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

const obj = {
  myProp: 123,
};

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

28.7.5 可列舉性

可列舉性 是屬性的 屬性。某些操作會忽略不可列舉的屬性,例如 Object.keys() 以及在擴散屬性時。預設情況下,大多數屬性都是可列舉的。以下範例說明如何變更這個設定,以及它如何影響擴散。

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,
  },
});

// Non-enumerable properties are ignored by spreading:
assert.deepEqual(
  {...obj},
  {
    enumerableStringKey: 1,
    [enumerableSymbolKey]: 2,
  }
);

Object.defineProperties() 會在 本章節後續說明。下一個小節說明這些操作如何受到可列舉性的影響

28.7.6 透過 Object.keys() 等方法列出屬性金鑰

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

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

為了示範這四個操作,我們回顧一下前一個小節的範例

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

const obj = {
  enumerableStringKey: 1,
  [enumerableSymbolKey]: 2,
}
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,
  ]
);

28.7.7 透過 Object.values() 列出屬性值

Object.values() 會列出物件中所有可列舉字串金鑰屬性的值

const firstName = Symbol('firstName');
const obj = {
  [firstName]: 'Jane',
  lastName: 'Doe',
};
assert.deepEqual(
  Object.values(obj),
  ['Doe']);

28.7.8 透過 Object.entries() 列出屬性項目 [ES2017]

Object.entries() 會將所有可列舉的字串金鑰屬性列出為金鑰值配對。每個配對都編碼為兩個元素的陣列

const firstName = Symbol('firstName');
const obj = {
  [firstName]: 'Jane',
  lastName: 'Doe',
};
assert.deepEqual(
  Object.entries(obj),
  [
    ['lastName', 'Doe'],
  ]);
28.7.8.1 Object.entries() 的簡單實作

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

function entries(obj) {
  return Object.keys(obj)
  .map(key => [key, obj[key]]);
}

  練習:Object.entries()

exercises/objects/find_key_test.mjs

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

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

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

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

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

  屬性的順序

ECMAScript 規格 更詳細地說明屬性的排序方式。

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

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

const symbolKey = Symbol('symbolKey');
assert.deepEqual(
  Object.fromEntries(
    [
      ['stringKey', 1],
      [symbolKey, 2],
    ]
  ),
  {
    stringKey: 1,
    [symbolKey]: 2,
  }
);

Object.fromEntries() 的作用與 Object.entries() 相反。不過,Object.entries() 會忽略符號金鑰屬性,而 Object.fromEntries() 卻不會(請參閱前一個範例)。

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

28.7.10.1 範例:pick()

Underscore 函式 pick() 有以下簽章

pick(object, ...keys)

它會傳回 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.7.10.2 範例:invert()

Underscore 函式 invert() 有以下簽章

invert(object)

它會傳回 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.7.10.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()

練習/物件/omit_properties_test.mjs

28.7.11 使用物件當作字典的陷阱

如果我們使用純粹的物件(透過物件字面值建立)作為字典,我們必須注意兩個陷阱。

第一個陷阱是 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.7.11.1 安全地使用物件作為字典

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

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

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

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

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

我們避免了兩個陷阱

  練習:使用物件作為字典

練習/物件/simple_dict_test.mjs

28.8 屬性與凍結物件(進階)

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

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

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

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

這是我們如何變更 obj.myProp 的屬性

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

// Hide property `myProp` from Object.keys()
// by making it non-enumerable
Object.defineProperty(obj, 'myProp', {
  enumerable: false,
});

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

進一步閱讀

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() 會變更屬性和物件的特徵(例如,使其不可寫入和不可擴充,意即無法再新增任何屬性)。

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

  更多資訊

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

28.9 原型鏈

原型是 JavaScript 唯一的繼承機制:每個物件都有原型,可能是 null 或物件。在後者的情況下,物件會繼承原型中的所有屬性。

在物件文字中,我們可以透過特殊屬性 __proto__ 設定原型

const proto = {
  protoProp: 'a',
};
const obj = {
  __proto__: proto,
  objProp: 'b',
};

// obj inherits .protoProp:
assert.equal(obj.protoProp, 'a');
assert.equal('protoProp' in obj, true);

由於原型物件本身可以有原型,因此我們會得到一個物件鏈,也就是所謂的原型鏈。繼承給我們一種錯覺,讓我們以為我們處理的是單一物件,但我們實際上處理的是物件鏈。

圖 9 顯示 obj 的原型鏈是什麼樣子。

Figure 9: obj starts a chain of objects that continues with proto and other objects.

未繼承的屬性稱為自有屬性obj 有自有屬性 .objProp

28.9.1 JavaScript 的運算:所有屬性與自有屬性

有些運算會考慮所有屬性(自有和繼承的),例如取得屬性

> const obj = { one: 1 };
> typeof obj.one // own
'number'
> typeof obj.toString // inherited
'function'

其他運算只會考慮自有屬性,例如 Object.keys()

> Object.keys(obj)
[ 'one' ]

請繼續閱讀另一個只考慮自有屬性的運算:設定屬性。

28.9.2 陷阱:只有原型鏈中的第一個成員會變異

假設有一個物件 obj 有原型物件的鏈,設定 obj 的自身屬性只會改變 obj 是合理的。然而,透過 obj 設定繼承的屬性也只會改變 obj。它會在 obj 中建立一個新的自身屬性來覆寫繼承的屬性。讓我們探討一下它如何與以下物件一起運作

const proto = {
  protoProp: 'a',
};
const obj = {
  __proto__: proto,
  objProp: 'b',
};

在下一段程式碼片段中,我們設定繼承的屬性 obj.protoProp(A 行)。透過建立一個自身屬性來「改變」它:當讀取 obj.protoProp 時,會先找到自身屬性,其值會覆寫繼承屬性的值。

// In the beginning, obj has one own property
assert.deepEqual(Object.keys(obj), ['objProp']);

obj.protoProp = 'x'; // (A)

// We created a new own property:
assert.deepEqual(Object.keys(obj), ['objProp', 'protoProp']);

// The inherited property itself is unchanged:
assert.equal(proto.protoProp, 'a');

// The own property overrides the inherited property:
assert.equal(obj.protoProp, 'x');

obj 的原型鏈描繪在圖 10

Figure 10: The own property .protoProp of obj overrides the property inherited from proto.

28.9.3 使用原型時的小技巧(進階)

28.9.3.1 取得和設定原型

__proto__ 的建議

取得和設定原型的建議方式如下

以下是這些功能的使用方式

const proto1 = {};
const proto2a = {};
const proto2b = {};

const obj1 = {
  __proto__: proto1,
  a: 1,
  b: 2,
};
assert.equal(Object.getPrototypeOf(obj1), proto1);

const obj2 = Object.create(
  proto2a,
  {
    a: {
      value: 1,
      writable: true,
      enumerable: true,
      configurable: true,
    },
    b: {
      value: 2,
      writable: true,
      enumerable: true,
      configurable: true,
    },  
  }
);
assert.equal(Object.getPrototypeOf(obj2), proto2a);

Object.setPrototypeOf(obj2, proto2b);
assert.equal(Object.getPrototypeOf(obj2), proto2b);
28.9.3.2 檢查一個物件是否在另一個物件的原型鏈中

到目前為止,「protoobj 的原型」總是表示「protoobj直接原型」。但它也可以更寬鬆地使用,表示 protoobj 的原型鏈中。這種較寬鬆的關係可以透過 .isPrototypeOf() 檢查

例如

const a = {};
const b = {__proto__: a};
const c = {__proto__: b};

assert.equal(a.isPrototypeOf(b), true);
assert.equal(a.isPrototypeOf(c), true);

assert.equal(c.isPrototypeOf(a), false);
assert.equal(a.isPrototypeOf(a), false);

有關此方法的更多資訊,請參閱 §29.8.5 “Object.prototype.isPrototypeOf()

28.9.4 Object.hasOwn():給定的屬性是自身(非繼承)的嗎?[ES2022]

in 運算子(A 行)檢查物件是否具有給定的屬性。相反地,Object.hasOwn()(B 行和 C 行)檢查屬性是否為自身。

const proto = {
  protoProp: 'protoProp',
};
const obj = {
  __proto__: proto,
  objProp: 'objProp',
}
assert.equal('protoProp' in obj, true); // (A)
assert.equal(Object.hasOwn(obj, 'protoProp'), false); // (B)
assert.equal(Object.hasOwn(proto, 'protoProp'), true); // (C)

  ES2022 之前的替代方案:.hasOwnProperty()

在 ES2022 之前,我們可以使用另一個功能:§29.8.8 “Object.prototype.hasOwnProperty()。此功能有陷阱,但所引用的章節說明了如何解決這些陷阱。

28.9.5 透過原型共用資料

考慮以下程式碼

const jane = {
  firstName: 'Jane',
  describe() {
    return 'Person named '+this.firstName;
  },
};
const tarzan = {
  firstName: 'Tarzan',
  describe() {
    return 'Person named '+this.firstName;
  },
};

assert.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan');

我們有兩個非常相似的物件。兩個物件都有兩個屬性,其名稱為 .firstName.describe。此外,方法 .describe() 是相同的。我們如何避免複製該方法?

我們可以將它移到物件 PersonProto,並使該物件成為 janetarzan 的原型

const PersonProto = {
  describe() {
    return 'Person named ' + this.firstName;
  },
};
const jane = {
  __proto__: PersonProto,
  firstName: 'Jane',
};
const tarzan = {
  __proto__: PersonProto,
  firstName: 'Tarzan',
};

原型的名稱反映出 janetarzan 都是人。

Figure 11: Objects jane and tarzan share method .describe(), via their common prototype PersonProto.

圖 11 說明了這三個物件是如何連接的:底部的物件現在包含特定於 janetarzan 的屬性。頂部的物件包含它們之間共有的屬性。

當我們呼叫方法 jane.describe() 時,this 指向該方法呼叫的接收者 jane(在圖表的左下角)。這就是為什麼該方法仍然有效。tarzan.describe() 的運作方式類似。

assert.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan');

展望下一章關於類別的內容 – 這是類別在內部組織的方式

§29.3「類別的內部結構」 會更詳細地說明這一點。

28.10 常見問題:物件

28.10.1 為什麼物件會保留屬性的插入順序?

原則上,物件是無序的。排序屬性的主要原因是讓列出項目、鍵或值的運算具有確定性。這有助於例如測試。

  測驗

請參閱 測驗應用程式