1. 程式人生 > >理解前端資料雙向繫結原理——Object.defineProperty()

理解前端資料雙向繫結原理——Object.defineProperty()

理解前端資料雙向繫結原理:Object.defineProperty()

Object.definedProperty方法可以在一個物件上直接定義一個新的屬性、或修改一個物件已經存在的屬性,最終返回這個物件。

語法

Object.defineProperty(obj, prop, descriptor)

引數:

obj
被定義或修改屬性的物件;

prop
要定義或修改的屬性名稱;

descriptor
對屬性的描述;

返回值

函式將返回傳遞給他的obj物件本身。

描述符(descriptor)說明

該方法允許開發者精確的對物件屬性的定義和修改。通過正常賦值進行屬性新增而構建的屬性會被列舉器方法(如for..in迴圈或Object.keys方法)獲取,從而導致屬性值被外部方法改變或刪除。而Object.defineProperty()可以避免以上描述的情況,預設的,通過Object.defineProperty()新增的屬性是預設不可改變的。
屬性描述引數(descriptor)主要由兩部分構成:資料描述符(data descriptor)和訪問器描述符(accessor descriptor)。資料描述符就是一個包含屬性的值,並說明這個值可讀或不可讀的物件;訪問器描述符就是包含該屬性的一對getter-setter方法的物件。一個完整的屬性描述(descriptor)必須是這兩者之一,並且不可以兩者都有。

資料描述符和訪問器描述符各自都是物件,他們必須包含以下鍵值對:

configurable
僅當設定的屬性的描述符需要被修改或需要通過delete來刪除該屬性時,configurable屬性設定為true。預設為false。

enumerable
僅當設定的屬性需要被列舉器(如for..in)訪問時設定為true。預設為false。

資料描述符可以包含以下可選鍵值對:

value
設定屬性的值,可以是任何JavaScript值型別(number,object,function等型別)。預設為undefined。

writable
僅當屬性的值可以被賦值操作修改時設定為true。預設為false。

訪問器描述符可以包含以下可選鍵值對:

get
屬性的getter方法,若屬性沒有getter方法則為undefined。該方法的返回為屬性的值。預設為undefined。

set
屬性的setter方法,若屬性沒有setter方法則為undefined。該方法接收唯一的引數,作為屬性的新值。預設為undefined。

請牢記,這些描述符的屬性並不是必須的,從原型鏈繼承而來的屬性也可填充。為了保證這些描述符屬性被填充為預設值,你可能會使用形如預先凍結Object.prototype、明確設定每個描述符屬性的值、使用Object.create(null)來獲取空物件等方式。

// using __proto__
var obj = {}; var descriptor = Object.create(null); // no inherited properties //所有描述符的屬性被設定為預設值 descriptor.value = 'static'; Object.defineProperty(obj, 'key', descriptor); //明確設定每個描述符的屬性 Object.defineProperty(obj, 'key', { enumerable: false, configurable: false, writable: false, value: 'static' }); //重用同一個物件作為描述符 function withValue(value) { var d = withValue.d || ( withValue.d = { enumerable: false, writable: false, configurable: false, value: null } ); d.value = value; return d; } Object.defineProperty(obj, 'key', withValue('static')); //如果Object.freeze方法可用,則使用它來防止對物件屬性的修改 (Object.freeze || Object)(Object.prototype);

使用示例

建立一個屬性

如果當前物件不存在我們要設定的屬性,Object.defineProperty()會根據方法設定為物件建立一個新的屬性。如果描述符引數缺失,則會被設定為預設值。所有布林型描述符屬性會被預設設定為false。而value,get,set會被預設設定為undefined。一個未設定get/set/value/writable的屬性被稱為一個“原生屬性(generic)”,並且他的描述符(descriptor)會被“歸類”為一個數據描述符(data descriptor)。

var o = {}; //建立一個物件

//使用資料描述符來為物件新增屬性
Object.defineProperty(o, 'a', {
  value: 37,
  writable: true,
  enumerable: true,
  configurable: true
});
//屬性”a”被設定到物件o上,並且值為37

//使用訪問器描述符來為物件新增屬性
var bValue = 38;
Object.defineProperty(o, 'b', {
  get: function() { return bValue; },
  set: function(newValue) { bValue = newValue; },
  enumerable: true,
  configurable: true
});
o.b; // 38
//屬性”b”被設定到物件o上,並且值為38。
//現在o.b的值指向bValue變數,除非o.b被重新定義

//你不能嘗試混合資料、訪問器兩種描述符
Object.defineProperty(o, 'conflict', {
  value: 0x9f91102,
  get: function() { return 0xdeadbeef; }
});
//丟擲一個型別錯誤: value appears only in data descriptors, get appears only in accessor descriptors(value只出現在資料描述符中,get只出現在訪問器描述符中)

