你可能不知道的 Object.defineProperty()

YanceyOfficial發表於2019-03-21

最近在寫一個 《JavaScript API 全解析》系列(剛寫完 String,現正在寫 Object,可戳 JavaScript API 全解析),想把 MDN 推薦使用的 API 全部擼一遍,也算是給自己準備一份資料。因為 Object.defineProperty() 涉及到的知識點比較複雜,所以單獨拎出來放到這裡,歡迎大家拍磚。

語法

defineProperty(o: any, p: PropertyKey, attributes: PropertyDescriptor & ThisType<any>): any;
複製程式碼

描述

用於在一個物件上定義新的屬性或修改現有屬性, 並返回該物件.

引數

  • o 目標物件

  • p 需要定義的屬性或方法名 (可修改既有的, 也可新增新屬性或方法)

  • attributes 屬性描述符, 具體屬性如下:

interface PropertyDescriptor {
  configurable?: boolean;
  enumerable?: boolean;
  value?: any;
  writable?: boolean;
  get?(): any;
  set?(v: any): void;
}
複製程式碼

屬性描述符

ECMAScript 中有兩種屬性: 資料屬性訪問器屬性.

資料屬性包括: [[Configurable]], [[Enumerable]], [[Writable]], [[Value]]

訪問器屬性包括: [[Configurable]], [[Enumerable]], [[Get]], [[Set]]

屬性描述符可同時具有的鍵值

configurable enumerable value writable get set
資料屬性 Yes Yes Yes Yes No No
訪問器屬性 Yes Yes No No Yes Yes

簡言之, 定義了 valuewritable , 一定不能有 getset, 反之亦然, 否則報錯.

描述符可同時具有的鍵值

Configurable

如果某個屬性的 configurable 為false, 那麼:

  1. 將不能刪除此屬性, 即 delete obj.xxx 無效, 在嚴格模式下直接報錯.
// 非嚴格模式下刪除一個"不可配置"的屬性會返回false
const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'yancey',
  configurable: false,
});

delete obj.name; // false

// obj.name並沒有被刪除
obj.name; // yancey
複製程式碼
// 嚴格模式下刪除一個"不可配置"的屬性直接報錯
(function() {
  'use strict';
  var o = {};
  Object.defineProperty(o, 'b', {
    value: 2,
    configurable: false,
  });
  delete o.b; // Uncaught TypeError: Cannot delete property 'b' of #<Object>
  return o.b;
})();
複製程式碼
  1. 當 enumerable 或 writable 是false時, 再次將它們變成true則報錯; 但當它們是true時, 卻可以把它們變成false ( 注意必須是在不可配置的前提下, 如果屬性可配置, enumerable 和 writable 可任意切換 true 和 false)
const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'yancey',
  configurable: false,
  writable: false,
});

// 當"writable"和"configurable"均為false時, 嘗試將"writable"變為true會報錯
// Uncaught TypeError: Cannot redefine property: name
Object.defineProperty(obj, 'name', { writable: true });
複製程式碼
const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'yancey',
  configurable: false,
  writable: true,
});

// 但"writable"可成功從true切換到false
Object.defineProperty(obj, 'name', { writable: false });
複製程式碼
  1. 無論如何再次修改getset都會報錯, 因為兩者的屬性值是一個函式,在 JS 中不可能存在一個相同的函式。

:::tip REVIEW 複雜資料型別在中儲存資料名和一個堆的地址, 在中儲存屬性. 訪問時先從棧獲取地址, 再到堆中拿出相應的值. :::

const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'yancey',
  configurable: false,
});

// Uncaught TypeError: Cannot redefine property: name
Object.defineProperty(obj, 'name', { get: function() {} });

// Uncaught TypeError: Cannot redefine property: name
Object.defineProperty(obj, 'name', { set: function() {} });
複製程式碼
  1. 只要writable 是 true, 可以任意重新定義 value, 但當writable是 false 時, 需要看具體資料型別. 第一個例子中, 雖然 configurable 是 false, 但只要 writable 是 true, 便可以重新定義 value; 第二個例子中, value 是 基本資料型別, 所以再次定義 value 時只要覆蓋原值即可; 第三個例子 value 是複雜資料型別, 同樣因為 堆疊 問題而不能重新賦值.
