vue雙向繫結的原理及實現雙向繫結MVVM原始碼分析
雙向資料繫結的原理是:可以將物件的屬性繫結到UI,具體的說,我們有一個物件,該物件有一個name屬性,當我們給這個物件name屬性賦新值的時候,新值在UI上也會得到更新。同樣的道理,當我們有一個輸入框或者textarea的時候,我們輸入一個新值的時候,也會在該物件的name屬性得到更新。
實現資料繫結的做法有如下幾種:
1. 釋出者-訂閱模式(http://www.cnblogs.com/tugenhua0707/p/7471381.html)
2. 髒值檢查(angular.js)
3. 資料劫持(vue.js)
髒值檢查:是通過髒值檢測的方式比對資料是否有變更,來決定是否更新檢視,最簡單的方式就是通過 setInterval()定時輪詢檢測資料的變動。
資料劫持:vue.js 則是採用資料劫持結合釋出者-訂閱者模式,通過Object.defineProperty()來劫持各個屬性的setter,getter。在資料變動時釋出訊息給訂閱者,觸發響應的監聽回撥。
下面是一個通過 Object.defineProperty 來實現一個簡單的資料雙向繫結。通過該方法來監聽屬性的變化。
實現的效果簡單如下:頁面上有一個input輸入框和div顯示框,當在input輸入框輸入值的時候,div也會顯示對應的值,當我開啟控制檯改變 obj.name="輸入任意值"的時候,按Enter鍵執行下,input輸入框的值也會跟著變,可以簡單的理解為 模型-> 檢視的 改變,以及 檢視 -> 模型的改變。如下程式碼:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no" name="viewport"> <meta content="yes" name="apple-mobile-web-app-capable"> <meta content="black" name="apple-mobile-web-app-status-bar-style"> <meta content="telephone=no" name="format-detection"> <meta content="email=no" name="format-detection"> <title>標題</title> <link rel="shortcut icon" href="/favicon.ico"> </head> <body> <h3>使用Object.defineProperty實現簡單的雙向資料繫結</h3> <input type="text" id="input" /> <div id="div"></div> <script> var obj = {}; var inputVal = document.getElementById("input"); var div = document.getElementById("div"); Object.defineProperty(obj, "name", { set: function(newVal) { inputVal.value = newVal; div.innerHTML = newVal; } }); inputVal.addEventListener('input', function(e){ obj.name = e.target.value; }); </script> </body> </html>
vue是通過資料劫持的方式來做資料繫結的,最核心的方法是通過 Object.defineProperty()方法來實現對屬性的劫持,達到能監聽到資料的變動。要實現資料的雙向繫結,需要實現如下幾點:
1. 需要實現一個資料監聽器Observer,能夠對資料物件的所有屬性進行監聽,如有變動拿到最新值並通知訂閱者。
2. 需要實現一個指令解析器Compile, 對每個元素節點的指令進行掃描和解析,根據指令模板替換資料,以及繫結相應的更新函式。
3. 需要實現一個Watcher,作為連結Observer和Compile的橋樑,能夠訂閱並收到每個屬性變動的通知,執行指令繫結的相應的回撥函式,從而更新檢視。
一: 實現Observer
我們可以使用Object.defineProperty()來監聽屬性的變動,我們需要對屬性物件進行遞迴遍歷,包括子屬性物件的屬性。再加上getter和setter方法,我們可以和上面的demo一樣,監聽input值的變化,即 檢視 -> 模型。且當我們給某個物件的屬性賦值的話,會自動呼叫setter方法,來動態修改資料,即 模型 -> 檢視。
引入我們可以使用 object.defineProperty來實現監聽屬性的變動,如下程式碼:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no" name="viewport"> <meta content="yes" name="apple-mobile-web-app-capable"> <meta content="black" name="apple-mobile-web-app-status-bar-style"> <meta content="telephone=no" name="format-detection"> <meta content="email=no" name="format-detection"> <title>標題</title> <link rel="shortcut icon" href="/favicon.ico"> </head> <body> <script> function Observer(data) { this.data = data; this.init(); } Observer.prototype = { init: function() { var self = this; var data = self.data; // 遍歷data物件 Object.keys(data).forEach(function(key) { self.defineReactive(data, key, data[key]); }); }, defineReactive: function(data, key, value) { // 遞迴遍歷子物件 var childObj = observer(value); // 對物件的屬性使用Object.defineProperty進行監聽 Object.defineProperty(data, key, { enumerable: true, // 可列舉 configurable: false, // 不能刪除目標屬性或不能修改目標屬性 get: function() { return value; }, set: function(newVal) { if (newVal === value) { return; } console.log('已經監聽到值的變化了', value, '==>', newVal); value = newVal; } }); } } function observer(value) { if (!value || typeof value !== 'object') { return; } return new Observer(value); } // 測試demo var data = {name: 'kongzhi'}; observer(data); data.name = 'kongzhi2'; // 控制檯列印出 已經監聽到值的變化了,kongzhi ==> kongzhi2 </script> </body> </html>
如上程式碼我們可以監聽每個屬性物件資料的變化了,那麼監聽到屬性值變化後我們需要把訊息通知到訂閱者,因此我們需要實現一個訊息訂閱器,該訂閱器的作用是收集所有的訂閱者,當有屬性值發生改變的時候,就把該訊息通知給所有訂閱者。因此我們可以實現如下程式碼:
// 對所有的屬性資料進行監聽 function Observer(data) { this.data = data; this.init(); } Observer.prototype = { init: function() { var self = this; var data = self.data; // 遍歷data物件 Object.keys(data).forEach(function(key) { self.defineReactive(data, key, data[key]); }); }, defineReactive: function(data, key, value) { var dep = new Dep(); // 遞迴遍歷子物件 var childObj = observer(value); // 對物件的屬性使用Object.defineProperty進行監聽 Object.defineProperty(data, key, { enumerable: true, // 可列舉 configurable: false, // 不能刪除目標屬性或不能修改目標屬性 get: function() { if (Dep.target) { dep.depend(); } return value; }, set: function(newVal) { if (newVal === value) { return; } value = newVal; // 如果新值是物件的話,遞迴該物件 進行監聽 childObj = observer(newVal); // 通知訂閱者 dep.notify(); } }); } } function observer(value) { if (!value || typeof value !== 'object') { return; } return new Observer(value); } function Dep() { this.subs = []; } Dep.prototype = { addSub: function(sub) { this.subs.push(sub); }, depend: function() { Dep.target.addDep(this); }, removeSub: function(sub) { var index = this.subs.indexOf(sub); if (index != -1) { this.subs.splice(index, 1); } }, notify: function() { // 遍歷所有的訂閱者 通知所有的訂閱者 this.subs.forEach(function(sub) { sub.update(); }) } }; Dep.target = null;
從上面的分析得到,需要實現一個Watcher,作為連結Observer和Compile的橋樑,能夠訂閱並收到每個屬性變動的通知,所以訂閱者就是Watcher,var dep = new Dep();是在 defineReactive方法內部定義的,是想通過dep新增訂閱者,通過Dep定義一個全域性的target屬性,暫存watcher,新增完後會移除,因此最後一句程式碼設定 Dep.target = null;
執行getter方法會返回值,執行setter方法會判斷新舊值是否相等,如果不相等的話,再次遞迴遍歷設定的物件的所有子物件,然後會通知所有的訂閱者。
二: 實現 Compile
compile做的事情是解析模板指令,將模板中的變數替換成資料,然後初始化渲染頁面檢視,並將每個指令對應的節點繫結更新函式。
如下圖所示:
// 具體看如下程式碼,程式碼中有對應的註釋 /* * @param {el} 元素節點容器 如:el: '#mvvm-app' * @param {vm} Object 物件傳遞資料 */ function Compile(el, vm) { this.$vm = vm; // 判斷是元素節點 還是 選擇器 this.$el = this.isElementNode(el) ? el : document.querySelector(el); if (this.$el) { /* 因為遍歷解析過程有多次操作dom節點,為了提高效能和效率,會先將根節點el轉換成文件碎片fragment進行解析編譯操作, 解析完成後,再將fragment新增回原來真實的dom節點中。有關 DocumentFragment 請看 http://www.cnblogs.com/tugenhua0707/p/7465915.html */ this.$fragment = this.node2Fragment(this.$el); this.init(); this.$el.appendChild(this.$fragment); } } Compile.prototype = { node2Fragment: function(el) { var fragment = document.createDocumentFragment(), child; // 將原生節點拷貝到fragment中 while (child = el.firstChild) { fragment.appendChild(child); } return fragment; }, init: function() { // 進行解析編譯操作 this.compileElement(this.$fragment); }, compileElement: function(el) { var childNodes = el.childNodes; // 遍歷所有的子節點 判斷子節點是 元素節點還是文字節點 分別進行編譯解析操作 [].slice.call(childNodes).forEach(function(node) { // 獲取節點的文字內容和它的所有後代 var text = node.textContent; // 正則匹配 {{xx}} 這樣的xx文字值 var reg = /\{\{(.*)\}\}/; // 判斷是否是元素節點,然後進行編譯 if (this.isElementNode(node)) { this.compile(node); } else if(this.isTextNode(node) && reg.test(text)) { // 判斷node是否是文字節點 且 符合正則匹配的 {{xx}} 那麼就會進行編譯解析 this.compileText(node, RegExp.$1); } // 如果該節點還有子節點的話,那麼遞迴進行判斷編譯 if (node.childNodes && node.childNodes.length) { this.compileElement(node); } }); }, // 元素節點編譯 compile: function(node) { // 獲取節點的所有屬性 var nodeAttrs = node.attributes; var self = this; /* 遍歷該節點的所有屬性,判斷該屬性是事件指令還是普通指令 */ [].slice.call(nodeAttrs).forEach(function(attr) { // 獲取屬性名 var attrName = attr.name; // 先判斷屬性名是否是 以 v- 開頭的 if (self.isDirective(attrName)) { var attrValue = attr.value; // 獲取 v-xx 中從xx開始的所有字串 var dir = attrName.substring(2); // 判斷是否是事件指令 if (self.isEventDirective(dir)) { compileUtil.eventHandler(node, self.$vm, attrValue, dir); } else { // 普通指令 compileUtil[dir] && compileUtil[dir](node, self.$vm, attrValue); } // 迴圈完成一次後 刪除該屬性 node.removeAttribute(attrName); } }); }, // 編譯文字 compileText: function(node, exp) { compileUtil.text(node, this.$vm, exp); }, // 是否是v- 開始的指令 isDirective: function(attrName) { return attrName.indexOf('v-') === 0; }, /* * 是否是事件指令 事件指令以 v-on開頭的 */ isEventDirective: function(dir) { return dir.indexOf('on') === 0; }, // 是否是元素節點 isElementNode: function(node) { return node.nodeType === 1; }, // 是否是文字節點 isTextNode: function(node) { return node.nodeType === 3; } }; // 指令處理 var compileUtil = { text: function(node, vm, exp) { this.bind(node, vm, exp, 'text'); }, html: function(node, vm, exp) { this.bind(node, vm, exp, 'html'); }, /* * 普通指令 v-model 開頭的,呼叫model方法 * @param {node} 容器節點 * @param {vm} 資料物件 * @param {exp} 普通指令的值 比如 v-model="xx" 那麼exp就等於xx */ model: function(node, vm, exp) { this.bind(node, vm, exp, 'model'); var self = this; var val = this.getVMVal(vm, exp); // 監聽input的事件 node.addEventListener('input', function(e) { var newValue = e.target.value; // 比較新舊值是否相同 if (val === newValue) { return; } // 重新設定新值 self.setVMVal(vm, exp, newValue); val = newValue; }); }, /* * 返回 v-mdoel = "xx" 中的xx的值, 比如data物件會定義如下: data: { "xx" : "111" } * @param {vm} 資料物件 * @param {exp} 普通指令的值 比如 v-model="xx" 那麼exp就等於xx */ getVMVal: function(vm, exp) { var val = vm; exp = exp.split('.'); exp.forEach(function(k) { val = val[k]; }); return val; }, /* 設定普通指令的值 @param {vm} 資料物件 @param {exp} 普通指令的值 比如 v-model="xx" 那麼exp就等於xx @param {value} 新值 */ setVMVal: function(vm, exp, value) { var val = vm; exp = exp.split('.'); exp.forEach(function(key, index) { // 如果不是最後一個元素的話,更新值 /* 資料物件 data 如下資料 data: { child: { someStr: 'World !' } }, 如果 v-model="child.someStr" 那麼 exp = ["child", "someStr"], 遍歷該陣列, val = val["child"]; val 先儲存該物件,然後再繼續遍歷 someStr,會執行else語句,因此val['someStr'] = value, 就會更新到物件的值了。 */ if (i < exp.length - 1) { val = val[key]; } else { val[key] = value; } }); }, class: function(node, vm, exp) { this.bind(node, vm, exp, 'class'); }, /* 事件處理 @param {node} 元素節點 @param {vm} 資料物件 @param {attrValue} attrValue 屬性值 @param {dir} 事件指令的值 比如 v-on:click="xx" 那麼dir就等 on:click */ eventHandler: function(node, vm, attrValue, dir) { // 獲取事件型別 比如dir=on:click 因此 eventType="click" var eventType = dir.split(':')[1]; /* * 獲取事件的函式 比如 v-on:click="clickBtn" 那麼就會對應vm裡面的clickBtn 函式。 * 比如 methods: { clickBtn: function(e) { console.log(11) } } */ var fn = vm.$options.methods && vm.$options.methods[attrValue]; if (eventType && fn) { // 如果有事件型別和函式的話,就繫結該事件 node.addEventListener(eventType, fn.bind(vm), false); } }, /* @param {node} 節點 @param {vm} 資料物件 @param {exp} 正則匹配的值 @param {dir} 字串型別 比如 'text', 'html' 'model' */ bind: function(node, vm, exp, dir) { // 獲取updater 物件內的對應的函式 var updaterFn = updater[dir + 'Updater']; // 如有該函式的話就執行該函式 引數node為節點,第二個引數為 指令的值。 比如 v-model = 'xx' 那麼返回的就是xx的值 updaterFn && updaterFn(node, this.getVMVal(vm, exp)); // 呼叫訂閱者watcher new Watcher(vm, exp, function(newValue, oldValue) { updaterFn && updaterFn(node, newValue, oldValue); }); } }; var updater = { textUpdater: function(node, value) { node.textContent = typeof value === 'undefined' ? '' : value; }, htmlUpdater: function(node, value) { node.innerHTML = typeof value === 'undefined' ? '' : value; }, classUpdater: function(node, newValue, oldValue) { var className = node.className; className = className.replace(oldValue, '').replace(/\s$/, ''); var space = className && String(newValue) ? ' ' : ''; node.className = className + space + newValue; }, modelUpdater: function(node, newValue) { node.value = typeof newValue === 'undefined' ? '' : newValue; } };
三: 實現Watcher
Watcher是訂閱者是作為 Observer和Compile之間通訊的橋樑。
相關程式碼如下:
/* * @param {vm} 資料物件 * @param {expOrFn} 屬性值 比如 v-model='xx' v-on:click='yy' v-text="tt" 中的 xx, yy, tt * @param {cb} 回撥函式 */ function Watcher(vm, expOrFn, cb) { this.vm = vm; this.expOrFn = expOrFn; this.cb = cb; this.depIds = {}; // expOrFn 是事件函式的話 if (typeof expOrFn === 'function') { this.getter = expOrFn; } else { this.getter = this.parseGetter(expOrFn); } // 為了觸發屬性getter,從而在dep新增自己作為訂閱者 this.value = this.get(); } Watcher.prototype = { update: function() { this.run(); // observer 中的屬性值發生變化 收到通知 }, run: function() { var value = this.get(); var oldValue = this.value; // 新舊值不相等的話 if (value !== oldValue) { // 把當前的值賦值給 this.value 更新this.value的值 this.value = value; this.cb.call(this.vm, value, oldValue); // 執行Compile中繫結的回撥 更新檢視 } }, get: function() { Dep.target = this; // 將當前訂閱者指向自己 var value = this.getter.call(this.vm, this.vm); // 觸發getter,新增自己到屬性訂閱器中 Dep.target = null; // 新增完成後 清空資料 return value; }, parseGetter: function(exp) { var reg = /[^\w.$]/; if (reg.test(exp)) { return; } var exps = exp.split('.'); return function(obj) { for(var i = 0, len = exps.length; i < len; i++) { if (!obj) { return; } obj = obj[exps[i]]; } return obj; } } }
四: 實現MVVM
MVVM是資料繫結的入口,整合 Observer, Compile, 和 Watcher,通過Observer來監聽model資料變化,通過Compile來解析編譯模板指令,最後使用watcher搭起Observer和Compile之間的通訊橋樑。 達到資料變化 --》檢視更新,檢視變化 --》 資料model更新的 雙向繫結效果。
程式碼如下:
function MVVM(options) { this.$options = options || {}; var data = this._data = this.$options.data; var self = this; /* 資料代理,實現 vm.xxx 上面的程式碼看出 監聽資料的物件是 options.data, 因此每次更新檢視的時候;如: var vm = new MVVM({ data: {name: 'kongzhi'} }); 那麼更新資料就變成 vm._data.name = 'kongzhi2'; 但是我們想實現這樣更改 vm.name = "kongzhi2"; 因此這邊需要使用屬性代理方式,利用Object.defineProperty()方法來劫持vm實列物件屬性的讀寫權,使讀寫vm實列的屬性轉成vm.name的屬性值。 */ Object.keys(data).forEach(function(key) { self._proxyData(key); }); this._initComputed(); // 初始化Observer observer(data, this); // 初始化 Compile this.$compile = new Compile(options.el || document.body, this); } MVVM.prototype = { $watch: function(key, cb, options) { new Watcher(this, key, cb); }, _proxyData: function(key) { var self = this; Object.defineProperty(self, key, { configurable: false, // 是否可以刪除或修改目標屬性 enumerable: true, // 是否可列舉 get: function proxyGetter() { return self._data[key]; }, set: function proxySetter(newVal) { self._data[key] = newVal; } }) }, _initComputed: function() { var self = this; var computed = this.$options.computed; if (typeof computed === 'object') { Object.keys(computed).forEach(function(key) { Object.defineProperty(self, key, { get: typeof computed[key] === 'function' ? computed[key] : computed[key].get, set: function() {} }) }) } } }
html程式碼初始化如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>MVVM</title> </head> <body> <div id="mvvm-app"> <input type="text" v-model="someStr"> <input type="text" v-model="child.someStr"> <p>{{getHelloWord}}</p> <p v-html="child.htmlStr"></p> <button v-on:click="clickBtn">change model</button> </div> <script src="./observer.js"></script> <script src="./watcher.js"></script> <script src="./compile.js"></script> <script src="./mvvm.js"></script> <script> var vm = new MVVM({ el: '#mvvm-app', data: { someStr: 'hello ', className: 'btn', htmlStr: '<span style="color: #f00;">red</span>', child: { someStr: 'World !' } }, computed: { getHelloWord: function() { return this.someStr + this.child.someStr; } }, methods: { clickBtn: function(e) { var randomStrArr = ['childOne', 'childTwo', 'childThree']; this.child.someStr = randomStrArr[parseInt(Math.random() * 3)]; } } }); vm.$watch('child.someStr', function() { console.log(arguments); }); </script> </body> </html>
程式碼分析:
1. 程式碼初始化執行
首先html程式碼如下:
<div id="mvvm-app"> <input type="text" v-model="someStr"> <input type="text" v-model="child.someStr"> <p>{{getHelloWord}}</p> <p v-html="child.htmlStr"></p> <button v-on:click="clickBtn">change model</button> </div>
資料呼叫如下:
var vm = new MVVM({ el: '#mvvm-app', data: { someStr: 'hello ', className: 'btn', htmlStr: '<span style="color: #f00;">red</span>', child: { someStr: 'World !' } }, computed: { getHelloWord: function() { return this.someStr + this.child.someStr; } }, methods: { clickBtn: function(e) { var randomStrArr = ['childOne', 'childTwo', 'childThree']; this.child.someStr = randomStrArr[parseInt(Math.random() * 3)]; } } }); vm.$watch('child.someStr', function() { console.log(arguments); });
執行 new MVVM 後 mvvm.js 實列化,mvvm.js 程式碼如下:
function MVVM(options) { this.$options = options || {}; var data = this._data = this.$options.data; var self = this; /* 資料代理,實現 vm.xxx 上面的程式碼看出 監聽資料的物件是 options.data, 因此每次更新檢視的時候;如: var vm = new MVVM({ data: {name: 'kongzhi'} }); 那麼更新資料就變成 vm._data.name = 'kongzhi2'; 但是我們想實現這樣更改 vm.name = "kongzhi2"; 因此這邊需要使用屬性代理方式,利用Object.defineProperty()方法來劫持vm實列物件屬性的讀寫權,使讀寫vm實列的屬性轉成vm.name的屬性值。 */ Object.keys(data).forEach(function(key) { self._proxyData(key); }); this._initComputed(); // 初始化Observer observer(data, this); // 初始化 Compile this.$compile = new Compile(options.el || document.body, this); }
因此 引數options值為物件(Object):
this.$options = { el: '#mvvm-app', data: { someStr: 'hello ', className: 'btn', htmlStr: '<span style="color: #f00;">red</span>', child: { someStr: 'World !' } }, computed: { getHelloWord: function() { return this.someStr + this.child.someStr; } }, methods: { clickBtn: function(e) { var randomStrArr = ['childOne', 'childTwo', 'childThree']; this.child.someStr = randomStrArr[parseInt(Math.random() * 3)]; } } }
var data = this._data = this.$options.data = { someStr: 'hello ', className: 'btn', htmlStr: '<span style="color: #f00;">red</span>', child: { someStr: 'World !' } }
然後 遍歷data物件,如下:
Object.keys(data).forEach(function(key) { self._proxyData(key); });
_proxyData 方法程式碼如下:
_proxyData: function(key) { var self = this; Object.defineProperty(self, key, { configurable: false, // 是否可以刪除或修改目標屬性 enumerable: true, // 是否可列舉 get: function proxyGetter() { return self._data[key]; }, set: function proxySetter(newVal) { self._data[key] = newVal; } }) }
使用Object.defineProperty來監聽物件屬性的變化,使vm實列的屬性轉變成vm._data的屬性值。
接著初始化 this._initComputed();方法;程式碼如下:
_initComputed: function() { var self = this; var computed = this.$options.computed; if (typeof computed === 'object') { Object.keys(computed).forEach(function(key) { Object.defineProperty(self, key, { get: typeof computed[key] === 'function' ? computed[key] : computed[key].get, set: function() {} }) }) } }
首先判斷 computed 是否為物件,然後遍歷computed,判斷如果computed[key] 鍵是否是一個函式,如果是一個函式的話,就執行該函式,否則的話,就執行該get呼叫。所以我們看到使用vue的
時候會看到 computed方法初始時呼叫。
然後 初始化Observer
observer(data, this);
因此會例項化 Observer;程式碼如下:
function Observer(data) { this.data = data; this.init(); }
因此 this.data值為:
this.data = { someStr: 'hello ', className: 'btn', htmlStr: '<span style="color: #f00;">red</span>', child: { someStr: 'World !' } }
然後呼叫init方法,如下程式碼:
init: function() { var self = this; var data = self.data; // 遍歷data物件 Object.keys(data).forEach(function(key) { self.defineReactive(data, key, data[key]); }); },
遍歷data,獲取每一個key,也就是 key 可以為 someStr, className, htmlStr, child等。
然後執行 self.defineReactive 方法,引數如下三個
data = { someStr: 'hello ', className: 'btn', htmlStr: '<span style="color: #f00;">red</span>', child: { someStr: 'World !' } }
key 分別為 someStr, className, htmlStr, child,
data[key]值分別為:
'hello ', 'btn', '<span style="color: #f00;">red</span>', 和 { someStr: 'World !' }
呼叫 defineReactive 程式碼如下:
defineReactive: function(data, key, value) { var dep = new Dep(); // 遞迴遍歷子物件 var childObj = observer(value); // 對物件的屬性使用Object.defineProperty進行監聽 Object.defineProperty(data, key, { enumerable: true, // 可列舉 configurable: false, // 不能刪除目標屬性或不能修改目標屬性 get: function() { if (Dep.target) { dep.depend(); } return value; }, set: function(newVal) { if (newVal === value) { return; } value = newVal; // 如果新值是物件的話,遞迴該物件 進行監聽 childObj = observer(newVal); // 通知訂閱者 dep.notify(); } }); }
同理 使用 Object.defineProperty 監聽物件屬性值的變化。執行完成後,下一步程式碼執行如下:
// 初始化 Compile
this.$compile = new Compile(options.el || document.body, this);
options.el引數就是傳進去的Id為 #mvvm-app, 如果沒有的話,就是 document.body;
compile實列化程式碼如下:
/* * @param {el} 元素節點容器 如:el: '#mvvm-app' * @param {vm} Object 物件傳遞資料 */ function Compile(el, vm) { this.$vm = vm; // 判斷是元素節點 還是 選擇器 this.$el = this.isElementNode(el) ? el : document.querySelector(el); if (this.$el) { /* 因為遍歷解析過程有多次操作dom節點,為了提高效能和效率,會先將根節點el轉換成文件碎片fragment進行解析編譯操作, 解析完成後,再將fragment新增回原來真實的dom節點中。有關 DocumentFragment 請看 http://www.cnblogs.com/tugenhua0707/p/7465915.html */ this.$fragment = this.node2Fragment(this.$el); this.init(); this.$el.appendChild(this.$fragment); } }
呼叫init方法會進行compileElement 執行解析編譯操作,找到id為#mvvm-app的childNodes, 遍歷子節點首先會判斷是元素節點還是文字節點,如果是元素節點會呼叫 該如下方法:
this.compile(node); 進行元素節點編譯和解析操作, 如果是文字節點的話,就會 呼叫 this.compileText(node, RegExp.$1); 方法編譯文字節點。
理清思路:
檢視 --> 模型的改變:
在輸入框輸入新值的時,會在 compile.js裡面會監聽input事件,如下程式碼:
node.addEventListener('input', function(e) { var newValue = e.target.value; if (val === newValue) { return; } self.setVMVal(vm, attrValue, newValue); val = newValue; });
首先會獲取當前的值,然後執行 setVMVal該方法,如下程式碼:
/* 設定普通指令的值 @param {vm} 資料物件 @param {exp} 普通指令的值 比如 v-model="xx" 那麼exp就等於xx @param {value} 新值 */ setVMVal: function(vm, exp, value) { var val = vm; exp = exp.split('.'); exp.forEach(function(key, index) { // 如果不是最後一個元素的話,更新值 /* 資料物件 data 如下資料 data: { child: { someStr: 'World !' } }, 如果 v-model="child.someStr" 那麼 exp = ["child", "someStr"], 遍歷該陣列, val = val["child"]; val 先儲存該物件,然後再繼續遍歷 someStr,會執行else語句,因此val['someStr'] = value, 就會更新到物件的值了。 */ if (index < exp.length - 1) { val = val[key]; } else { val[key] = value; } }); }
執行完setVMVal方法後,會拿到最新值,然後會呼叫 Object.defineProperty 裡面的setter方法,如下程式碼:
set: function(newVal) { if (newVal === value) { return; } value = newVal; // 釋出訊息 dep.public(); }
從而釋出訊息, 執行方法 dep.public(); 因此會呼叫Dep裡面的public方法,程式碼如下:
public: function() { this.subs.forEach(function(sub){ sub.update(); }); }
從而呼叫 watcher.js 裡面的update方法,程式碼如下:
update: function() { this.run(); },
接著會呼叫run方法,程式碼如下:
run: function() { var value = this.get(); var oldVal = this.value; if (value !== oldVal) { this.value = value; this.cb.call(this.vm, value, oldVal); } },
先呼叫get方法拿到當前的值,程式碼如下:
get: function() { Dep.target = this; var value = this.getter.call(this.vm, this.vm); Dep.target = null; return value; },
如上程式碼,會呼叫 this.getter方法,this.getter方法初始化程式碼在watcher如下:
// expOrFn 是事件函式的話 if (typeof expOrFn === 'function') { this.getter = expOrFn; } else { this.getter = this.parseGetter(expOrFn); }
接著呼叫 parseGetter 方法程式碼:
parseGetter: function(exp) { if (/[^\w.$]/.test(exp)) return; var exps = exp.split('.'); return function(obj) { for (var i = 0, len = exps.length; i < len; i++) { if (!obj) return; obj = obj[exps[i]]; } return obj; } }
用完成後,會執行到get方法裡面程式碼 var value = this.getter.call(this.vm, this.vm); 因此會繼續呼叫到 parseGetter 返回函式的程式碼,所以最後返回新值。
返回新值後,在執行函式run方法後,判斷新舊值是否相等,不相等的話,就會執行 compile.js裡面bind的方法的回撥,如下程式碼:
new Watcher(vm, attrValue, function(value, oldValue) { updaterFn && updaterFn(node, value, oldValue); });
compile.js裡面的updater物件如下:
var updater = { textUpdater: function(node, value) { node.textContent = typeof value === 'undefined' ? '' : value; }, htmlUpdater: function(node, value) { node.innerHTML = typeof value === 'undefined' ? '' : value; }, classUpdater: function(node, newValue, oldValue) { var className = node.className; className = className.replace(oldValue, '').replace(/\s$/, ''); var space = className && String(newValue) ? ' ' : ''; node.className = className + space + newValue; }, modelUpdater: function(node, newValue) { node.value = typeof newValue === 'undefined' ? '' : newValue; } };
從而更新檢視。
模型 -> 檢視的改變
當頁面初始化時,會判斷是普通指令 v-model, v-text, or v-html,還是事件指令 v-on:click; compile.js相對應的程式碼如下:
// 判斷是否是事件指令 if (self.isEventDirective(dir)) { // 事件指令 比如 v-on:click 這樣的 compileUtil.eventHandler(node, self.$vm, attrValue, dir); }
所以會對事件進行處理: 如下函式:
// 事件處理 eventHandler: function(node, vm, attrValue, dir) { var eventType = dir.split(':')[1], fn = vm.$options.methods && vm.$options.methods[attrValue]; if (eventType && fn) { node.addEventListener(eventType, fn.bind(vm), false); } }
因此會對該節點進行繫結 'click'事件,程式碼 node.addEventListener(eventType, fn.bind(vm), false); 就是對繫結的事件 進行監聽,demo裡面是使用 v-on:click, 因此eventType就是click事件,因此會對 點選事件 進行監聽。然後執行相對應的回撥函式。該demo中的回撥函式為 clickBtn(函式名)。
clickBtn函式程式碼如下:
clickBtn: function(e) { var randomStrArr = ['childOne', 'childTwo', 'childThree']; this.child.someStr = randomStrArr[parseInt(Math.random() * 3)]; }
因此 this.child.someStr 會重新賦值一個隨機數,也就是說值會得到更新,因此首先會呼叫 mvvm.js的_proxyData中的get方法;程式碼如下:
_proxyData: function(key, setter, getter) { var me = this; setter = setter || Object.defineProperty(me, key, { configurable: false, enumerable: true, get: function proxyGetter() { return me._data[key]; }, set: function proxySetter(newVal) { me._data[key] = newVal; } }); }
頁面初始化的時候,vm.data.child.someStr 會被mvvm.js 裡面的 代理方法轉換成 vm.child.someStr, 因此給 vm.child.someStr 設定新值的時候,會呼叫 Object.defineProperty方法來監聽屬性值的改變,因此需要返回一個新值,所以先呼叫mvvm.js中的get方法,之後會呼叫 Observer.js程式碼 的Object.defineProperty(obj, key, {})中的get方法,返回頁面初始化的值,然後會呼叫Observer.js該對應的set方法,獲取新值,然後判斷新舊值是否相等,如果不相等的話,就會把訊息釋出出去該訂閱者,訂閱者會接收該訊息。從而和上面 檢視 -》模型 步驟一樣 渲染檢視頁面。
git程式碼