修改一個屬性

當某個屬性已經存在了,Object.defineProperty()會根據物件的屬性配置(configuration)和新設定的值來嘗試修改該屬性。如果該屬性的configurable被設定為false,則該屬性無法被修改(這種情況下有個特殊情況:如果之前的writable設定為true,則我們仍可以將writable設定為false,一旦這麼做之後,任何描述符屬性將變得不可設定)。如果屬性的configurable設定為false,則我們無法將屬性的描述符在資料描述符和訪問器描述符之間轉換。
如果新設定的屬性和該屬性不同,並且該屬性的configurable被設定為false,則一個型別錯誤(TypeError)會被丟擲(除了上一段文字中說的特殊情況)。若新舊屬性完全相同,則什麼都不會發生。

可寫特性-writable

當一個屬性的writable被設定為false,這個屬性就成為“不可寫的(non-writable)”。該屬性不可被重新賦值。

var o = {}; //建立一個物件

Object.defineProperty(o, 'a', {
  value: 37,
  writable: false
});

console.log(o.a); // 37
o.a = 25; //沒有錯誤丟擲
//在嚴格模式下會丟擲錯誤
console.log(o.a); //仍然是37,賦值操作無效

正如上述程式碼所述,嘗試重寫一個“不可寫(non-writable)”屬性不會發生任何改變,也不會丟擲錯誤。

可列舉特性-enumerable

屬性的enumerable值定義物件的屬性是否會出現在列舉器(for..in迴圈和Object.keys())中。

var o = {};
Object.defineProperty(o, 'a', {
  value: 1,
  enumerable: true
});
Object.defineProperty(o, 'b', {
 value: 2,
 enumerable: false
});
Object.defineProperty(o, 'c', {
  value: 3
}); //enumerable預設設定為false
o.d = 4; //通過直接設定屬性的方式,enumerable將被設定為true

for (var i in o) {
  console.log(i);
}
//打印出’a’和’d’

Object.keys(o); // ['a', 'd']

o.propertyIsEnumerable('a'); // true
o.propertyIsEnumerable('b'); // false
o.propertyIsEnumerable('c'); // false

可配置特性-configurable

屬性的configurable值控制一個物件的屬性可否被delete刪除,同時也控制該屬性描述符的配置可否改變(除了前文所述在configurable為false時,若writable為true,則仍可以進行一次修改將writable改變為false)。

var o = {};
Object.defineProperty(o, 'a', {
  get: function() { return 1; },
  configurable: false
});

Object.defineProperty(o, 'a', {
  configurable: true
}); //丟擲錯誤
Object.defineProperty(o, 'a', {
  enumerable: true
}); //丟擲錯誤
Object.defineProperty(o, 'a', {
  set: function() {}
}); //丟擲錯誤(set之前被設定為undefined)
Object.defineProperty(o, 'a', {
  get: function() { return 1; }
}); //丟擲錯誤(即使新的get做的是相同的事,但方法的前後引用不相同)
Object.defineProperty(o, 'a', {
  value: 12
}); //丟擲錯誤

console.log(o.a); // 1
delete o.a; //什麼都不發生
console.log(o.a); // 1

如果o.a屬性的configurable為true,就不會有任何錯誤丟擲,並且o.a在最後的delete操作中會被刪除。

新增屬性時的預設值

考慮描述符特性的預設值如何被應用是非常重要的。正如下面示例所示,簡單的使用”.”符號來設定一個屬性和使用Object.defineProperty()是有很大區別的。

var o = {};

o.a = 1;
//等同於:
Object.defineProperty(o, 'a', {
  value: 1,
  writable: true,
  configurable: true,
  enumerable: true
});


//另一方面,
Object.defineProperty(o, 'a', { value: 1 });
//等同於:
Object.defineProperty(o, 'a', {
  value: 1,
  writable: false,
  configurable: false,
  enumerable: false
});

定製的Setters和Getters

下面的示例展示瞭如何實現一個“自存檔(self-archiving)”的物件。當temperature屬性被設定時,archive陣列就會新增一個日誌記錄。

function Archiver() {
  var temperature = null;
  var archive = [];

  Object.defineProperty(this, 'temperature', {
    get: function() {
      console.log('get!');
      return temperature;
    },
    set: function(value) {
      temperature = value;
      archive.push({ val: temperature });
    }
  });

  this.getArchive = function() { return archive; };
}

var arc = new Archiver();
arc.temperature; // 'get!'
arc.temperature = 11;
arc.temperature = 13;
arc.getArchive(); // [{ val: 11 }, { val: 13 }]

