Vue.js最核心的功能有兩個,一個是響應式的數據綁定系統,另外一個是組件系統。本文僅僅探究雙向綁定是怎樣實現的。先講涉及的知識點,再用簡化的代碼實現一個簡單的hello world示例。html
1、訪問器屬性node
訪問器屬性是對象中的一種特殊屬性,它不能直接在對象中設置,而必須經過defineProperty()方法單獨定義。git
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title></title> </head> <body> <script> var obj = {}; Object.defineProperty(obj, 'hello', { get: function() { console.log('get方法被調用了'); }, set: function(val) { console.log('set方法被調用了,參數是' + val); } }); obj.hello; //get方法被調用了 obj.hello = 'abc'; //set方法被調用了,參數是abc </script> </body> </html>
get和set方法內部的this都指向obj,這意味着get和set函數能夠操做對象內部的值。另外,訪問器屬性的會「覆蓋」同名的普通屬性,由於訪問器屬性會被優先訪問,與其同名的普通屬性則會被忽略。github
2、極簡的雙向綁定實現segmentfault
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title></title> </head> <body> <input type="text" id="a" /> <span id="b"></span> <script> var obj = {}; Object.defineProperty(obj, 'hello', { set: function(newval) { document.getElementById('a').value = newval; document.getElementById('b').innerHTML = newval } }); document.addEventListener('keyup', function(e) { obj.hello = e.target.value; }) </script> </body> </html>
此例實現的效果是:隨着文本框輸入文字的變化,span中會同步顯示相同的內容。在js或者在控制檯上顯式的修改obj.hello的值,視圖會相應的更新。這樣就實現了model=>view以及view=>model的雙向綁定。瀏覽器
以上就是Vue實現雙向綁定的基本原理。app
3、分解任務dom
上述示例僅僅是爲了說明原理,咱們最終要實現的是:ide
<div id="app"> <input type="text" v-model="text"> {{ text }} </div> var vm = new Vue({ el:'#app', data:{ text:'hello world' } })
首先將該任務分紅幾個子任務:函數
一、輸入框以及文本節點與data中的數據綁定;
二、輸入框內容變化時,data中的數據同步變化,即view =>model的變化;
三、data中的數據變化時,文本節點的內容同步變化,即model =>view的變化;
要實現任務1,須要對DOM進行編譯,這裏有一個知識點:DocumentFragment。
4、DocumentFragment
DocumentFragment(文檔片斷)能夠看作節點容器,它能夠包含多個子節點,當咱們將它插入到DOM中時,只有它的子節點會插入目標節點,因此把它看做一組節點的容器。使用DocumentFragment處理節點,速度和性能遠遠優於直接操做DOM。Vue進行編譯時,就是將掛載目標的全部子節點劫持(真的是劫持,經過append方法,DOM中的節點會被自動刪除)到DocumentFragment中,通過一番處理後,再將DocumentFragment總體返回插入掛載目標。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title></title> </head> <body> <div id="app"> <input type="text" id="a" /> <span id="b"></span> </div> <script> var dom = nodeToFragment(document.getElementById('app')); console.log(dom); function nodeToFragment(node) { var flag = document.createDocumentFragment(); var child; while(child == node.firstChild) { flag.appendChild(child); //劫持node的全部子節點 } return flag; } document.getElementById('app').appendChild(dom); //返回到app中 </script> </body> </html>
5、數據初始化綁定
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Two-way-data-binding</title> </head> <body> <div id="app"> <input type="text" v-model="text"> {{ text }} </div> <script> function compile(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.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.data[name]; //將data的值賦給該node } } } function nodeToFragment(node, vm) { var flag = document.createDocumentFragment(); var child; // 全部表達式必然會返回一個值,賦值表達式亦不例外 // 理解了上面這一點,就能理解 while (child = node.firstChild) 這種用法 // 其次,appendChild 方法有個隱蔽的地方,就是調用之後 child 會從原來 DOM 中移除 // 因此,第二次循環時,node.firstChild 已經再也不是以前的第一個子元素了 while(child = node.firstChild) { compile(child, vm); flag.appendChild(child); // 將子節點劫持到文檔片斷中 } return flag; } function Vue(options) { this.data = options.data; var id = options.el; var dom = nodeToFragment(document.getElementById(id), this); // 編譯完成後,將 dom 返回到 app 中 document.getElementById(id).appendChild(dom); } var vm = new Vue({ el: 'app', data: { text: 'hello world' } }); </script> </body> </html>
以上代碼實現了任務一,咱們能夠看到,hello world已經呈如今輸入框和文本節點中。
6、響應式的數據綁定
再來看任務2的是實現思路:當咱們在輸入框輸入數據的時候,首先觸發input事件或者keyup、change事件,在相應的事件處理程序中,咱們獲取輸入框的value並賦值給vm實例的text屬性。咱們會利用defineProperty將data中的text設置爲vm的訪問器屬性,所以給vm.text賦值就會觸發set方法。在set方法中主要作兩件事,第一是更新屬性的值,第二留到任務3來講。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Two-way-data-binding</title> </head> <body> <div id="app"> <input type="text" v-model="text"> {{ text }} </div> <script> function observe(obj, vm) { Object.keys(obj).forEach(function(key) { defineReactive(vm, key, obj[key]); }) } 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 nodeToFragment(node, vm) { var flag = document.createDocumentFragment(); var child; // 全部表達式必然會返回一個值,賦值表達式亦不例外 // 理解了上面這一點,就能理解 while (child = node.firstChild) 這種用法 // 其次,appendChild 方法有個隱蔽的地方,就是調用之後 child 會從原來 DOM 中移除 // 因此,第二次循環時,node.firstChild 已經再也不是以前的第一個子元素了 while(child = node.firstChild) { compile(child, vm); flag.appendChild(child); // 將子節點劫持到文檔片斷中 } return flag; } function compile(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 } } } function Vue(options) { this.data = options.data; var data = this.data; observe(data, this); var id = options.el; var dom = nodeToFragment(document.getElementById(id), this); // 編譯完成後,將 dom 返回到 app 中 document.getElementById(id).appendChild(dom); } var vm = new Vue({ el: 'app', data: { text: 'hello world' } }); </script> </body> </html>
任務2也就完成了,text屬性值會與輸入框的內容同步變化(打開瀏覽器後臺進行查看)。
7、訂閱/發佈模式(subscribe&publish)
text屬性變化了,set方法觸發了,可是文本節點的內容沒有變化。如何讓一樣綁定到text的文本節點也同步變化呢?這裏又有一個知識點:訂閱發佈模式。
訂閱發佈模式(又稱觀察者模式)定義了一種一對多的關係,讓多個觀察者同時監聽某一個主題對象,這個主題對象的狀態發生改變時就會通知全部觀察者對象。發佈者發出通知 =>主題對象收到通知並推送給訂閱者 =>訂閱者執行相應操做。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Two-way-data-binding</title> </head> <body> <script> //一個發佈者publisher var pub = { publish: function() { dep.notify(); } } //三個訂閱者subscribers var sub1 = { update: function() { console.log(1) } }; var sub2 = { update: function() { console.log(2) } }; var sub3 = { update: function() { console.log(3) } }; //一個主題對象 function Dep() { this.subs = [sub1, sub2, sub3]; } Dep.prototype.notify = function() { this.subs.forEach(function(sub) { sub.update(); }) } //發佈者發佈消息,主題對象執行notify方法,進而觸發訂閱者執行update方法 var dep = new Dep(); pub.publish(); //1,2,3 </script> </body> </html>
以前提到的,當set方法觸發後作的第二件事就是做爲發佈者發出通知:「我是屬性text,我變了」。文本節點則是做爲訂閱者,在收到消息後執行相應的更新操做。
8、雙向綁定的實現
回顧一下,每當 new 一個 Vue,主要作了兩件事:第一個是監聽數據:observe(data),第二個是編譯 HTML:nodeToFragement(id)。
在監聽數據的過程當中,會爲data中的每個屬性生成一個主題對象dep。
在編譯HTML的過程當中,會爲每一個與數據綁定相關的節點生成一個訂閱者watcher,watcher會將本身添加到相應屬性的dep中。
咱們已經實現:修改輸入框內容 =>在事件回調函數中修改屬性值 =>觸發屬性的set方法。接下來咱們要實現的是:發出通知dep.notify() =>觸發訂閱者的update方法 =>更新視圖。
這裏的關鍵邏輯時:如何將watcher添加到關聯屬性的dep中。
function compile(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'); } }; new Watcher(vm, node, name, 'input'); } // 節點類型爲 text if(node.nodeType === 3) { if(reg.test(node.nodeValue)) { var name = RegExp.$1; // 獲取匹配到的字符串 name = name.trim(); new Watcher(vm, node, name, 'text'); } } }
在編譯HTML過程當中,爲每一個與data關聯的節點生成一個watcher,watcher函數中發生了什麼呢?
function Watcher(vm, node, name, nodeType) {
Dep.target = this;
this.name = name;
this.node = node;
this.vm = vm;
this.update();
Dep.target = null;
}
Watcher.prototype = {
update: function() {
this.get();
this.node.nodeValue = this.value;
},
// 獲取 data 中的屬性值
get: function() {
this.value = this.vm[this.name]; // 觸發相應屬性的 get
}
}
首先,將本身賦給了一個全局變量Dep.target;
其次,執行了update方法,進而執行了get方法,get的方法讀取了vm的訪問器屬性,從而觸發了訪問器屬性的get方法,get方法中將該watcher添加到了對應訪問器屬性的dep中;
接着,獲取屬性的值,而後更新視圖。
最後,將Dep.target設爲空,由於它是全局變量,也是watcher與dep關聯的惟一橋樑,任什麼時候刻都必須保證Dep.target只有一個值。
function defineReactive(obj, key, val) {
var dep = new Dep();
Object.defineProperty(obj, key, {
get: function() {
// 添加訂閱者 watcher 到主題對象 Dep
if(Dep.target) dep.addSub(Dep.target);
return val;
},
set: function(newVal) {
if(newVal === val) return
val = newVal;
// 做爲發佈者發出通知
dep.notify();
}
});
}
function Dep() {
this.subs = []
}
Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
});
}
}
至此,hello world雙向綁定就基本實現了。文本內容會隨輸入框內容同步變化,在控制器中修改vm.text的值,會同步反映到文本內容中。如下是完整代碼:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Two-way-data-binding</title> </head> <body> <div id="app"> <input type="text" v-model="text"> {{ text }} </div> <script> function observe(obj, vm) { Object.keys(obj).forEach(function(key) { defineReactive(vm, key, obj[key]); }) } function defineReactive(obj, key, val) { var dep = new Dep(); Object.defineProperty(obj, key, { get: function() { // 添加訂閱者 watcher 到主題對象 Dep if(Dep.target) dep.addSub(Dep.target); return val }, set: function(newVal) { if(newVal === val) return val = newVal; // 做爲發佈者發出通知 dep.notify(); } }); } function nodeToFragment(node, vm) { var flag = document.createDocumentFragment(); var child; // 全部表達式必然會返回一個值,賦值表達式亦不例外 // 理解了上面這一點,就能理解 while (child = node.firstChild) 這種用法 // 其次,appendChild 方法有個隱蔽的地方,就是調用之後 child 會從原來 DOM 中移除 // 因此,第二次循環時,node.firstChild 已經再也不是以前的第一個子元素了 while(child = node.firstChild) { compile(child, vm); flag.appendChild(child); // 將子節點劫持到文檔片斷中 } return flag } function compile(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'); } }; new Watcher(vm, node, name, 'input'); } // 節點類型爲 text if(node.nodeType === 3) { if(reg.test(node.nodeValue)) { var name = RegExp.$1; // 獲取匹配到的字符串 name = name.trim(); new Watcher(vm, node, name, 'text'); } } } function Watcher(vm, node, name, nodeType) { Dep.target = this; this.name = name; this.node = node; this.vm = vm; this.nodeType = nodeType; this.update(); Dep.target = null; } Watcher.prototype = { update: function() { this.get(); if(this.nodeType == 'text') { this.node.nodeValue = this.value; } if(this.nodeType == 'input') { this.node.value = this.value; } }, // 獲取 data 中的屬性值 get: function() { this.value = this.vm[this.name]; // 觸發相應屬性的 get } } function Dep() { this.subs = [] } Dep.prototype = { addSub: function(sub) { this.subs.push(sub); }, notify: function() { this.subs.forEach(function(sub) { sub.update(); }); } } function Vue(options) { this.data = options.data; var data = this.data; observe(data, this); var id = options.el; var dom = nodeToFragment(document.getElementById(id), this); // 編譯完成後,將 dom 返回到 app 中 document.getElementById(id).appendChild(dom); } var vm = new Vue({ el: 'app', data: { text: 'hello world' } }) </script> </body> </html>
參考文章1:https://github.com/DDFE/DDFE-blog/issues/7
參考文章2:http://www.javashuo.com/article/p-brudryzf-hq.html
原文地址