深入 JavaScript
請支持這本書:購買捐款
(廣告,請不要阻擋。)

11 屬性:指定與定義



有兩種方法可以建立或變更物件 obj 的屬性 prop

本章節將說明它們如何運作。

  必備知識:屬性屬性和屬性描述

對於本章,您應熟悉屬性特徵和屬性描述符。如果您不熟悉,請查看 §9「屬性特徵:簡介」

11.1 指派與定義

11.1.1 指派

我們使用指派運算子 = 將值 value 指派給物件 obj 的屬性 .prop

obj.prop = value

此運算子會根據 .prop 的樣貌而有不同的作用

也就是說,指派的目的是進行變更。這就是它支援設定器的原因。

11.1.2 定義

若要定義物件 obj 的金鑰為 propKey 的屬性,我們會使用下列方法等操作

Object.defineProperty(obj, propKey, propDesc)

此方法會根據屬性的樣貌而有不同的作用

也就是說,定義的主要目的是建立一個自有屬性(即使存在繼承設定器,它也會忽略)並變更屬性特徵。

11.2 理論上的指派和定義(選用)

  ECMAScript 規範中的屬性描述符

在規範操作中,屬性描述符不是 JavaScript 物件,而是 記錄,一種具有欄位的規範內部資料結構。欄位的金鑰以雙中括號撰寫。例如,Desc.[[Configurable]] 存取 Desc 的欄位 .[[Configurable]]。這些記錄在與外界互動時會轉譯為 JavaScript 物件,反之亦然。

11.2.1 指派給屬性

指派給屬性的實際工作透過 ECMAScript 規範中的下列操作 處理

OrdinarySetWithOwnDescriptor(O, P, V, Receiver, ownDesc)

以下是參數

傳回值為布林值,表示操作是否成功。如 本章節後續 所述,如果 `OrdinarySetWithOwnDescriptor()` 失敗,嚴格模式賦值會擲出 `TypeError`。

以下是演算法的高階摘要

更詳細來說,此演算法的運作方式如下

11.2.1.1 我們如何從指定轉換到 OrdinarySetWithOwnDescriptor()

評估指定而不解構涉及以下步驟

值得注意的是,如果 .[[Set]]() 的結果為 falsePutValue() 會在嚴格模式中擲回 TypeError

11.2.2 定義屬性

定義屬性的實際工作透過 ECMAScript 規格中的下列操作 處理

ValidateAndApplyPropertyDescriptor(O, P, extensible, Desc, current)

參數為

操作的結果是一個布林值,表示它是否成功。失敗可能導致不同的後果。有些呼叫者會忽略結果。其他呼叫者,例如 Object.defineProperty(),如果結果為 false,則會擲回例外狀況。

以下是演算法摘要

11.3 定義和指派實務

本節說明屬性定義和指派運作的一些後果。

11.3.1 只有定義允許我們建立具有任意屬性的屬性

如果我們透過指派建立自有屬性,它總是會建立屬性,其 writableenumerableconfigurable 屬性皆為 true

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

因此,如果我們要指定任意屬性,我們必須使用定義。

雖然我們可以在物件文字中建立 getter 和 setter,但我們無法透過指派在稍後新增它們。在此,我們也需要定義。

11.3.2 指派運算子不會變更原型中的屬性

讓我們考慮以下設定,其中 objproto 繼承屬性 prop

const proto = { prop: 'a' };
const obj = Object.create(proto);

我們無法透過指派給 obj.prop 來(破壞性地)變更 proto.prop。這麼做會建立新的自有屬性

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

obj.prop = 'b';

// The assignment worked:
assert.equal(obj.prop, 'b');

// But we created an own property and overrode proto.prop,
// we did not change it:
assert.deepEqual(
  Object.keys(obj), ['prop']);
assert.equal(proto.prop, 'a');

此行為的理由如下:原型可以擁有其值由所有後代共用的屬性。如果我們只想在一個後代中變更此類屬性,我們必須透過覆寫以非破壞性方式這麼做。然後,變更不會影響其他後代。

11.3.3 指派會呼叫 setter,定義則不會

定義 obj 的屬性 .prop 與指派給它之間有什麼區別?

如果我們定義,我們的目的是建立或變更 obj 的自有(非繼承)屬性。因此,在以下範例中,定義會忽略 .prop 在繼承中的 setter

