在理解原理之前先簡要了解一些ES5的Object.defineProperty()方法。
1.Object.defineProperty()
ECMAScript 5 為 JavaScript 添加了大量新的對象方法。
// 添加或更改對象屬性 Object.defineProperty(object, property, descriptor) // 添加或更改多個對象屬性 Object.defineProperties(object, descriptors) // 訪問屬性 Object.getOwnPropertyDescriptor(object, property) // 以數組返回所有屬性 Object.getOwnPropertyNames(object) // 以數組返回所有可枚舉的屬性 Object.keys(object) // 訪問原型 Object.getPrototypeOf(object) // 阻止向對象添加屬性 Object.preventExtensions(object) // 如果可將屬性添加到對象,則返回 true Object.isExtensible(object) // 防止更改對象屬性(而不是值) Object.seal(object) // 如果對象被密封,則返回 true Object.isSealed(object) // 防止對對象進行任何更改 Object.freeze(object) // 如果對象被凍結,則返回 true Object.isFrozen(object)
這里簡單的了解一下Object.defineProperty()方法。
(0)語法:
Object.defineProperty(obj, prop, descriptor)
參數:
obj:要在其上定義屬性的對象
prop:要定義或修改的屬性的名稱
descriptor:將被定義或修改的屬性描述符
返回值:
被傳遞給函數的對象。
描述符可同時具有的鍵值:
對象里目前存在的屬性描述符有兩種主要形式:數據描述符和存取描述符。數據描述符是一個具有值的屬性,該值可能是可寫的,也可能不是可寫的。存取描述符是由getter-setter函數對描述的屬性。描述符必須是這兩種形式之一;不能同時是兩者。
如果一個描述符不具有value,writable,get 和 set 任意一個關鍵字,那么它將被認為是一個數據描述符。如果一個描述符同時有(value或writable)和(get或set)關鍵字,將會產生一個異常。
(1)更改屬性:
Object.defineProperty(object, property, {value : value})
例如:
var person = { firstName: "Bill", lastName: "Gates", language: "EN" }; // 更改屬性 Object.defineProperty(person, "language", { value: "ZH" }); console.log(person);
結果:
(2)添加屬性,可以指定是否允許修改、枚舉、可重新配置等。
// 創建屬性 Object.defineProperty(object, property, { value: value, // 值 writable: true // 屬性值可修改 enumerable: true // 屬性可枚舉 configurable: true // 屬性可重新配置 writable: false // 屬性值不可修改 (默認值) enumerable: false // 屬性不可枚舉 configurable: false // 屬性不可重新配置(默認值) });
例如:
<script type="text/javascript"> // 創建對象 var person = { firstName: "Bill", lastName: "Gates" }; // 添加屬性 Object.defineProperty(person, "fullName", { value: "QZ", configurable: true, writable: true }); console.log(person); person.fullName = "新值"; console.log(person); </script>
結果:
(3)添加getter與setter
// 創建對象 var person = { firstName: "Bill", lastName: "Gates" }; // 添加屬性 Object.defineProperty(person, "fullName", { value: "QZ", configurable: true, writable: true }); // 添加getter與setter Object.defineProperty(person, "getF", { get: function() { console.log("getF被調用"); return this.fullName; } }); Object.defineProperty(person, "setF", { set: function(newValue) { console.log("setF被調用 " + newValue); this.fullName = newValue; } }); console.log(person.getF) person.setF = "新值" console.log(person.getF)
結果:
(4)Object.defineProperty()實現數據劫持
// 用於記錄劫持的數據 let objName = ""; let obj = {}; // 添加屬性 Object.defineProperty(obj, "name", { configurable: true, // 允許配置 enumerable: true, // 允許枚舉,遍歷 // getter。每次調用obj.name都會走這里 get: function() { console.log("get被執行!"); return objName; }, // setter。每次調用obj.name=xxx 修改屬性都會走這里 set: function(newValue) { // 達到劫持數據的效果 console.log("set被執行!"); objName = newValue; } }); obj.name = "123456"; console.log("objName: " + objName)
結果:
2.深入響應式原理
當把一個普通的 JavaScript 對象傳入 Vue 實例作為 data 選項,Vue 將遍歷此對象所有的屬性,並使用 Object.defineProperty 把這些屬性全部轉為 getter/setter。Object.defineProperty 是 ES5 特性,這也就是 Vue 不支持 IE8 以及更低版本瀏覽器的原因。
這些 getter/setter 對用戶來說是不可見的,但是在內部它們讓 Vue 能夠追蹤依賴,在屬性被訪問和修改時通知變更。
每個組件實例都對應一個 watcher 實例,它會在組件渲染的過程中把“接觸”過的數據屬性記錄為依賴。之后當依賴項的 setter 觸發時,會通知 watcher,從而使它關聯的組件重新渲染。
簡單理解是由數據劫持結合發布者-訂閱者模式實現的。說白了就是通過Object.defineProperty()來劫持對象屬性的setter和getter操作,在數據變動時通過發布者-訂閱者模式來實現更新試圖。
1.檢測變化的注意事項
受現代 JavaScript 的限制 (而且 Object.observe 也已經被廢棄),Vue 無法檢測到對象屬性的添加或刪除。由於 Vue 會在初始化實例時對屬性執行 getter/setter 轉化,所以屬性必須在 data 對象上存在才能讓 Vue 將它轉換為響應式的。例如:
var vm = new Vue({ data:{ a:1 } }) // `vm.a` 是響應式的 vm.b = 2 // `vm.b` 是非響應式的
對於已經創建的實例,Vue 不允許動態添加根級別的響應式屬性。但是,可以使用 Vue.set(object, propertyName, value) 方法向嵌套對象添加響應式屬性。例如,對於:
Vue.set(vm.someObject, 'b', 2)
還可以使用 vm.$set 實例方法,這也是全局 Vue.set 方法的別名:
this.$set(this.someObject,'b',2)
有時可能需要為已有對象賦值多個新屬性,比如使用 Object.assign() 或 _.extend()。但是,這樣添加到對象上的新屬性不會觸發更新。在這種情況下,你應該用原對象與要混合進去的對象的屬性一起創建一個新的對象。
// 代替 `Object.assign(this.someObject, { a: 1, b: 2 })` this.someObject = Object.assign({}, this.someObject, { a: 1, b: 2 })
補充:Object.assign() 方法
Object.assign() 方法用於將所有可枚舉屬性的值從一個或多個源對象復制到目標對象。它將返回目標對象。類似於jQuery.extend()方法。
因為 Object.assign()拷貝的是屬性值。假如源對象的屬性值是一個對象的引用,那么它也只指向那個引用。也就是說,如果對象的屬性值為簡單類型(如string, number),通過Object.assign({},srcObj);得到的新對象為深拷貝;如果屬性值為對象或其它引用類型,那對於這個對象而言其實是淺拷貝的。可以用jQuery.extend()實現深拷貝。
語法:
Object.assign(target, ...sources)
2.聲明響應式屬性
由於 Vue 不允許動態添加根級響應式屬性,所以你必須在初始化實例前聲明所有根級響應式屬性,哪怕只是一個空值:
var vm = new Vue({ data: { // 聲明 message 為一個空值字符串 message: '' }, template: '<div>{{ message }}</div>' }) // 之后設置 `message` vm.message = 'Hello!'
如果你未在 data
選項中聲明 message
,Vue 將警告你渲染函數正在試圖訪問不存在的屬性。
這樣的限制在背后是有其技術原因的,它消除了在依賴項跟蹤系統中的一類邊界情況,也使 Vue 實例能更好地配合類型檢查系統工作。但與此同時在代碼可維護性方面也有一點重要的考慮:data
對象就像組件狀態的結構 (schema)。提前聲明所有的響應式屬性,可以讓組件代碼在未來修改或給其他開發人員閱讀時更易於理解。
3.異步更新隊列
Vue 在更新 DOM 時是異步執行的。只要偵聽到數據變化,Vue 將開啟一個隊列,並緩沖在同一事件循環中發生的所有數據變更。如果同一個 watcher 被多次觸發,只會被推入到隊列中一次。這種在緩沖時去除重復數據對於避免不必要的計算和 DOM 操作是非常重要的。然后,在下一個的事件循環“tick”中,Vue 刷新隊列並執行實際 (已去重的) 工作。Vue 在內部對異步隊列嘗試使用原生的 Promise.then、MutationObserver 和 setImmediate,如果執行環境不支持,則會采用 setTimeout(fn, 0) 代替。
例如,當設置 vm.someData = 'new value',該組件不會立即重新渲染。當刷新隊列時,組件會在下一個事件循環“tick”中更新。多數情況我們不需要關心這個過程,但是如果你想基於更新后的 DOM 狀態來做點什么,這就可能會有些棘手。雖然 Vue.js 通常鼓勵開發人員使用“數據驅動”的方式思考,避免直接接觸 DOM,但是有時我們必須要這么做。為了在數據變化之后等待 Vue 完成更新 DOM,可以在數據變化之后立即使用 Vue.nextTick(callback)。這樣回調函數將在 DOM 更新完成后被調用。例如:
<div id="example">{{message}}</div> <script type="text/javascript"> var vm = new Vue({ el: '#example', data: { message: '123' } }) vm.message = 'new message' // 更改數據 // vm.$el 獲取的是元素。textContent 屬性設置或返回指定節點的文本內容,以及它的所有后代。 var va1 = vm.$el.textContent === 'new message'; // false console.log("va1" + va1) // 修改完屬性會 Vue.nextTick(function() { var va2 = vm.$el.textContent === 'new message' // true console.log("va2" + va2) }) </script>
結果:
在組件內使用 vm.$nextTick()
實例方法特別方便,因為它不需要全局 Vue
,並且回調函數中的 this
將自動綁定到當前的 Vue 實例上:
<div id="example">{{message}}</div> <script type="text/javascript"> var vm = new Vue({ el: '#example', data: { message: '123' }, methods: { changeValue() { this.message = 'new message'; var va1 = this.$el.textContent === 'new message'; // false console.log("va1" + va1) // 修改完屬性會 this.$nextTick(function() { var va2 = vm.$el.textContent === 'new message' // true console.log("va2" + va2) }) } } }); vm.changeValue(); </script>
結果同上。
因為 $nextTick() 返回一個 Promise 對象,所以你可以使用新的 ES2017 async/await 語法完成相同的事情:
<div id="example">{{message}}</div> <script type="text/javascript"> var vm = new Vue({ el: '#example', data: { message: '123' }, methods: { async changeValue() { this.message = 'new message' console.log(this.$el.textContent) // => '123' await this.$nextTick() console.log(this.$el.textContent) // => 'new message' } } }); vm.changeValue(); </script>
結果:
3.手寫vue雙向數據綁定
通過Object.defineProperty()來劫持對象屬性的setter和getter操作,在數據變動時通過發布者-訂閱者模式來實現更新試圖。
頁面結構如下:
<div id="app"> <form> <input type="text" v-model="number"> <button type="button" v-on:click="increment">增加</button> </form> <h3 v-bind:title="number">{{number}}</h3> </div>
解釋:
(1)一個input,用v-model指令實現雙向數據綁定
(2)一個button,用v-on:click 實現綁定事件
(3)一個h3,用v-bind和 {{ }} 實現動態數據監聽
對應的JS如下:
<script> /** * 構造函數 * @param {Object} options 選項 */ function myVue(options) { this._init(options); } // 初始化 myVue.prototype._init = function(options) { this.$options = options; this.$el = document.querySelector(options.el); this.$data = options.data; this.$methods = options.methods; // _binding 存的是每個屬性的觀察者,在setter方法中獲取到之后通知觀察者進行更新。扮演一個導演者的角色 this._binding = {}; this._observe(this.$data); this._complie(this.$el); } // 觀察者:攔截對象(劫持get、set方法,綁定每個屬性的觀察者到_binding屬性) myVue.prototype._observe = function(obj) { var value; for(key in obj) { // Object的hasOwnProperty()方法返回一個布爾值,判斷對象是否包含特定的自身(非繼承)屬性。 // 如果是自身的屬性就綁定監聽者 if(obj.hasOwnProperty(key)) { this._binding[key] = { _directives: [] }; value = obj[key]; if(typeof value === 'object') { this._observe(value); } var binding = this._binding[key]; // 劫持get、set方法 Object.defineProperty(this.$data, key, { enumerable: true, configurable: true, get: function() { console.log(`獲取${value}`); return value; }, set: function(newVal) { console.log(`更新${newVal}`); if(value !== newVal) { value = newVal; // 通知訂閱者更新自身 binding._directives.forEach(function(item) { item.update(); }) } } }) } } } /** * 解析DOM結構 * @param {Object} root 元素 */ myVue.prototype._complie = function(root) { var _this = this; var nodes = root.children; for(var i = 0; i < nodes.length; i++) { var node = nodes[i]; if(node.children.length) { this._complie(node); } // 解析v-on指令 if(node.hasAttribute('v-on:click') || node.hasAttribute('@click')) { node.onclick = (function() { var attrVal = nodes[i].getAttribute('v-on:click'); if(!attrVal) { attrVal = nodes[i].getAttribute('@click'); } // fun.bind是綁定一個函數,語法是fun.bind(thisArg[, arg1[, arg2[, ...]]]) return _this.$methods[attrVal].bind(_this.$data); })(); } // 解析v-model指令 if(node.hasAttribute('v-model') && (node.tagName == 'INPUT' || node.tagName == 'TEXTAREA')) { node.addEventListener('input', (function(key) { var attrVal = node.getAttribute('v-model'); _this._binding[attrVal]._directives.push(new Watcher( 'input', node, _this, attrVal, 'value' )) return function() { _this.$data[attrVal] = nodes[key].value; } })(i)); } // 解析v-bind指令,縮寫:。 if(node.hasAttribute('v-bind:title') || node.hasAttribute(':title')) { var attrVal = node.getAttribute('v-bind:title'); if(!attrVal) { attrVal = node.getAttribute(':title'); } _this._binding[attrVal]._directives.push(new Watcher( 'text', node, _this, attrVal, 'title' )) } // 解析innerHtml,替換{{}}里面的數據 if(node.innerHTML) { let innerHTMLStr = node.innerHTML; var matchReg = /(?<={{).*?(?=})/g; if(innerHTMLStr.match(matchReg)) { for(let attrVal of innerHTMLStr.match(matchReg)) { _this._binding[attrVal]._directives.push(new Watcher( 'text', node, _this, attrVal, 'innerText' )) } } } } } // 觀察者 function Watcher(name, el, vm, exp, attr) { this.name = name; //指令名稱,例如文本節點,該值設為"text" this.el = el; //指令對應的DOM元素 this.vm = vm; //指令所屬myVue實例 this.exp = exp; //指令對應的值,本例如"number" this.attr = attr; //綁定的屬性值 this.update(); } Watcher.prototype.update = function() { this.el[this.attr] = this.vm.$data[this.exp]; } // 類似vue的運行方法 var app = new myVue({ el: '#app', data: { number: 0 }, methods: { increment: function() { this.number++; }, } }) </script>
代碼解釋:
(1)上面執行new myVue(option)的時候會執行一系列的初始化工作,最重要的就是_observe方法劫持對象,重寫set更新值方法,更新 值的方法會通知訂閱了指定屬性的訂閱者進行更新操作。_complie 方法是編譯DOM,解析里面指定的指令,重新設置屬性,以及向 _directives 注冊屬性的觀察者
(2)當修改屬性的時候會走set方法,通知訂閱者進行update操作。update方法根據訂閱的屬性以及元素進行修改指定的屬性。
運行成功之后我們在控制台打印app看一下屬性如下;將其屬性number改為9.如下