深入瞭解 Object.defineProperty

沫俱巨集發表於2022-05-01

屬性的操作

在 JavaScript 中,給物件增加一個屬性是非常簡單的,直接呼叫屬性並賦值即可。

const obj = {};
obj.name = 'Tom';
console.log(obj);
/**
 * 輸出:
 * {name: 'Tom'}
 */

通過這種方式新增的屬性,可以隨意操作:

  • 可修改
  • 可列舉
  • 可刪除

可修改:

  // 可修改
+ obj.name = 'Jim';
+ console.log(obj.name);
  /**
  * 輸出:
  * 'Jim'
  */

可列舉:

  // 可列舉
+ for (let key in obj) {
+   console.log(`${key} : ${obj[key]}`);
+ }
  /**
  * 輸出:
  * name : Jim
  */

可刪除:

  // 可刪除
+ delete obj.name;
+ console.log(obj);
  /**
  * 輸出:
  * {}
  */

如果想通過 Object.defineProperty 實現上面的功能,可以使用下面的程式碼:

- obj.name = 'Tom';
+ Object.defineProperty(obj, 'name', {
+   value: 'Tom',
+   writable: true,
+   enumerable: true,
+   configurable: true,
+ });

函式簽名

在對 Object.defineProperty 深入學習之前,先對這個方法簽名有一個認識:

Object.defineProperty(obj, prop, descriptor);

從函式簽名中可以看出,definePropertyObject 上的一個靜態方法,可以傳遞三個引數:

  • obj 要定義屬性的物件
  • prop 要定義或修改的屬性名稱
  • descriptor 要定義或修改屬性的描述符

返回值是被傳遞給函式的物件,也就是第一個引數 obj

描述符可以有以下幾個可選值:

  • configurable
  • enumerable
  • value
  • writable
  • get
  • set

描述符

通過 Object.defineProperty 來為物件定義一個屬性。

const obj = {};
Object.defineProperty(obj, 'name', {});
console.log(obj);
/**
 * 輸出:
 * {name: undefined}
 */

從輸出的結果可以看出,在物件 obj 上增加一個屬性 name,但是它的值是 undefined

value

如果想給屬性賦值,可以使用描述符中的 value 屬性。

- Object.defineProperty(obj, 'name', {});
+ Object.defineProperty(obj, 'name', {
+   value: 'Tom',
+ });
  /**
  * 輸出:
  * {name: 'Tom'}
  */

writable

一般情況下,修改一個物件中的屬性值,可以使用 obj.name = 'Jim' 的形式。

+ obj.name = 'Jim';
+ console.log(obj);
  /**
  * 輸出:
  * {name: 'Tom'}
  */

從輸出結果可以看出,並沒有修改成功。如果想修改屬性值,可以把描述符中的 writable 設定為 true

  Object.defineProperty(obj, 'name', {
    value: 'Tom',
+   writable: true,
  });

enumerable

列舉物件的屬性,可以使用 for...in

+ for (let key in obj) {
+   console.log(`${key} : ${obj[key]}`);
+ }

比較奇怪的是,執行上面的程式碼沒有輸出任何資訊。

如果想正常列舉物件的屬性,可以將描述符中的 enumerable 值設定為 true

  Object.defineProperty(obj, 'name', {
    value: 'Tom',
    writable: true,
+   enumerable: true,
  });

configurable

當這個屬性不需要時,可以通過 delete 來刪除。

+ delete obj.name;
+ console.log(obj);
  /**
  * 輸出:
  * {name: 'Jim'}
  */

從輸出結果可以看出,並沒有達到預期的效果。如果想從物件上正常刪除屬性,可以將描述符中的 configurable 設定為 true

  Object.defineProperty(obj, 'name', {
    value: 'Tom',
    writable: true,
    enumerable: true,
+   configurable: true,
  });

get

如果需要獲取物件的值,可以使用描述符中的 get

const obj = {};
let _tmpName = 'Tom';
Object.defineProperty(obj, 'name', {
  get() {
    return _tmpName;
  },
});
console.log(obj.name);
/**
 * 輸出:
 * {name: 'Tom'}
 */

set

