Vue 的雙向綁定策略基礎是數據劫持,在 Vue2.0 中使用了 ES5 語法 Object.defineProperty,來劫持各個屬性的 setter/getter,在數據變動時發布消息給訂閱者(Wacther), 觸發相應的監聽回調。先來看一下這個 ES5 特性,我們可以通過 Object.defineProperty 這個方法,直接在一個對象上定義一個新的屬性,或者修改已存在的屬性,最終這個方法會返回該對象,如下為簡單說明,對該特性不了解的同學可以查看《JavaScript 高級程序設計》的第六章,或者在線訪問 MDN Web 文檔: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
var o = {};
var value = 1;
Object.defineProperty(o, 'a', {
get: function() { return value; },
set: function(newValue) { value = newValue; },
enumerable: true,
configurable: true
});
o.a; // 1
o.a = 2;
o.a; // 2
結合這一特定與發布訂閱機制,可以實現完整的雙向綁定。如下所示,Observer 數據監聽器能夠對數據對象的所有屬性進行監聽,如有變動可拿到最新值並通知訂閱者,內部采用 Object.defineProperty 的 getter 和 setter 來實現 [3]。
Compile 指令解析器,它的作用對每個元素節點的指令進行掃描和解析,根據指令模板替換數據,以及綁定相應的更新函數。
Watcher 訂閱者, 作為連接 Observer 和 Compile 的橋梁,能夠訂閱並收到每個屬性變動的通知,執行指令綁定的相應回調函數。Dep 消息訂閱器,內部維護了一個數組,用來收集訂閱者(Watcher),數據變動觸發 notify 函數,再調用訂閱者的 update 方法。
當執行 new Vue() 時,Vue 就進入了初始化階段,一方面會遍歷 data 選項中的屬性,用 Object.defineProperty 將它們轉為 getter/setter,實現數據變化監聽功能;另一方面,Vue 的指令編譯器 Compile 對元素節點的指令進行掃描和解析,初始化視圖,並訂閱 Watcher 來更新視圖, 此時 Wather 會將自己添加到消息訂閱器中 (Dep), 初始化完畢。當數據發生變化時,Observer 中的 setter 方法被觸發,setter 會立即調用 Dep.notify(),Dep 開始遍歷所有的訂閱者,並調用訂閱者的 update 方法,訂閱者收到通知后對視圖進行相應的更新

使用 Object.defineProperty 這個特性存在一些明顯的缺點,總結起來大概是下面兩個:
- Object.defineProperty 無法監控到數組下標的變化,當監控數組數據對象的時候,實質上就是監控數組的地址,地址不變也就不會被監測到。為了解決這個問題,經過 Vue 內部處理后可以使用 push、pop、shift、unshift、splice、sort、reverse 來監聽數組。
-
Object.defineProperty 只能劫持對象的屬性, 因此我們需要對每個對象的每個屬性進行遍歷。Vue 2.x 里,是通過遞歸 + 遍歷 data 對象來實現對數據的監控的。如果屬性值也是對象,那么需要深度遍歷,顯然如果能劫持一個完整的對象是才是更好的選擇。
由於只針對了以上八種方法進行了 hack 處理,所以其他數組的屬性也是檢測不到的,還是具有一定的局限性。Vue3.0 中使用了 ES6 語法 Proxy,用於取代 defineProperty,使用 Proxy 有以下兩個優點:
- 可以劫持整個對象,並返回一個新對象。
-
有 13 種劫持操作。
既然 Proxy 能解決以上兩個問題,而且 Proxy 作為 es6 的新屬性在 Vue2.x 之前就有了,為什么 Vue2.x 不使用 Proxy 呢?一個很重要的原因就是,Proxy 是 ES6 提供的新特性,兼容性不好,並且是這個屬性無法用 polyfill 來兼容。
Vue 的雙向綁定策略成為當前考察前端人員技術功底的重點,我們以 Object.defineProperty 特性實現一個簡單的雙向綁定,實現最初的 hello everyone 效果。
<!DOCTYPE html>
<html lang="en">
<head>
<title>雙向綁定最最最初級 demo</title>
<meta charset="UTF-8">
</head>
<body>
<div id="app">
<input type="text" id="txt">
<p id="show-txt"></p>
<button onClick="changeData()">更新數據</button>
</div>
</body>
<script>
var obj={}
Object.defineProperty(obj,'txt',{
get:function(){
return obj
},
set:function(newValue){
document.getElementById('txt').value = newValue
document.getElementById('show-txt').innerHTML = newValue
}
})
document.addEventListener('keyup',function(e){
obj.txt = e.target.value
})
changeData = function() {
obj.txt = 'hello world';
}
</script>
</html>
由於 Object.defineProperty 默認只能劫持值類型數據,對引用類型數據的內部修改無法劫持,需要重寫覆蓋原原型方法,以 Array 為例,如下可以支持到 7 種數組方法:
let arr = [];
let arrayMethod = Object.create(Array.prototype);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
Object.defineProperty(arrayMethod, method, {
enumerable: true,
configurable: true,
value: function () {
let args = [...arguments]
Array.prototype[method].apply(this, args);
console.log(`operation: ${method}`);
}
})
});
arr.__proto__ = arrayMethod;
arr.push(1); // 劫持到了 push 方法
相對完整的仿 Vue 雙向綁定實現,來自雙向綁定數組源碼: https://gitee.com/merico/Blog/tree/master/Object.defineProperty_Array
<!DOCTYPE html>
<html lang="en">
<head>
<title>雙向綁定支持數組監聽</title>
<meta charset="UTF-8">
</head>
<body>
<div id="app">
<div id='list'></div>
<input type="button" value="添加" onclick="btnAdd()" />
<input type="button" value="刪除" onclick="btnDel()" />
</div>
</body>
<script>
// 數據源
let vm = {
list: [1, 2, 3, 4]
}
// 用於管理 watcher 的 Dep 對象
let Dep = function () {
this.list = [];
this.add = function (watcher) {
this.list.push(watcher)
};
this.notify = function (newValue) {
this.list.forEach(function (fn) {
fn(newValue)
})
}
};
// 模擬 compile, 通過對 Html 的解析生成一系列訂閱者(watcher)
function renderList() {
let listContainer = document.querySelector('#list');
let contentList = '';
vm.list.forEach(function (item) {
contentList = contentList + `<div><h3>${item}</h3></div>`
})
listContainer.innerHTML = contentList;
}
// 將解析出來的 watcher 存入 Dep 中待用
let dep = new Dep();
dep.add(renderList)
// 核心方法
function initMVVM(vm) {
Object.keys(vm).forEach(function (key) {
let value = vm[key];
if (Array.isArray(value)) {
observeArray(vm, key)
}
})
}
function observeArray(vm, key) {
let arrayMethod = bindWatcherToArray();
vm[key].__proto__ = arrayMethod;
}
function bindWatcherToArray() {
let arrayMethod = Object.create(Array.prototype);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
Object.defineProperty(arrayMethod, method, {
enumerable: true,
configurable: true,
value: function () {
let args = [...arguments]
Array.prototype[method].apply(this, args);
console.log(`operation: ${method}`)
dep.notify();
}
})
});
return arrayMethod
}
// 頁面引用的方法
function btnAdd() {
vm.list.push(Math.random())
}
function btnDel() {
vm.list.pop()
}
// 初始化數據源
initMVVM(vm)
// 初始化頁面
dep.notify();
</script>
</html>