let setterWasCalled = false;
const proto = {
  get prop() {
    return 'protoGetter';
  },
  set prop(x) {
    setterWasCalled = true;
  },
};
const obj = Object.create(proto);

assert.equal(obj.prop, 'protoGetter');

// Defining obj.prop:
Object.defineProperty(
  obj, 'prop', { value: 'objData' });
assert.equal(setterWasCalled, false);

// We have overridden the getter:
assert.equal(obj.prop, 'objData');

如果我們反之指派給 .prop,我們的目的是變更已經存在的事物,而變更應由 setter 處理

let setterWasCalled = false;
const proto = {
  get prop() {
    return 'protoGetter';
  },
  set prop(x) {
    setterWasCalled = true;
  },
};
const obj = Object.create(proto);

assert.equal(obj.prop, 'protoGetter');

// Assigning to obj.prop:
obj.prop = 'objData';
assert.equal(setterWasCalled, true);

// The getter still active:
assert.equal(obj.prop, 'protoGetter');

11.3.4 繼承的唯讀屬性會阻止透過指派建立自有屬性

如果 .prop 在原型中是唯讀的,會發生什麼事?

const proto = Object.defineProperty(
  {}, 'prop', {
    value: 'protoValue',
    writable: false,
  });

在任何從 proto 繼承唯讀 .prop 的物件中,我們無法使用指派建立具有相同鍵值的自有屬性,例如

const obj = Object.create(proto);
assert.throws(
  () => obj.prop = 'objValue',
  /^TypeError: Cannot assign to read only property 'prop'/);

為何我們無法指定?其理由在於,透過建立自有屬性來覆寫繼承屬性,可以視為非破壞性地變更繼承屬性。可以說,如果屬性不可寫入,我們不應該能夠這麼做。

然而,定義 .prop 仍然有效,並讓我們能覆寫

Object.defineProperty(
  obj, 'prop', { value: 'objValue' });
assert.equal(obj.prop, 'objValue');

沒有 setter 的存取器屬性也被視為唯讀

const proto = {
  get prop() {
    return 'protoValue';
  }
};
const obj = Object.create(proto);
assert.throws(
  () => obj.prop = 'objValue',
  /^TypeError: Cannot set property prop of #<Object> which has only a getter$/);

  「覆寫錯誤」:優缺點

唯讀屬性會阻止在原型鏈中較早的指定,這個事實被稱為覆寫錯誤

11.4 哪些語言建構使用定義,哪些使用指定?

在本節中,我們探討語言在何處使用定義,何處使用指定。我們透過追蹤是否呼叫繼承的 setter 來偵測使用哪個運算。有關更多資訊,請參閱§11.3.3「指定會呼叫 setter,定義不會」

11.4.1 物件文字的屬性透過定義新增

當我們透過物件文字建立屬性時,JavaScript 永遠使用定義(因此永遠不會呼叫繼承的 setter)

let lastSetterArgument;
const proto = {
  set prop(x) {
    lastSetterArgument = x;
  },
};
const obj = {
  __proto__: proto,
  prop: 'abc',
};
assert.equal(lastSetterArgument, undefined);

11.4.2 指定運算子 = 永遠使用指定

指定運算子 = 永遠使用指定來建立或變更屬性。

let lastSetterArgument;
const proto = {
  set prop(x) {
    lastSetterArgument = x;
  },
};
const obj = Object.create(proto);

// Normal assignment:
obj.prop = 'abc';
assert.equal(lastSetterArgument, 'abc');

// Assigning via destructuring:
[obj.prop] = ['def'];
assert.equal(lastSetterArgument, 'def');

11.4.3 公開類別欄位透過定義新增

唉呀,即使公用類別欄位具有與指定相同的語法,它們並不會使用指定來建立屬性,它們使用定義(例如物件文字中的屬性)

let lastSetterArgument1;
let lastSetterArgument2;
class A {
  set prop1(x) {
    lastSetterArgument1 = x;
  }
  set prop2(x) {
    lastSetterArgument2 = x;
  }
}
class B extends A {
  prop1 = 'one';
  constructor() {
    super();
    this.prop2 = 'two';
  }
}
new B();

// The public class field uses definition:
assert.equal(lastSetterArgument1, undefined);
// Inside the constructor, we trigger assignment:
assert.equal(lastSetterArgument2, 'two');

11.5 進一步閱讀和本章來源