如果需要設定物件的值,可以使用描述符中的 set,它需要傳遞一個引數,就是修改後的值。

  Object.defineProperty(obj, 'name', {
    get() {
      return _tmpName;
    },
+   set(newVal) {
+     _tmpName = newVal;
+   },
  });

+ obj.name = 'Jim';
+ console.log(obj.name);
  /**
  * 輸出:
  * {name: 'Jim'}
  */

注意事項

在操作符物件中,如果存在了 valuewritable 中的任意一個或多個,就不能存在 getset 了。

const obj = {};
Object.defineProperty(obj, 'name', {
  value: 1,
  get() {
    return 2;
  },
});

報錯資訊如下:

Uncaught TypeError: Invalid property descriptor. Cannot both specify accessors and a value or writable attribute

為了方便後期查閱,總結一下互斥的情況:

  • valueget 互斥
  • valueset 互斥
  • valueset + get 互斥
  • writableget 互斥
  • writableset 互斥
  • writableset + get 互斥

使用場景

Object.defineProperty() 方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性,並返回此物件。該方法允許精確地新增或修改物件的屬性。

這個方法是 JavaScript 的一個比較底層的方法,主要用於在物件上新增或修改物件的屬性。

簡單應用

基礎修飾符的使用

假設現在有一個需求,實現下面的效果:

const obj = { a: 1, b: 2, c: 3 };
for (let key in obj) {
  obj[key] += 1;
}
console.log(obj);
/*
輸出:
{ a: 3, b: 3, c: 5 }
*/

Object.defineProperty() 不僅可以定義屬性,也可以修改屬性。這個時候就可以使用修改屬性的方式來實現上面的需求。

for (let key in obj) {
  Object.defineProperty(obj, key, {
    enumerable: true,
    value: ++obj[key],
    writable: key !== 'b',
  });
}

get 的使用

使用 Object.defineProperty 中的 getconsole.log 中的資訊正常輸出。

if (num === 1 && num === 2 && num === 3) {
  console.log('you win ...');
}

從題目中可以看出,num 不可能即等於 1,又等於 2,還等於 3。如果想實現這樣的效果,必然需要在 num 取值的同時自增。

要實現一個變數取值並自增,就需要使用 Object.defineProperty 中的 getnum 是直接使用的,在瀏覽器中,只有掛載到 window 物件上的屬性可以直接使用。

let _tmpNum = 0;
Object.defineProperty(window, 'num', {
  get() {
    return ++_tmpNum;
  },
});

假設現在有一個需求,實現如下效果:

_ // a
_ + _ // ab
_ + _ + _ // abc

這個需求其實就是需要在 window 物件上掛載一個 _ 屬性,每呼叫一次 _ 就會自增一次 ASCII 碼。

Object.defineProperty(window, '_', {
  get() {
    // 獲取字母 a 的 ASCII 碼
    const aAsciiCode = 'a'.charCodeAt(0);
    // 獲取字母 z 的 ASCII 碼
    const zAsciiCode = 'z'.charCodeAt(0);
    // 如果 _code 不存在,將其賦值為 a 的 ASCII 碼
    this._code = this._code || aAsciiCode;
    // 如果 _code 的範圍超出了小寫字母的範圍,直接返回
    if (this._code > zAsciiCode) return;
    // 獲取當前 ASCII 碼對應的字母
    const _char = String.fromCharCode(this._code);
    // 每呼叫一次自增一次
    this._code++;
    // 返回
    return _char;
  },
});

如果想列印輸出 26 個字母的組合,可以通過遍歷的方式。

let resStr = '';
for (let i = 0; i < 26; i++) {
  resStr += _;
}
console.log(resStr);

set 的使用

如果將一個字串賦值為 'Object',列印這個字串輸出 {type: 'Object', length: 6};如果將一個字串賦值為 'Object',列印這個字串輸出 {type: 'Array', length: 5};如果將字串賦值成其他值,程式報錯 TypeError: This type is invalid.

分析這個題目,列印的時候其實就是取值的過程,需要用到 get 操作符,type 是當前字串的值,length 是當前字串的長度。

let _tmpStr = '';
Object.defineProperty(window, 'str', {
  get() {
    return { type: _tmpStr, length: _tmpStr.length };
  },
});