或者下面這樣寫也是同樣的效果:

var pattern = {
    get: function () {
        return 'I always return this string, whatever you have assigned';
    },
    set: function () {
        this.myname = 'this is my name string';
    }
};


function TestDefineSetAndGet() {
    Object.defineProperty(this, 'myproperty', pattern);
}


var instance = new TestDefineSetAndGet();
instance.myproperty = 'test';
console.log(instance.myproperty);
// I always return this string, whatever you have assigned

console.log(instance.myname); // this is my name string

譯者注

Object.defineProperty()方法被許多現代前端框架(如Vue.js,React.js)用於資料雙向繫結的實現,當我們在框架Model層設定data時,框架將會通過Object.defineProperty()方法來繫結所有資料,並在資料變化的同時修改虛擬節點,最終修改頁面的Dom結構。在這個過程中有幾點需要注意:

延遲發生變化

現代框架為了避免密集的Dom修改操作,對繫結的資料修改後將會設定一個極小(通常為1ms)的setTimeout延遲再應用變化。也就是說,虛擬節點和頁面Dom樹的變化和資料的變化中間會存在一個空閒期。注意到這一點的開發者就會意識到,如果我們想實現一個功能:在某項資料變化後,頁面立即產生變化,並且下一步開發者將獲取這個變化的Dom…這樣的功能通過現代前端框架是無法完成的。當然,那些框架也為我們提供了許多應對方法,例如Vue的nextTick()方法等。

陣列的變化

先讓我們瞭解下Object.defineProperty()對陣列變化的跟蹤情況:

var a={};
bValue=1;
Object.defineProperty(a,"b",{
    set:function(value){
        bValue=value;
        console.log("setted");
    },
    get:function(){
        return bValue;
    }
});
a.b;//1
a.b=[];//setted
a.b=[1,2,3];//setted
a.b[1]=10;//無輸出
a.b.push(4);//無輸出
a.b.length=5;//無輸出
a.b;//[1,10,3,4,undefined];

可以看到,當a.b被設定為陣列後,只要不是重新賦值一個新的陣列物件,任何對陣列內部的修改都不會觸發setter方法的執行。這一點非常重要,因為基於Object.defineProperty()方法的現代前端框架實現的資料雙向繫結也同樣無法識別這樣的陣列變化。因此第一點,如果想要觸發資料雙向繫結,我們不要使用arr[1]=newValue;這樣的語句來實現;第二點,框架也提供了許多方法來實現陣列的雙向繫結。
對於框架如何實現陣列變化的監測,大多數情況下,框架會重寫Array.prototype.push方法,並生成一個新的陣列賦值給資料,這樣資料雙向繫結就會觸發。作為框架使用者,我們需要知道的就是,這樣實現的陣列修改會消耗更多的記憶體。

configurable和writable

原文中描述過一種特殊情況:當configurable為false時,我們唯一仍能改變的屬性就是將設定為true的writable設定為false。對此譯者進行了以下測試(以下程式碼在Chrome和IE下執行論證,輸出結果相同):

var a={};
Object.defineProperty(a,"o",{
    configurable:false,
    value:10,
    writable:true
});

console.log(a.o);//10
a.o=12;//不報錯
console.log(a.o);//12

Object.defineProperty(a,"o",{
    configurable:false,
    value:14,
    writable:true
});
console.log(a.o);//14

Object.defineProperty(a,"o",{
    configurable:false,
    value:14,
    writable:false
});
a.o=16;//不報錯
console.log(a.o);//14

//報錯
Object.defineProperty(a,"o",{
    configurable:false,
    value:16,
    writable:false
});

由以上程式碼可以得出結論,對於描述符(descriptor)為資料描述符(data descriptor)的情況:
1.使用“.”操作符來設定屬性的值永遠不會報錯,僅當writable為false時無效。
2.只要writable為true,不論configurable是否為false,都可以通過Object.defineProperty()來修改value的值。
由此得出結論,各大瀏覽器運營商實現的Object.defineProperty()和標準描述在configurable的定義上稍有偏差。描述符為資料描述符時值的改變與否僅受writable的控制。

關於本文

本文基於譯者自己的理解,詳細描述了Object.defineProperty()方法的原理以及各種使用情況,對於理解資料雙向繫結的原理會有很大幫助。但是如果各位讀者想要完整的實現一個高可用的資料雙向繫結系統,則還需要對其進行稍加改進。例如單元件的物件管理、陣列修改的監聽方案、密集型修改的優化、變化後的狀態管理等。
限於水平有限,原文最後一部分“相容性注意事項(Compatibility notes)”沒有翻譯。感興趣的讀者可以直接訪問原文來了解相關資訊。
原文地址:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty