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

9 屬性屬性:簡介



在本章中,我們將更深入地了解 ECMAScript 規範如何看待 JavaScript 物件。特別是,屬性在規範中並非原子性的,而是由多個屬性組成(想想記錄中的欄位)。甚至資料屬性的值都儲存在屬性中!

9.1 物件的結構

在 ECMAScript 規範中,物件包含

9.1.1 內部插槽

規範將內部插槽描述如下。我加入了項目符號並強調了一部分

有兩種內部插槽

一般物件具有下列資料插槽

9.1.2 屬性鍵

屬性的鍵為下列其中一者

9.1.3 屬性屬性

有兩種屬性,它們的屬性決定其特性

此外,還有兩種屬性都具備。下表列出所有屬性及其預設值。

屬性種類 屬性名稱和類型 預設值
資料屬性 value: any undefined
writable: boolean false
存取器屬性 get: (this: any) => any undefined
set: (this: any, v: any) => void undefined
所有屬性 configurable: boolean false
enumerable: boolean false

我們已經看過屬性 valuegetset。其他屬性的運作方式如下

9.1.3.1 陷阱:繼承而來的不可寫入屬性會阻止透過指派建立自己的屬性

如果繼承而來的屬性不可寫入,我們無法使用指派來建立具有相同鍵的自己的屬性

const proto = {
  prop: 1,
};
// Make proto.prop non-writable:
Object.defineProperty(
  proto, 'prop', {writable: false});

const obj = Object.create(proto);

assert.throws(
  () => obj.prop = 2,
  /^TypeError: Cannot assign to read only property 'prop'/);

如需更多資訊,請參閱 §11.3.4「繼承而來的唯讀屬性會阻止透過指派建立自己的屬性」

9.2 屬性描述子

屬性描述子將屬性的屬性編碼為 JavaScript 物件。其 TypeScript 介面如下所示。

interface DataPropertyDescriptor {
  value?: any;
  writable?: boolean;
  configurable?: boolean;
  enumerable?: boolean;
}
interface AccessorPropertyDescriptor {
  get?: (this: any) => any;
  set?: (this: any, v: any) => void;
  configurable?: boolean;
  enumerable?: boolean;
}
type PropertyDescriptor = DataPropertyDescriptor | AccessorPropertyDescriptor;

問號表示所有屬性都是選用的。§9.7 “省略描述子屬性” 說明如果省略這些屬性會發生什麼事。

9.3 擷取屬性的描述子

9.3.1 Object.getOwnPropertyDescriptor():擷取單一屬性的描述子

考慮下列物件

const legoBrick = {
  kind: 'Plate 1x3',
  color: 'yellow',
  get description() {
    return `${this.kind} (${this.color})`;
  },
};

我們先為資料屬性 .color 取得描述子

assert.deepEqual(
  Object.getOwnPropertyDescriptor(legoBrick, 'color'),
  {
    value: 'yellow',
    writable: true,
    enumerable: true,
    configurable: true,
  });

存取器屬性 .description 的描述子如下所示

const desc = Object.getOwnPropertyDescriptor.bind(Object);
assert.deepEqual(
  Object.getOwnPropertyDescriptor(legoBrick, 'description'),
  {
    get: desc(legoBrick, 'description').get, // (A)
    set: undefined,
    enumerable: true,
    configurable: true
  });

使用 A 行的公用函式 desc() 可確保 .deepEqual() 正常運作。

9.3.2 Object.getOwnPropertyDescriptors():擷取物件所有屬性的描述子

const legoBrick = {
  kind: 'Plate 1x3',
  color: 'yellow',
  get description() {
    return `${this.kind} (${this.color})`;
  },
};

const desc = Object.getOwnPropertyDescriptor.bind(Object);
assert.deepEqual(
  Object.getOwnPropertyDescriptors(legoBrick),
  {
    kind: {
      value: 'Plate 1x3',
      writable: true,
      enumerable: true,
      configurable: true,
    },
    color: {
      value: 'yellow',
      writable: true,
      enumerable: true,
      configurable: true,
    },
    description: {
      get: desc(legoBrick, 'description').get, // (A)
      set: undefined,
      enumerable: true,
      configurable: true,
    },
  });

使用 A 行的輔助函式 desc() 可確保 .deepEqual() 正常運作。

9.4 透過描述子定義屬性

如果我們透過屬性描述子 propDesc 定義具有金鑰 k 的屬性,會發生下列情況

9.4.1 Object.defineProperty():透過描述子定義單一屬性

首先,我們透過描述子建立一個新屬性

