上一篇 文章 瞭解了Vue.js的生命週期。這篇分析Observe Data過程,瞭解Vue.js的雙向數據綁定實現原理。javascript
前端MVVM最使人激動的就是雙向綁定機制了,實現雙向數據綁定的作法大體有以下三種:html
思路:使用自定義的data屬性在HTML代碼中指明綁定。全部綁定起來的JavaScript對象以及DOM元素都將「訂閱」一個發佈者對象。任什麼時候候若是JavaScript對象或者一個HTML輸入字段被偵測到發生了變化,咱們將代理事件到發佈者-訂閱者模式,這會反過來將變化廣播並傳播到全部綁定的對象和元素。前端
思路:angular.js 是經過髒值檢測的方式比對數據是否有變動,來決定是否更新視圖,最簡單的方式就是經過
setInterval()
定時輪詢檢測數據變更,angular只有在指定的事件觸發時進入髒值檢測,大體以下:vue
DOM事件,譬如用戶輸入文本,點擊按鈕等。( ng-click )java
XHR響應事件 ( $http )node
瀏覽器Location變動事件 ( $location )git
Timer事件( $timeout , $interval )github
執行 $digest() 或 $apply()segmentfault
思路: vue.js 則是採用數據劫持結合發佈者-訂閱者模式的方式,經過
Object.defineProperty()
來劫持各個屬性的setter
,getter
,在數據變更時發佈消息給訂閱者,觸發相應的監聽回調。瀏覽器
因而可知,Object.defineProperty() 這個API是Vue實現雙向數據綁定的關鍵,咱們先簡單瞭解下這個API,瞭解更多戳這裏
簡單例子:
var obj = {}; Object.defineProperty(obj, 'hello', { get: function() { console.log('get val:'+ val); return val; }, set: function(newVal) { val = newVal; console.log('set val:'+ val); } });
obj.hello='111';
obj.hello;
結果:
若是去掉 obj.hello=‘111’ 這行代碼,則get的返回值val會報錯val is not defined。可見Object.defineProperty() 監控對數據的操做,能夠自動觸發數據同步。下面咱們先用Object.defineProperty()來實現一個很是簡單的雙向綁定。
最簡單例子:
<!DOCTYPE html> <head></head> <body> <div id="app"> <input type="text" id="a"> <span id="b"></span> </div> <script type="text/javascript"> var obj = {}; Object.defineProperty(obj, 'hello', { get: function() { console.log('get val:'+ val); return val; }, set: function(newVal) { val = newVal; console.log('set val:'+ val); document.getElementById('a').value = val; document.getElementById('b').innerHTML = val; } }); document.addEventListener('keyup', function(e) { obj.hello = e.target.value; }); </script> </body> </html>
實現效果以下:
上面例子直接用了dom操做改變了文本節點的值,並且是在咱們知道是哪一個id的狀況下,經過document.getElementById 獲取到相應的文本節點,而後直接修改文本節點的值,這種作法是最簡單粗暴的。
封裝成一個框架,確定不能是這種作法,因此咱們須要一個解析dom,並能修改dom中相應的變量的模塊。
首先咱們須要獲取文本中真實的dom節點,而後再分析節點的類型,根據節點類型作相應的處理。
在上面例子咱們屢次操做了dom節點,爲提升性能和效率,會先將全部的節點轉換城文檔碎片fragment進行編譯操做,解析操做完成後,再將fragment添加到原來的真實dom節點中。
<!DOCTYPE html> <head></head> <body> <div id="app"> <input type="text" id="a" v-model="text"> {{text}} </div> <script type="text/javascript"> function Compile(node, vm) { if(node) {this.$frag = this.nodeToFragment(node, vm); return this.$frag; } } Compile.prototype = { nodeToFragment: function(node, vm) { var self = this; var frag = document.createDocumentFragment(); var child; while(child = node.firstChild) { self.compileElement(child, vm); frag.append(child); // 將全部子節點添加到fragment中,child是指向元素首個子節點的引用。將child引用指向的對象append到父對象的末尾,原來child引用的對象就跳到了frag對象的末尾,而child就指向了原本是排在第二個的元素對象。如此循環下去,連接就逐個日後跳了 } return frag; }, compileElement: function(node, vm) { var reg = /\{\{(.*)\}\}/; //節點類型爲元素 if(node.nodeType === 1) { var attr = node.attributes; // 解析屬性 for(var i = 0; i < attr.length; i++ ) { if(attr[i].nodeName == 'v-model') { var name = attr[i].nodeValue; // 獲取v-model綁定的屬性名 node.addEventListener('input', function(e) { // 給相應的data屬性賦值,進而觸發該屬性的set方法 vm.data[name]= e.target.value; }); node.value = vm.data[name]; // 將data的值賦給該node node.removeAttribute('v-model'); } }; } //節點類型爲text
if(node.nodeType === 3) { if(reg.test(node.nodeValue)) { var name = RegExp.$1; // 獲取匹配到的字符串 name = name.trim(); node.nodeValue = vm.data[name]; // 將data的值賦給該node } } }, } function Vue(options) { this.data = options.data; var data = this.data; var id = options.el; var dom =new Compile(document.getElementById(id),this); // 編譯完成後,將dom返回到app中 document.getElementById(id).appendChild(dom); } var vm = new Vue({ el: 'app', data: { text: 'hello world' } }); </script> </body> </html>
結果:
到這,咱們作到了獲取文本中真實的dom節點,而後分析節點的類型,並能處理節點中相應的變量如上面代碼中的{{text}},最後渲染到頁面中。接着咱們須要和雙向綁定聯繫起來,實現{{text}}響應式的數據綁定。
簡單的observe定義以下:
須要監控data的屬性值,這個對象的某個值賦值,就會觸發setter
,這樣就能監聽到數據變化。而後注意vm.data[name]屬性將改成vm[name]
完整代碼以下:
<!DOCTYPE html> <head></head> <body> <div id="app"> <input type="text" id="a" v-model="text"> {{text}} </div> <script type="text/javascript"> function Compile(node, vm) { if(node) { this.$frag = this.nodeToFragment(node, vm); return this.$frag; } } Compile.prototype = { nodeToFragment: function(node, vm) { var self = this; var frag = document.createDocumentFragment(); var child; while(child = node.firstChild) { self.compileElement(child, vm); frag.append(child); // 將全部子節點添加到fragment中 } return frag; }, compileElement: function(node, vm) { var reg = /\{\{(.*)\}\}/; //節點類型爲元素 if(node.nodeType === 1) { var attr = node.attributes; // 解析屬性 for(var i = 0; i < attr.length; i++ ) { if(attr[i].nodeName == 'v-model') { var name = attr[i].nodeValue; // 獲取v-model綁定的屬性名 node.addEventListener('input', function(e) { // 給相應的data屬性賦值,進而觸發該屬性的set方法 vm[name]= e.target.value; }); node.value = vm[name]; // 將data的值賦給該node node.removeAttribute('v-model'); } }; } //節點類型爲text if(node.nodeType === 3) { if(reg.test(node.nodeValue)) { var name = RegExp.$1; // 獲取匹配到的字符串 name = name.trim(); node.nodeValue = vm[name]; // 將data的值賦給該node // new Watcher(vm, node, name); } } }, } function defineReactive (obj, key, val) { Object.defineProperty(obj, key, { get: function() { return val; }, set: function (newVal) { if(newVal === val) return; val = newVal; console.log(val); } }) } function observe(obj, vm) { Object.keys(obj).forEach(function(key) { defineReactive(vm, key, obj[key]); }) } function Vue(options) { this.data = options.data; var data = this.data; observe(data, this); var id = options.el; var dom =new Compile(document.getElementById(id),this); // 編譯完成後,將dom返回到app中 document.getElementById(id).appendChild(dom); } var vm = new Vue({ el: 'app', data: { text: 'hello world' } }); </script> </body> </html>
結果:
到這,雖然set方法觸發了,可是文本節點{{text}}的內容沒有變化,要讓綁定的文本節點同步變化,咱們須要引入訂閱發佈模式。
訂閱發佈模式(又稱觀察者模式)定義了一種一對多的關係,讓多個觀察者同時監聽某一個主題對象,這個主題對象的狀態發生改變時就會通知全部觀察者對象。
發佈者發出通知 => 主題對象收到通知並推送給訂閱者 => 訂閱者執行相應操做
首先咱們要一個收集訂閱者的容器,定義一個Dep做爲主題對象
而後定義訂閱者Watcher
添加訂閱者Watcher到主題對象Dep,發佈者發出通知放到屬性監聽裏面
最後須要訂閱的地方
至此,比較簡單地實現了咱們第三步用dom操做實現的雙向綁定效果,代碼:
<!DOCTYPE html> <head></head> <body> <div id="app"> <input type="text" id="a" v-model="text"> {{text}} </div> <script type="text/javascript"> function Compile(node, vm) { if(node) { this.$frag = this.nodeToFragment(node, vm); return this.$frag; } } Compile.prototype = { nodeToFragment: function(node, vm) { var self = this; var frag = document.createDocumentFragment(); var child; while(child = node.firstChild) { self.compileElement(child, vm); frag.append(child); // 將全部子節點添加到fragment中 } return frag; }, compileElement: function(node, vm) { var reg = /\{\{(.*)\}\}/; //節點類型爲元素 if(node.nodeType === 1) { var attr = node.attributes; // 解析屬性 for(var i = 0; i < attr.length; i++ ) { if(attr[i].nodeName == 'v-model') { var name = attr[i].nodeValue; // 獲取v-model綁定的屬性名 node.addEventListener('input', function(e) { // 給相應的data屬性賦值,進而觸發該屬性的set方法 vm[name]= e.target.value; }); // node.value = vm[name]; // 將data的值賦給該node new Watcher(vm, node, name, 'value'); } }; } //節點類型爲text if(node.nodeType === 3) { if(reg.test(node.nodeValue)) { var name = RegExp.$1; // 獲取匹配到的字符串 name = name.trim(); // node.nodeValue = vm[name]; // 將data的值賦給該node new Watcher(vm, node, name, 'nodeValue'); } } }, } function Dep() { this.subs = []; } Dep.prototype = { addSub: function(sub) { this.subs.push(sub); }, notify: function() { this.subs.forEach(function(sub) { sub.update(); }) } } function Watcher(vm, node, name, type) { Dep.target = this; this.name = name; this.node = node; this.vm = vm; this.type = type; this.update(); Dep.target = null; } Watcher.prototype = { update: function() { this.get(); this.node[this.type] = this.value; // 訂閱者執行相應操做 }, // 獲取data的屬性值 get: function() { this.value = this.vm[this.name]; //觸發相應屬性的get } } function defineReactive (obj, key, val) { var dep = new Dep(); Object.defineProperty(obj, key, { get: function() { //添加訂閱者watcher到主題對象Dep if(Dep.target) { // JS的瀏覽器單線程特性,保證這個全局變量在同一時間內,只會有同一個監聽器使用 dep.addSub(Dep.target); } return val; }, set: function (newVal) { if(newVal === val) return; val = newVal; console.log(val); // 做爲發佈者發出通知 dep.notify(); } }) } function observe(obj, vm) { Object.keys(obj).forEach(function(key) { defineReactive(vm, key, obj[key]); }) } function Vue(options) { this.data = options.data; var data = this.data; observe(data, this); var id = options.el; var dom =new Compile(document.getElementById(id),this); // 編譯完成後,將dom返回到app中 document.getElementById(id).appendChild(dom); } var vm = new Vue({ el: 'app', data: { text: 'hello world' } }); </script> </body> </html>
關於雙向綁定的實現,看了網上不少資料,開始看到是對Vue源碼的解析,看的過程似懂非懂。後來找到參考資料1,而後本身跟着實現一遍,才理解許多。感謝這篇文章的做者,寫的由淺入深,比較好理解。爲了加深本身的理解,因而本身順着這個思路寫下這個筆記。本文主要了解了幾種雙向綁定的作法,而後先用原生JS,dom操做實現一個最簡單雙向綁定,在這個基礎上進行改裝,爲減小dom操做,實現簡單的Compile(編譯HTML);接着爲了實現數據監聽,實現observe;最後爲了實現數據的雙向綁定實現訂閱發佈模式。
雖然實現的比較簡單,有不少功能沒有考慮,不過這個過程仍是能夠理解到Vue實現雙向綁定的原理。過程當中,有思考:
1. Vue的源代碼中,用了文檔碎片fragment做爲真實節點的存儲嗎?
以前有據說用VDOM,在Vue源代碼中,也找過是否有建立文檔碎片,結果沒找到。看了參考資料4中,VDOM的介紹,好像是把節點用JS對象模擬。相似:
;模板 <ul id='list'> <li class='item'>Item 1</li> <li class='item'>Item 2</li> <li class='item'>Item 3</li> </ul> ;js對象 var element = { tagName: 'ul', // 節點標籤名 props: { // DOM的屬性,用一個對象存儲鍵值對 id: 'list' }, children: [ // 該節點的子節點 {tagName: 'li', props: {class: 'item'}, children: ["Item 1"]}, {tagName: 'li', props: {class: 'item'}, children: ["Item 2"]}, {tagName: 'li', props: {class: 'item'}, children: ["Item 3"]}, ] }
恩,這就又牽扯出模板了。先收住,我先儘可能把簡單的搞懂。
2.Compile模塊對v-model節點的解析,事件的綁定,我只實現簡單的,特定的v-model,還有其它事件綁定如v-on等沒有分析,看了別人的代碼,狀況一多起來,看得就有些吃力,但願後面本身會再來完善,給本身定一個這樣的框架在這,代碼github:戳這裏 。
參考資料:
1.http://www.cnblogs.com/kidney/p/6052935.html?utm_source=gold_browser_extension
2.http://www.javashuo.com/article/p-brudryzf-hq.html
3.https://github.com/fwing1987/MyVue
4.http://www.kancloud.cn/zmwtp/vue2/149485
5.http://blog.cgsdream.org/2016/11/05/vue-source-analysis-1/