const obj = {};

Object.defineProperty(obj, 'name', {
  value: [],
  configurable: false,
  writable: true,
});

// 任意重定義value不報錯
Object.defineProperty(obj, 'name', { value: 123 }); // {name: 123}

// 任意重定義value不報錯
Object.defineProperty(obj, 'name', { value: {}); // {name: {}}
複製程式碼
const obj = {};

Object.defineProperty(obj, 'name', {
  value: 123,
  configurable: false,
  writable: false,
});

// 當value是基本資料型別, 用原值覆蓋不會報錯
Object.defineProperty(obj, 'name', { value: 123 }); // {name: 123}

// 用其他值代替必然報錯
Object.defineProperty(obj, 'name', { value: {}); // Uncaught TypeError: Cannot redefine property: name
複製程式碼
const obj = {};

Object.defineProperty(obj, 'name', {
  value: [],
  configurable: false,
  writable: false,
});

// 當value是複雜資料型別, 修改value必定報錯, 同樣是堆疊的原因
Object.defineProperty(obj, 'name', { value: [] }); // {name: 123}
複製程式碼

Writable

如果某個屬性的writable設為false, 那麼該屬性將不能被賦值運算子改變. 但屬性值假如是陣列時, 將不受 push, splice等方法的影響.

const obj = {};

Object.defineProperty(obj, 'hobby', {
  value: ['girl', 'music', 'sleep'],
  writable: false,
  configurable: true,
  enumerable: true,
});

// "writable: false"並不對push、shift等方法起作用
obj.hobby.push('drink');
obj.hobby; // ['girl', 'music', 'sleep', 'drink']

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

// 當 hobby 被"賦值"給一個空陣列時, 此屬性的屬性值不會被改變
obj.hobby = [];
obj.hobby; // ['girl', 'music', 'sleep', 'drink']

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

// 而當使用"嚴格模式"時, 給一個"不可寫"屬性賦值將直接報錯
(function() {
  'use strict';
  var o = {};
  Object.defineProperty(o, 'b', {
    value: 2,
    writable: false
  });
  o.b = 3; // throws TypeError: "b" is read-only
  return o.b; // 2
}());
複製程式碼

Enumerable

定義了物件的屬性是否可以在 for...in 迴圈和 Object.keys() 中被列舉

const obj = {
  name: 'yancey',
  age: 18,
  say() {
    return 'say something...';
  },
};

Object.defineProperty(obj, 'hobby', {
  value: ['girl', 'music', 'sleep'],
  enumerable: true,
});

Object.defineProperty(obj, 'income', {
  value: '100,00,000',
  enumerable: false,
});

// 以下迭代器均不能輸出"不可列舉屬性", 即 income 的相關資訊
for (const i in obj) {
  console.log(obj[i]);
}
Object.keys(obj);
Object.values(obj);
Object.entries(obj);
複製程式碼

Getter & Setter

Getter 為讀取屬性時呼叫的函式. Setter 為設定屬性是呼叫的函式, Setter 會有一個引數, 即設定的那個值.

下面的程式碼建立一個 obj 物件, 定義了兩個屬性 name 和 _time, 注意 _time 的下劃線是一個常用記號, 用於表示只能通過物件方法訪問的屬性. 而訪問器屬性 time 則包含一個 getter 函式和一個 setter 函式. getter 函式返回被修飾的 _time 的值, setter 則根據被設定的值修改 name. 因此當obj.time = 2, name 會變成我為長者+2s. 這是使用訪問器屬性的常見方式, 即設定一個屬性的值會導致其他屬性發生變化.

const obj = {
  name: '長者',
  _time: 1,
};

Object.defineProperty(obj, 'time', {
  configurable: true,
  get() {
    return `default: ${this._time}s`;
  },
  set(newValue) {
    if (Number(newValue)) {
      this._time = newValue;
      this.name = `我為${this.name}+${newValue}s`;
    }
  },
});

obj.time; // 'default: 1s'
obj.time = 2; // 2
obj.name; // '我為長者+2s'
複製程式碼

再看另一個例子, 通過 Object.defineProperty 劫持 obj.input, 將輸入的值 set 到 id 為 name 的標籤裡. 這裡便有了種 Vue.js 的味道, 推薦一篇文章 剖析 Vue 實現原理 - 如何實現雙向繫結 mvvm.

<p>Hello, <span id='name'></span></p>
<input type='text' id='input'>

const obj = {
  input: '',
};

const inputDOM = document.getElementById('input');
const nameDOM = document.getElementById('name');

inputDOM.addEventListener('input', function (e) {
  obj.input = e.target.value;
})

Object.defineProperty(obj, 'input', {
  set: function (newValue) {
    nameDOM.innerHTML = newValue.trim().toUpperCase();
  }
})
複製程式碼

MVVM?

最後看一個關於繼承的例子, 我們建立了一個 Person 建構函式, 它包括兩個引數: firstNamelastName, 此建構函式暴露出四個屬性: firstName, lastName, fullName, species, 我們想讓前三個屬性動態變化, 最後一個屬性是一個常量而不允許變化.

下面這段程式碼顯然沒有達到想要的效果: 在嘗試修改 firstNamelastName 時, fullName 並沒有實時被更新; species屬效能隨意被改變.

function Person(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
  this.fullName = this.firstName + ' ' + this.lastName;
  this.species = 'human';
}

const person = new Person('Yancey', 'Leo');

// 雖然 firstName 和 lastName 被修改了, 但 fullName 仍然是 "Yancey Leo"
person.firstName = 'Sayaka';
person.lastName = 'Yamamoto';

// 我們定義了一個關於“人”的建構函式, 所以並不希望 species 被修改成 fish
person.species = 'fish';

// 當我們修改了 fullName, 也同樣希望 firstName 和 lastName 被更新
person.fullName = 'Kasumi Arimura';
複製程式碼

所以我們使用 Object.defineProperty() 重寫這個例子. 需要注意的是: 被劫持的屬性應放在原型裡. 通過下面這種方式, 即使建立多個例項, 也不會衝突, 所以可以放心使用.

function Person(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
}

Object.defineProperty(Person.prototype, 'species', {
  value: 'human',
  writable: false,
});

Object.defineProperty(Person.prototype, 'fullName', {
  get() {
    return this.firstName + ' ' + this.lastName;
  },
  set(newValue) {
    const newValueArr = newValue.trim().split(' ');
    if (newValueArr.length === 2) {
      this.firstName = newValueArr[0];
      this.lastName = newValueArr[1];
    }
  },
});

const person = new Person('Yancey', 'Leo');

person.firstName = 'Sakaya';
person.lastName = 'Yamamoto';
person.fullName; // 'Sayaka Yamamoto'

person.fullName = 'Kasumi Arimura';
person.firstName; // 'Kasumi'
person.lastName; // 'Arimura'

person.species = 'fish';
person.species; // 'human'
複製程式碼

擴充套件

除了 Object.defineProperty() 中的 Getter 和 Setter, 還有兩種類似的方式.

__defineGetter__ 和 __defineSetter__()

__defineGetter__ 方法可以為一個已經存在的物件設定 (新建或修改) 訪問器屬性, __defineSetter__ 方法可以將一個函式繫結在當前物件的指定屬性上, 當那個屬性被賦值時, 你所繫結的函式就會被呼叫.

var o = {};
o.__defineGetter__('gimmeFive', function() {
  return 5;
});
o.gimmeFive; // 5
複製程式碼

:::danger 該特性是非標準的, 請儘量不要在生產環境中使用它!

該特性已經從 Web 標準中刪除, 雖然一些瀏覽器目前仍然支援它, 但也許會在未來的某個時間停止支援, 請儘量不要使用該特性. :::

物件字面量中的 get 語法

物件字面量中的 get 語法只能在新建一個物件時使用.

var o = {
  get gimmeFive() {
    return 5;
  },
};
o.gimmeFive; // 5
複製程式碼

參考

Vue 核心之資料劫持

不會 Object.defineProperty 你就 out 了

vue.js 關於 Object.defineProperty 的利用原理

面試官: 實現雙向繫結 Proxy 比 defineproperty 優劣如何?

JAVASCRIPT ES5: MEET THE OBJECT.DEFINEPROPERTY() METHOD

相關文章