const car = {};

Object.defineProperty(car, 'color', {
  value: 'blue',
  writable: true,
  enumerable: true,
  configurable: true,
});

assert.deepEqual(
  car,
  {
    color: 'blue',
  });

接下來,我們透過描述子變更屬性的種類;我們將資料屬性轉換為 getter

const car = {
  color: 'blue',
};

let readCount = 0;
Object.defineProperty(car, 'color', {
  get() {
    readCount++;
    return 'red';
  },
});

assert.equal(car.color, 'red');
assert.equal(readCount, 1);

最後,我們透過描述子變更資料屬性的值

const car = {
  color: 'blue',
};

// Use the same attributes as assignment:
Object.defineProperty(
  car, 'color', {
    value: 'green',
    writable: true,
    enumerable: true,
    configurable: true,
  });

assert.deepEqual(
  car,
  {
    color: 'green',
  });

我們使用與指定相同屬性屬性。

9.4.2 Object.defineProperties():透過描述子定義多個屬性

Object.defineProperties() 是 `Object.defineProperty() 的多屬性版本

const legoBrick1 = {};
Object.defineProperties(
  legoBrick1,
  {
    kind: {
      value: 'Plate 1x3',
      writable: true,
      enumerable: true,
      configurable: true,
    },
    color: {
      value: 'yellow',
      writable: true,
      enumerable: true,
      configurable: true,
    },
    description: {
      get: function () {
        return `${this.kind} (${this.color})`;
      },
      enumerable: true,
      configurable: true,
    },
  });

assert.deepEqual(
  legoBrick1,
  {
    kind: 'Plate 1x3',
    color: 'yellow',
    get description() {
      return `${this.kind} (${this.color})`;
    },
  });

9.5 Object.create():透過描述子建立物件

Object.create() 會建立一個新物件。其第一個引數指定該物件的原型。其第二個引數(選用)指定該物件屬性的描述子。在下列範例中,我們建立與前一個範例相同的物件。

const legoBrick2 = Object.create(
  Object.prototype,
  {
    kind: {
      value: 'Plate 1x3',
      writable: true,
      enumerable: true,
      configurable: true,
    },
    color: {
      value: 'yellow',
      writable: true,
      enumerable: true,
      configurable: true,
    },
    description: {
      get: function () {
        return `${this.kind} (${this.color})`;
      },
      enumerable: true,
      configurable: true,
    },
  });

// Did we really create the same object?
assert.deepEqual(legoBrick1, legoBrick2); // Yes!

9.6 Object.getOwnPropertyDescriptors() 的使用案例

如果將 Object.getOwnPropertyDescriptors()Object.defineProperties()Object.create() 結合使用,它有助於我們處理兩個使用案例。

9.6.1 用例:將屬性複製到物件中

自 ES6 以來,JavaScript 已具備用於複製屬性的工具方法:Object.assign()。不過,此方法使用簡單的取得和設定操作來複製其金鑰為 key 的屬性

target[key] = source[key];

這表示它只有在以下情況下才會建立屬性的忠實副本

下列範例說明了此限制。物件 source 有其金鑰為 data 的設定程式。

const source = {
  set data(value) {
    this._data = value;
  }
};

// Property `data` exists because there is only a setter
// but has the value `undefined`.
assert.equal('data' in source, true);
assert.equal(source.data, undefined);

如果我們使用 Object.assign() 來複製屬性 data,則存取器屬性 data 會轉換為資料屬性

const target1 = {};
Object.assign(target1, source);

assert.deepEqual(
  Object.getOwnPropertyDescriptor(target1, 'data'),
  {
    value: undefined,
    writable: true,
    enumerable: true,
    configurable: true,
  });

// For comparison, the original:
const desc = Object.getOwnPropertyDescriptor.bind(Object);
assert.deepEqual(
  Object.getOwnPropertyDescriptor(source, 'data'),
  {
    get: undefined,
    set: desc(source, 'data').set,
    enumerable: true,
    configurable: true,
  });

很幸運地,將 Object.getOwnPropertyDescriptors()Object.defineProperties() 搭配使用,確實會複製屬性 data

const target2 = {};
Object.defineProperties(
  target2, Object.getOwnPropertyDescriptors(source));

assert.deepEqual(
  Object.getOwnPropertyDescriptor(target2, 'data'),
  {
    get: undefined,
    set: desc(source, 'data').set,
    enumerable: true,
    configurable: true,
  });
9.6.1.1 陷阱:複製使用 super 的方法

使用 super 的方法與其家系物件(儲存該方法的物件)緊密連結。目前沒有辦法將此類方法複製或移動到不同的物件。

9.6.2 Object.getOwnPropertyDescriptors() 的用例:複製物件

淺層複製類似於複製屬性,這就是為什麼 Object.getOwnPropertyDescriptors() 在此也是一個好選擇。

若要建立複製,我們使用 Object.create()

const original = {
  set data(value) {
    this._data = value;
  }
};

const clone = Object.create(
  Object.getPrototypeOf(original),
  Object.getOwnPropertyDescriptors(original));

assert.deepEqual(original, clone);

如需有關此主題的更多資訊,請參閱 §6「複製物件和陣列」

9.7 省略描述符屬性

描述符的所有屬性都是選用的。省略屬性時會發生什麼事,取決於操作。

9.7.1 建立屬性時省略描述符屬性

當我們透過描述符建立新屬性時,省略屬性表示使用其預設值

const car = {};
Object.defineProperty(
  car, 'color', {
    value: 'red',
  });
assert.deepEqual(
  Object.getOwnPropertyDescriptor(car, 'color'),
  {
    value: 'red',
    writable: false,
    enumerable: false,
    configurable: false,
  });

9.7.2 變更屬性時省略描述符屬性

如果我們變更現有屬性,則省略描述符屬性表示不變更對應的屬性

const car = {
  color: 'yellow',
};
assert.deepEqual(
  Object.getOwnPropertyDescriptor(car, 'color'),
  {
    value: 'yellow',
    writable: true,
    enumerable: true,
    configurable: true,
  });
Object.defineProperty(
  car, 'color', {
    value: 'pink',
  });
assert.deepEqual(
  Object.getOwnPropertyDescriptor(car, 'color'),
  {
    value: 'pink',
    writable: true,
    enumerable: true,
    configurable: true,
  });

9.8 內建建構式使用哪些屬性屬性?

屬性屬性的通則(少數例外)為

9.8.1 透過指派建立的自有屬性

const obj = {};
obj.prop = 3;

assert.deepEqual(
  Object.getOwnPropertyDescriptors(obj),
  {
    prop: {
      value: 3,
      writable: true,
      enumerable: true,
      configurable: true,
    }
  });

9.8.2 透過物件文字建立的自有屬性

const obj = { prop: 'yes' };

assert.deepEqual(
  Object.getOwnPropertyDescriptors(obj),
  {
    prop: {
      value: 'yes',
      writable: true,
      enumerable: true,
      configurable: true
    }
  });

9.8.3 陣列的自有屬性 .length

陣列的自有屬性 .length 是不可列舉的,因此不會被 Object.assign()、散布和其他類似操作複製。它也是不可設定的

> Object.getOwnPropertyDescriptor([], 'length')
{ value: 0, writable: true, enumerable: false, configurable: false }
> Object.getOwnPropertyDescriptor('abc', 'length')
{ value: 3, writable: false, enumerable: false, configurable: false }

.length 是一個特殊的資料屬性,它會受到其他自有屬性(特別是索引屬性)影響(並影響它們)。

9.8.4 內建類別的原型屬性

assert.deepEqual(
  Object.getOwnPropertyDescriptor(Array.prototype, 'map'),
  {
    value: Array.prototype.map,
    writable: true,
    enumerable: false,
    configurable: true
  });

9.8.5 使用者定義類別的原型屬性與實例屬性

class DataContainer {
  accessCount = 0;
  constructor(data) {
    this.data = data;
  }
  getData() {
    this.accessCount++;
    return this.data;
  }
}
assert.deepEqual(
  Object.getOwnPropertyDescriptors(DataContainer.prototype),
  {
    constructor: {
      value: DataContainer,
      writable: true,
      enumerable: false,
      configurable: true,
    },
    getData: {
      value: DataContainer.prototype.getData,
      writable: true,
      enumerable: false,
      configurable: true,
    }
  });

請注意,DataContainer 實例的所有自有屬性都是可寫入、可列舉和可設定的

const dc = new DataContainer('abc')
assert.deepEqual(
  Object.getOwnPropertyDescriptors(dc),
  {
    accessCount: {
      value: 0,
      writable: true,
      enumerable: true,
      configurable: true,
    },
    data: {
      value: 'abc',
      writable: true,
      enumerable: true,
      configurable: true,
    }
  });

9.9 API:屬性描述符

下列工具方法使用屬性描述符

9.10 進一步閱讀

接下來的三章節會提供更多關於屬性屬性的詳細資訊