Vue 雙向資料繫結原理
# 前言
MVVM 是與 MVC 進化出來的,區別在與將view層的資料變動直接響應到viewModel層上而不是響應給model,其表現上最大的區別就在於雙向資料繫結功能
# 雙向資料繫結原理簡介
釋出者-訂閱者模式(Backbone.js)一般通過sub, pub的方式實現資料和檢視的繫結監聽,更新資料方式通常做法是 vm.set('property', value)
髒值檢查(Angular.js) 通過一定的事件觸發檢測資料是否有變更來決定是否需要更新檢視。如使用者操作ng-click,XHR事件,瀏覽器Location變更事件 (digest() 或 $apply()等
資料劫持(vue.js)
Vue 的雙向資料繫結的原理關心兩個要點
- 資料劫持: 通過
Object.definedProperty()
劫持並監聽資料變動 - 觀察者模式: 通過
釋出者-訂閱者
模式實現資料更新
# JavaScript/Reference/Global_Objects/Object/defineProperty" target="_blank" rel="nofollow,noindex">Object.definedProperty()
這是一個原生JavaScript標準庫中的一個方法,被稱作是 物件屬性的精確新增和修改 。用於直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性, 並返回這個物件。
Object.defineProperty(obj, prop, descriptor)

操作符含義:1/2/4的預設值是false,3/5/6預設值是undefined
從圖上可以看得出來,這個“精確新增和修改”的方法最重要的是屬性操作符的配置。需要提到的一點是,屬性操作符分類: 存取操作符
和 資料操作符
。上圖也有表示,指定時兩中操作符只能選擇其一,不能混用,否者報錯。
被操作的屬性如果不存在,則會建立這個屬性;如果已存在,則執行更新。
新增屬性
常用的 var obj = {};
obj.a = 1;
這種新增屬性的辦法實質上就是執行的如下操作。需要強調的是:直接賦值的操作符設定的都是true,而這些操作符他們本身的預設值都是false,注意區分。
var obj = {}; // 在物件中新增一個屬性與資料描述符的示例 Object.defineProperty(o, "attr", { value : 37, writable : true, enumerable : true, configurable : true });
【 解讀 】本例的意思是 給物件 obj
新增屬性 attr
,它的值為37,並且設定屬性 attr
的值可以被修改(writable),加入到可列舉佇列中以便 for-in / for-of
等操作可以遍歷到(enumerable),設定配置屬性可以修改,且該屬性可以被 delete obj.attr
刪除(configurable),所以有以下操作
obj.attr = 20; console.log( obj.attr );// 20 delete obj.attr; console.log(obj.attr); // undefined
修改屬性(writable)
正常情況下屬性都需要修改功能,需要設定操作符writable值為true。當設定為false時,並不會丟擲錯誤,但是值不會被修改。在嚴格模式下,會丟擲錯誤 throw Error:xx is read-only
var o = {}; // Creates a new object Object.defineProperty(o, 'a', { value: 37, writable: false }); console.log(o.a); // logs 37 o.a = 25; // No error thrown console.log(o.a); // logs 37.
是否可被for...in和Object.keys()獲取(enumerable)
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(configurable也是true) for (var i of o) { console.log(i);// 列印 'a' 和 'd' } Object.keys(o); // ["a", "d"] o.propertyIsEnumerable('a'); // true o.propertyIsEnumerable('b'); // false
設定屬性不可被刪除 (configurable)
configurable
特性表示物件的屬性是否可以被刪除,以及除 writable
特性外的其他特性是否可以被修改。【這裡並不是說設定 configurable
之後就不能設定其他操作符,而是不能修改,如下 enumerable
屬性】
var o = {}; Object.defineProperty(o, "a", { get : function(){return 1;}, enumerable: true, configurable: false }); // 以下例子會丟擲異常,因為屬性a的值不可被修改了 Object.defineProperty(o, "a", {value : 12}); console.log(o.a);// logs 1 delete o.a;// 不會丟擲錯誤,但執行不成功 console.log(o.a);// logs 1
當然,如果把 configurable
設定為 true
, delete o.a
就能將 a
屬性正確刪除
能實現 資料劫持
的存取操作符 get/set
get()
和 set()
是 Object.definedProperty()
中非常重要的兩個操作符,當屬性被訪問時, get
方法被執行,傳入的引數列表為空。當屬性被修改時,只有一個引數,即新值。注意引數列表雖然沒體現但預設都帶有 this
物件,並且需要注意 this
有可能是本身屬性,也有可能是繼承屬性。另外,訪問器屬性的會"覆蓋"同名的普通屬性,因為訪問器屬性會被優先訪問,與其同名的普通屬性則會被忽略。
| obj.attr = 10
這種直接賦值操作對應的 getter 和 setter 邏輯如下
var obj = { attr: 10 } Object.definedProperty(o, 'attr', { enumerable : true, configurable : true, get: function() { console.log('get方法被呼叫了'); return this.attr }, set: function(newValue) { console.log('set方法被呼叫了,引數是: ' + newVal); this.attr = newValue } }) obj.attr;// get方法被呼叫了 obj.attr = 3;// set方法被呼叫了,引數是: 3
| 資料劫持的實現
正因為 get
和 set
的存在,我們可以在其中自行新增一些處理邏輯
// 屬性操作符配置 var pattern = { enumerable : true, configurable : true, get: function () { return 'I alway return this string,whatever you have assigned'; }, set: function (newVal) { this.myname = 'this is my name string'; } }; function TestDefineSetAndGet() { Object.defineProperty(this, 'attr', pattern); }; var instance = new TestDefineSetAndGet(); instance.attr = 'baby'; console.log(instance.attr); // I alway return this string,whatever you have assigned console.log(instance.myname);// this is my name string
# 極簡的雙向資料繫結

此例實現的效果是:隨文字框輸入文字的變化, span
中會同步顯示相同的文字內容;在js或控制檯顯式的修改 obj.hello
的值,檢視會相應更新。這樣就實現了 model => view
以及 view => model
的雙向繫結。
# Vue 實現雙向資料繫結
整理了一下,要實現mvvm的雙向繫結,就必須要實現以下幾點:
1、實現一個數據監聽器Observer,能夠對資料物件的所有屬性進行監聽,如有變動可拿到最新值並通知訂閱者
2、實現一個指令解析器Compile,對每個元素節點的指令進行掃描和解析,根據指令模板替換資料,以及繫結相應的更新函式
3、實現一個Watcher,作為連線Observer和Compile的橋樑,能夠訂閱並收到每個屬性變動的通知,執行指令繫結的相應回撥函式,從而更新檢視
4、mvvm入口函式,整合以上三者

|實現Observer - 監聽每個資料的變化
現在我們知道可以利用 Obeject.defineProperty()
來監聽屬性變動, 那麼將需要observe的資料物件進行遞迴遍歷,包括子屬性物件的屬性,都加上 setter和getter。這樣的話,給這個物件的某個值賦值,就會觸發setter,那麼就能監聽到了資料變化。相關的程式碼可以是這樣的
var data = {name: 'Crain'} observe(data); data.name = "Ocaka";// 監聽到資料變化Crain => Ocaka (set方法中列印) function observe() { if (!data || typeof data !== 'object')return; Object.keys(data).forEach(function(key) { defineReactive(data, key, data[key]); }) } function defineReactive(data, key, val) { observe(val);// 深度遞迴 Object.defineProperty(data, key, { enumerable: true,// 可以被列舉 configurable: false,// 不能再被defined get: function() { return val; }, set: function(newVal) { console.log('監聽到變化 ', val, '=>', newVal) val = newVal } }) }
| 將資料變化通知訂閱者
我們已經可以監聽每個資料的變化了,那麼監聽到變化之後就是怎麼通知訂閱者了,所以接下來我們需要實現一個訊息訂閱器,很簡單,維護一個數組,用來收集訂閱者,資料變動觸發 notify
,再呼叫訂閱者的 update
方法,程式碼改善之後是這樣:
// ..省略 function definedReactive(data, key, val) { var dep = new Dep(); // 例項化一個訂閱器 observe(val);// 深度遞迴 Object.defineProperty(data, key, { // ... 省略 set: function(newVal) { if (val === newVal) return; console.log('監聽到變化 ', val, '=>', newVal); val = newVal; dep.notify(); // 通知所有訂閱者 } }); } // Dep訂閱器建構函式 function Dep() { this.subs = [];// 收集訂閱者 } Dep.prototype = { addSub: function(sub) { this.subs.push(sub); }, notify: function() { this.subs.forEach(function(sub) { sub.update(); }) } }
那麼問題來了,誰是訂閱者?怎麼往訂閱器新增訂閱者?
沒錯,上面的思路整理中我們已經明確訂閱者應該是 Watcher
, 而且 var dep = new Dep();
是在 defineReactive
方法內部定義的,所以想通過 dep
新增訂閱者,就必須要在閉包內操作,所以我們可以在 getter
裡面動手腳:
// Observer.js // ...省略 Object.defineProperty(data, key, { get: function() { // 由於需要在閉包內新增watcher,所以通過Dep定義一個全域性target屬性,暫存watcher, 新增完移除 Dep.target && dep.addDep(Dep.target); return val; } // ... 省略 }); // Watcher.js Watcher.prototype = { get: function(key) { Dep.target = this; this.value = data[key];// 這裡會觸發屬性的getter,從而新增訂閱者 Dep.target = null; } }
這裡已經實現了一個Observer了,已經具備了監聽資料和資料變化通知訂閱者的功能, 完整程式碼 。那麼接下來就是實現Compile了
| 實現 Compile
compile
主要做的事情是解析模板指令,將模板中的變數替換成資料,然後初始化渲染頁面檢視,並將每個指令對應的節點繫結更新函式,新增監聽資料的訂閱者,一旦資料有變動,收到通知,更新檢視,如圖所示:

因為遍歷解析的過程有多次操作dom節點,為提高效能和效率,會先將跟節點el轉換成文件碎片fragment進行解析編譯操作,解析完成,再將fragment添加回原來的真實dom節點中
function Compile(el) { this.$el = this.isElementNode(el) ? el : document.querySelector(el); if (this.$el) { this.$fragment = this.node2Fragment(this.$el); this.init(); this.$el.appendChild(this.$fragment); } } Compile.prototype = { init: function() { this.compileElement(this.$fragment); }, node2Fragment: function(el) { var fragment = document.createDocumentFragment(), child; // 將原生節點拷貝到fragment while (child = el.firstChild) { fragment.appendChild(child); } return fragment; } };
compileElement方法將遍歷所有節點及其子節點,進行掃描解析編譯,呼叫對應的指令渲染函式進行資料渲染,並呼叫對應的指令更新函式進行繫結,詳看程式碼及註釋說明:
Compile.prototype = { // ... 省略 compileElement: function(el) { var childNodes = el.childNodes, me = this; [].slice.call(childNodes).forEach(function(node) { var text = node.textContent; var reg = /\{\{(.*)\}\}/;// 表示式文字 // 按元素節點方式編譯 if (me.isElementNode(node)) { me.compile(node); } else if (me.isTextNode(node) && reg.test(text)) { me.compileText(node, RegExp.$1); } // 遍歷編譯子節點 if (node.childNodes && node.childNodes.length) { me.compileElement(node); } }); }, compile: function(node) { var nodeAttrs = node.attributes, me = this; [].slice.call(nodeAttrs).forEach(function(attr) { // 規定:指令以 v-xxx 命名 // 如 <span v-text="content"></span> 中指令為 v-text var attrName = attr.name;// v-text if (me.isDirective(attrName)) { var exp = attr.value; // content var dir = attrName.substring(2);// text if (me.isEventDirective(dir)) { // 事件指令, 如 v-on:click compileUtil.eventHandler(node, me.$vm, exp, dir); } else { // 普通指令 compileUtil[dir] && compileUtil[dir](node, me.$vm, exp); } } }); } }; // 指令處理集合 var compileUtil = { text: function(node, vm, exp) { this.bind(node, vm, exp, 'text'); }, // ...省略 bind: function(node, vm, exp, dir) { var updaterFn = updater[dir + 'Updater']; // 第一次初始化檢視 updaterFn && updaterFn(node, vm[exp]); // 例項化訂閱者,此操作會在對應的屬性訊息訂閱器中添加了該訂閱者watcher new Watcher(vm, exp, function(value, oldValue) { // 一旦屬性值有變化,會收到通知執行此更新函式,更新檢視 updaterFn && updaterFn(node, value, oldValue); }); } }; // 更新函式 var updater = { textUpdater: function(node, value) { node.textContent = typeof value == 'undefined' ? '' : value; } // ...省略 };
這裡通過遞迴遍歷保證了每個節點及子節點都會解析編譯到,包括了 {{}}
表示式宣告的文字節點。指令的宣告規定是通過特定字首的節點屬性來標記,如 <span v-text="content" other-attr
中 v-text
便是指令,而 other-attr
不是指令,只是普通的屬性。
監聽資料、繫結更新函式的處理是在 compileUtil.bind()
這個方法中,通過 new Watcher()
添加回調來接收資料變化的通知
至此,一個簡單的Compile就完成了, 完整程式碼 。接下來要看看Watcher這個訂閱者的具體實現了
| 實現Watcher
Watcher訂閱者作為Observer和Compile之間通訊的橋樑,主要做的事情是:
1、在自身例項化時往屬性訂閱器( dep
)裡面新增自己
2、自身必須有一個 update()
方法
3、待屬性變動 dep.notice()
通知時,能呼叫自身的 update()
方法,並觸發 Compile
中繫結的回撥,則功成身退。
如果有點亂,可以回顧下前面的思路整理
function Watcher(vm, exp, cb) { this.cb = cb; this.vm = vm; this.exp = exp; // 此處為了觸發屬性的getter,從而在dep新增自己,結合Observer更易理解 this.value = this.get(); } Watcher.prototype = { update: function() { this.run();// 屬性值變化收到通知 }, run: function() { var value = this.get(); // 取到最新值 var oldVal = this.value; if (value !== oldVal) { this.value = value; this.cb.call(this.vm, value, oldVal); // 執行Compile中繫結的回撥,更新檢視 } }, get: function() { Dep.target = this;// 將當前訂閱者指向自己 var value = this.vm[exp];// 觸發getter,新增自己到屬性訂閱器中 Dep.target = null;// 新增完畢,重置 return value; } }; // 這裡再次列出Observer和Dep,方便理解 Object.defineProperty(data, key, { get: function() { // 由於需要在閉包內新增watcher,所以可以在Dep定義一個全域性target屬性,暫存watcher, 新增完移除 Dep.target && dep.addDep(Dep.target); return val; } // ... 省略 }); Dep.prototype = { notify: function() { this.subs.forEach(function(sub) { sub.update(); // 呼叫訂閱者的update方法,通知變化 }); } };
例項化 Watcher
的時候,呼叫 get()
方法,通過 Dep.target = watcherInstance
標記訂閱者是當前watcher例項,強行觸發屬性定義的 getter
方法, getter
方法執行的時候,就會在屬性的訂閱器 dep
添加當前watcher例項,從而在屬性值有變化的時候,watcherInstance就能收到更新通知。
ok, Watcher也已經實現了, 完整程式碼 。
基本上vue中資料繫結相關比較核心的幾個模組也是這幾個,猛戳 這裡 , 在 src
目錄可找到vue原始碼。
最後來講講MVVM入口檔案的相關邏輯和實現吧,相對就比較簡單了~
| 實現 MVVM
MVVM作為資料繫結的入口,整合Observer、Compile和Watcher三者,通過Observer來監聽自己的model資料變化,通過Compile來解析編譯模板指令,最終利用Watcher搭起Observer和Compile之間的通訊橋樑,達到資料變化 -> 檢視更新;檢視互動變化(input) -> 資料model變更的雙向繫結效果。
一個簡單的MVVM構造器是這樣子:
function MVVM(options) { this.$options = options; var data = this._data = this.$options.data; observe(data, this); this.$compile = new Compile(options.el || document.body, this) }
但是這裡有個問題,從程式碼中可看出監聽的資料物件是 options.data
,每次需要更新檢視,則必須通過 var vm = new MVVM({data:{name: 'kindeng'}}); vm._data.name = 'dmq';
這樣的方式來改變資料。
顯然不符合我們一開始的期望,我們所期望的呼叫方式應該是這樣的:
var vm = new MVVM({data: {name: 'kindeng'}}); vm.name = 'dmq';
所以這裡需要給MVVM例項新增一個屬性代理的方法,使訪問vm的屬性代理為訪問vm._data的屬性,改造後的程式碼如下:
function MVVM(options) { this.$options = options; var data = this._data = this.$options.data, me = this; // 屬性代理,實現 vm.xxx -> vm._data.xxx Object.keys(data).forEach(function(key) { me._proxy(key); }); observe(data, this); this.$compile = new Compile(options.el || document.body, this) } MVVM.prototype = { _proxy: function(key) { var me = this; Object.defineProperty(me, key, { configurable: false, enumerable: true, get: function proxyGetter() { return me._data[key]; }, set: function proxySetter(newVal) { me._data[key] = newVal; } }); } };
這裡主要還是利用了 Object.defineProperty()
這個方法來劫持了vm例項物件的屬性的讀寫權,使讀寫vm例項的屬性轉成讀寫了 vm._data
的屬性值,達到魚目混珠的效果