在給字串賦值的時候,當字串的值是 'Object''Array' 的時候正常賦值,其餘情況直接丟擲錯誤。這個操作就需要在操作符的 set 中實現。

  Object.defineProperty(window, 'str', {
    get() {
      return { type: _tmpStr, length: _tmpStr.length };
    },
    set(newVal) {
+     if (newVal === 'Object' || newVal === 'Array') {
+       _tmpStr = newVal;
+     } else {
+       throw new TypeError('This type is invalid.');
+     }
    },
  });

驗證程式碼的執行效果:

str = 'Object';
console.log(str);
/*
輸出:
{type: 'Object', length: 6}
*/

str = 'Array';
console.log(str);
/*
輸出:
{type: 'Array', length: 5}
*/

str = '123';
console.log(str);
/*
輸出:
TypeError: This type is invalid.
*/

複雜應用

需求:在頁面中有一個輸入框,下面有一個顯示區域,當輸入框中的內容發生變化時,顯示區中的內容同步變化。當重新整理頁面時,頁面中的資訊保持和重新整理前一致。

首先,在 index.html 中繪製頁面資訊:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div><input type="text" placeholder="輸入資訊" id="idInfo" /></div>
    <div id="idShowInfo"></div>
    <script type="module" src="./js/index.js"></script>
  </body>
</html>

簡單實現

最簡單的實現方式就是進入頁面的時候從快取中獲取資料,能夠獲取到就給輸入框和顯示區域賦值;同時監聽輸入框的輸入事件,向快取和顯示區域中寫入對應的資訊。

function init() {
  const eleInfo = document.getElementById('idInfo');
  const eleShowInfo = document.getElementById('idShowInfo');

  const _storageInfo = JSON.parse(localStorage.getItem('storageInfo') || '{}');
  if (_storageInfo.info) {
    eleInfo.value = _storageInfo.info;
  }
  eleShowInfo.innerHTML = eleInfo.value;

  eleInfo.addEventListener(
    'input',
    function () {
      localStorage.setItem(
        'storageInfo',
        JSON.stringify({ info: eleInfo.value || '' }),
      );
      eleShowInfo.innerHTML = eleInfo.value;
    },
    false,
  );
}

init();

Object.defineProperty 實現

上面的實現方式相對而言比較直接且比較簡單,但是程式碼的封裝性比較差,並且資料耦合性比較高。如果使用 Object.defineProperty 就可以更好的組織程式碼。

首先書寫入口檔案的程式碼 js/index.js

import { observer } from './observer.js';

const eleInfo = document.getElementById('idInfo');
const eleShowInfo = document.getElementById('idShowInfo');
const infoObj = observer({ info: '' }, eleInfo, eleShowInfo);

function init() {
  bindEvent(eleInfo);
}

function bindEvent(ele) {
  ele.addEventListener('input', handleInput, false);
}

function handleInput(event) {
  const _info = event.target.value || '';
  infoObj.info = _info;
}

init();

其次書寫 js/observer.js 中的程式碼:

export function observer(infoObj, inputDom, viewDom) {
  const _storageInfo = JSON.parse(localStorage.getItem('storageInfo') || '{}');
  const _resInfo = {};
  init(_storageInfo, infoObj, _resInfo, inputDom, viewDom);
  return _resInfo;
}

function init(storageInfo, infoObj, resInfo, inputDom, viewDom) {
  initData(storageInfo, infoObj, resInfo, inputDom, viewDom);
  initDom(resInfo, inputDom, viewDom);
}

function initData(storageInfo, infoObj, resInfo, inputDom, viewDom) {
  for (let key in storageInfo) {
    infoObj[key] = storageInfo[key];
  }

  for (let key in infoObj) {
    (function (key) {
      Object.defineProperty(resInfo, key, {
        get() {
          return infoObj[key];
        },
        set(newVal) {
          infoObj[key] = newVal;
          localStorage.setItem('storageInfo', JSON.stringify(infoObj));
          initDom(resInfo, inputDom, viewDom);
        },
      });
    })(key);
  }
}

function initDom(resInfo, inputDom, viewDom) {
  inputDom.value = resInfo.info;
  viewDom.innerHTML = resInfo.info;
}

參考資料

相關文章