[轉] Vue原理解析——自己寫個Vue


一、Vue對比其他框架原理

Vue相對於React,Angular更加綜合一點。AngularJS則使用了“臟值檢測”。

React則采用避免直接操作DOM的虛擬dom樹。而Vue則采用的是 Object.defineProperty特性(這在ES5中是無法slim的,這就是為什么vue2.0不支持ie8以下的瀏覽器)

Vue可以說是尤雨溪從Angular中提煉出來的,又參照了React的性能思路,而集大成的一種輕量、高效,靈活的框架。

二、Vue的原理

Vue的原理可以簡單地從下列圖示所得出

  1. 通過建立虛擬dom樹document.createDocumentFragment(),方法創建虛擬dom樹。
  2. 一旦被監測的數據改變,會通過Object.defineProperty定義的數據攔截,截取到數據的變化。
  3. 截取到的數據變化,從而通過訂閱——發布者模式,觸發Watcher(觀察者),從而改變虛擬dom的中的具體數據。
  4. 最后,通過更新虛擬dom的元素值,從而改變最后渲染dom樹的值,完成雙向綁定

Vue的模式是m-v-vm模式,即(model-view-modelView),通過modelView作為中間層(即vm的實例),進行雙向數據的綁定與變化。

而實現這種雙向綁定的關鍵就在於:

Object.defineProperty訂閱——發布者模式浙兩點。

下面我們通過實例來實現Vue的基本雙向綁定。

三、Vue雙向綁定的實現

3.1 簡易雙綁

首先,我們把注意力集中在這個屬性上:Object.defineProperty。

Object.defineProperty() 方法會直接在一個對象上定義一個新屬性,或者修改一個對象的現有屬性, 並返回這個對象。

語法:Object.defineProperty(obj, prop, descriptor)

什么叫做,定義或修改一個對象的新屬性,並返回這個對象呢?

var obj = {}; Object.defineProperty(obj,'hello',{ get:function(){ //我們在這里攔截到了數據 console.log("get方法被調用"); }, set:function(newValue){ //改變數據的值,攔截下來額 console.log("set方法被調用"); } }); obj.hello//輸出為“get方法被調用”,輸出了值。 obj.hello = 'new Hello';//輸出為set方法被調用,修改了新值

輸出結果如下:

clipboard.png

可以從這里看到,這是在對更底層的對象屬性進行編程。簡單地說,也就是我們對其更底層對象屬性的修改或獲取的階段進行了攔截(對象屬性更改的鈎子函數)。

在這數據攔截的基礎上,我們可以做到數據的雙向綁定:

var obj = {}; Object.defineProperty(obj,'hello',{ get:function(){ //我們在這里攔截到了數據 console.log("get方法被調用"); }, set:function(newValue){ //改變數據的值,攔截下來額 console.log("set方法被調用"); document.getElementById('test').value = newValue; document.getElementById('test1').innerHTML = newValue; } }); //obj.hello; //obj.hello = '123'; document.getElementById('test').addEventListener('input',function(e){ obj.hello = e.target.value;//觸發它的set方法 })

html:

<div id="mvvm"> <input v-model="text" id="test"></input> <div id="test1"></div> </div>

在線演示:demo演示

在這我們可以簡單的實現了一個雙向綁定。但是到這還不夠,我們的目的是實現一個Vue。

3.2 Vue初始化(虛擬節點的產生與編譯)

3.2.1 Vue的虛擬節點容器
function nodeContainer(node, vm, flag){ var flag = flag || document.createDocumentFragment(); var child; while(child = node.firstChild){ compile(child, vm); flag.appendChild(child); if(child.firstChild){ // flag.appendChild(nodeContainer(child,vm)); nodeContainer(child, vm, flag); } } return flag; }

這里幾個注意的點:

  1. while(child = node.firstChild)把node的firstChild賦值成while的條件,可以看做是遍歷所有的dom節點。一旦遍歷到底了,node的firstChild就會未定義成undefined就跳出while。
  2. document.createDocumentFragment();是一個虛擬節點的容器樹,可以存放我們的虛擬節點。
  3. 上面的函數是個迭代,一直循環到節點的終點為止。
3.2.2 Vue的節點初始化編譯

先聲明一個Vue對象

function Vue(options){ this.data = options.data; var id = options.el; var dom = nodeContainer(document.getElementById(id),this); document.getElementById(id).appendChild(dom); } //隨后使用他 var Demo = new Vue({ el:'mvvm', data:{ text:'HelloWorld', d:'123' } })

接下去的具體得初始化內容

//編譯 function compile(node, vm){ var reg = /\{\{(.*)\}\}/g;//匹配雙綁的雙大括號 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; node.value = vm.data[name];//講實例中的data數據賦值給節點 //node.removeAttribute('v-model'); } } } //如果節點類型為text if(node.nodeType === 3){ if(reg.test(node.nodeValue)){ // console.dir(node); var name = RegExp.$1;//獲取匹配到的字符串 name = name.trim(); node.nodeValue = vm.data[name]; } } }

代碼解釋:

  1. 當nodeType為1的時候,表示是個元素。同時我們進行判斷,如果節點中的指令含有v-model這個指令,那么我們就初始化,進行對節點的值的賦值。
  2. 如果nodeType為3的時候,也就是text節點屬性。表示你的節點到了終點,一般都是節點的前后末端。我們常常在這里定義我們的雙綁值。此時一旦匹配到了雙綁(雙大括號),即進行值的初始化。

至此,我們的Vue初始化已經完成。

clipboard.png

在線演示:demo1

3.3 Vue的聲明響應式

3.3.1 定義Vue的data的屬性響應式
function defineReactive (obj, key, value){ Object.defineProperty(obj,key,{ get:function(){ console.log("get了值"+value); return value;//獲取到了值 }, set:function(newValue){ if(newValue === value){ return;//如果值沒變化,不用觸發新值改變 } value = newValue;//改變了值 console.log("set了最新值"+value); } }) }

這里的obj我們這定義為vm實例或者vm實例里面的data屬性。

PS:這里強調一下,defineProperty這個方法,不僅可以定義obj的直接屬性,比如obj.hello這個屬性。也可以間接定義屬性比如:obj.middle.hello。這里導致的效果就是兩者的hello屬性都被定義成響應式了。

用下列的observe方法循環調用響應式方法。

function observe (obj,vm){ Object.keys(obj).forEach(function(key){ defineReactive(vm,key,obj[key]); }) }

然后再Vue方法中初始化:

function Vue(options){ this.data = options.data; var data = this.data; ------------------------- observe(data,this);//這里調用定義響應式方法 ------------------------- var id = options.el; var dom = nodeContainer(document.getElementById(id),this); document.getElementById(id).appendChild(dom); //把虛擬dom渲染上去 }

在編譯方法中v-model屬性找到的時候去監聽:

function compile(node, vm){ var reg = /\{\{(.*)\}\}/g; 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; -------------------------//這里新添加的監聽 node.addEventListener('input',function(e){ console.log(vm[name]); vm[name] = e.target.value;//改變實例里面的值 }); ------------------------- node.value = vm[name];//講實例中的data數據賦值給節點 //node.removeAttribute('v-model'); } } } }

以上我們實現了,你再輸入框里面輸入,同時觸發getter&setter,去改變vm實例中data的值。也就是說MVVM的圖例中經過getter&setter已經成功了。接下去就是訂閱——發布者模式。

在線演示:demo2

實現效果:

clipboard.png

3.4 訂閱——發布者模式

什么是訂閱——發布者?簡單點說:你微信里面經常會訂閱一些公眾號,一旦這些公眾號發布新消息了。那么他就會通知你,告訴你:我發布了新東西,快來看。

這種情景下,你就是訂閱者,公眾號就是發布者

所以我們要模擬這種情景,我們先聲明3個訂閱者:

var sub1 = { update:function(){ console.log(1); } } var sub2 = { update:function(){ console.log(2); } } var sub3 = { update:function(){ console.log(3); } }

每個訂閱者對象內部聲明一個update方法來觸發訂閱屬性。

再聲明一個發布者,去觸發發布消息,通知的方法::

function Dep(){ this.subs = [sub1,sub2,sub3];//把三個訂閱者加進去 } Dep.prototype.notify = function(){//在原型上聲明“發布消息”方法 this.subs.forEach(function(sub){ sub.update(); }) } var dep = new Dep(); //pub.publish(); dep.notify();

我們也可以聲明另外一個中間對象

var dep = new Dep(); var pub = { publish:function(){ dep.notify(); } } pub.publish();//這里的結果是跟上面一樣的

實現效果:

clipboard.png

到這,我們已經實現了:

  1. 修改輸入框內容 => 觸發修改vm實例里的屬性值 => 觸發set&get方法
  2. 訂閱成功 => 發布者發出通知notify() => 觸發訂閱者的update()方法

接下來重點要實現的是:如何去更新視圖,同時把訂閱——發布者模式進去watcher觀察者模式?

3.5 觀察者模式

先定義發布者:

function Dep(){ this.subs = []; } Dep.prototype ={ add:function(sub){//這里定義增加訂閱者的方法 this.subs.push(sub); }, notify:function(){//這里定義觸發訂閱者update()的通知方法 this.subs.forEach(function(sub){ console.log(sub); sub.update();//下列發布者的更新方法 }) } }

再定義觀察者(訂閱者):

function Watcher(vm,node,name){ Dep.global = this;//這里很重要!把自己賦值給Dep函數對象的全局變量 this.name = name; this.node = node; this.vm = vm; this.update(); Dep.global = null;//這里update()完記得清空Dep函數對象的全局變量 } Watcher.prototype.update = function(){ this.get(); switch (this.node.nodeType) { //這里去通過判斷節點的類型改變視圖的值 case 1: this.node.value = this.value; break; case 3: this.node.nodeValue = this.value; break; default: break; }; } Watcher.prototype.get = function(){ this.value = this.vm[this.name];//這里把this的value值賦值,觸發data的defineProperty方法中的get方法! }

以上需要注意的點:

  1. 在Watcher函數對象的原型方法update里面更新視圖的值(實現watcher到視圖層的改變)。
  2. Watcher函數對象的原型方法get,是為了觸發defineProperty方法中的get方法!
  3. 在new一個Watcher的對象的時候,記得把Dep函數對象賦值一個全局變量,而且及時清空。至於為什么這么做,我們接下來看。
function defineReactive (obj, key, value){ var dep = new Dep();//這里每一個vm的data屬性值聲明一個新的訂閱者 Object.defineProperty(obj,key,{ get:function(){ console.log(Dep.global); ----------------------- if(Dep.global){//這里是第一次new對象Watcher的時候,初始化數據的時候,往訂閱者對象里面添加對象。第二次后,就不需要再添加了 dep.add(Dep.global); } ----------------------- return value; }, set:function(newValue){ if(newValue === value){ return; } value = newValue; dep.notify();//觸發了update()方法 } }) }

這里有一點需要注意:

在上述圈起來的地方:if(Dep.global)是在第一次new Watcher()的時候,進入update()方法,觸發這里的get方法。這里非常的重要的一點!在此時new Watcher()只走到了this.update();方法,此刻沒有觸發Dep.global = null函數,所以值並沒有清空,所以可以進到dep.add(Dep.global);方法里面去。

而第二次后,由於清空了Dep的全局變量,所以不會觸發add()方法。

PS:這個思路容易被忽略,由於是參考之前一個博主的代碼影響,我自己想了很多方法改變,但是在這種情景下難以實現別的更好的交互方式。

所以我暫時現在只能使用Dep的全局變量的方式,來實現Dep函數與Watcher函數的交互。(如果是ES6的模塊化方法會不一樣)

而后我會盡量找尋其他更好的方法來實現Dep函數與Watcher函數的交互。

緊接着在text節點和綁定了的input節點(別忘記了這個節點)new Watcher的方法來觸發以上的內容:

// 如果節點為input if(node.nodeType === 1){ ........... ---------- new Watcher(vm,node,name) // 別忘記給input添加觀察者模式 ---------- } //如果節點類型為text if(node.nodeType === 3){ if(reg.test(node.nodeValue)){ // console.dir(node); var name = RegExp.$1;//獲取匹配到的字符串 name = name.trim(); // node.nodeValue = vm[name]; ------------------------- new Watcher(vm,node,name);//這里到了一個新的節點,new一個新的觀察者 ------------------------- } }

至此,vue雙向綁定已經簡單的實現。

3.6 最終效果

在線演示:Codepen實現Vue的demo(有時候要翻牆)

在線源碼參考:demo4

下列是全部的源碼,僅供參考。

HTML:

<div id="mvvm"> <input v-model="d" id="test">{{text}} <div>{{d}}</div> </div>

JS:

var obj = {}; function nodeContainer(node, vm, flag){ var flag = flag || document.createDocumentFragment(); var child; while(child = node.firstChild){ compile(child, vm); flag.appendChild(child); if(child.firstChild){ nodeContainer(child, vm, flag); } } return flag; } //編譯 function compile(node, vm){ var reg = /\{\{(.*)\}\}/g; 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; node.addEventListener('input',function(e){ vm[name] = e.target.value; }); node.value = vm[name];//講實例中的data數據賦值給節點 node.removeAttribute('v-model'); } } } //如果節點類型為text if(node.nodeType === 3){ if(reg.test(node.nodeValue)){ // console.dir(node); var name = RegExp.$1;//獲取匹配到的字符串 name = name.trim(); // node.nodeValue = vm[name]; new Watcher(vm,node,name); } } } function defineReactive (obj, key, value){ var dep = new Dep(); Object.defineProperty(obj,key,{ get:function(){ console.log(Dep.global); if(Dep.global){ dep.add(Dep.global); } console.log("get了值"+value); return value; }, set:function(newValue){ if(newValue === value){ return; } value = newValue; console.log("set了最新值"+value); 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 = nodeContainer(document.getElementById(id),this); document.getElementById(id).appendChild(dom); } function Dep(){ this.subs = []; } Dep.prototype ={ add:function(sub){ this.subs.push(sub); }, notify:function(){ this.subs.forEach(function(sub){ console.log(sub); sub.update(); }) } } function Watcher(vm,node,name){ Dep.global = this; this.name = name; this.node = node; this.vm = vm; this.update(); Dep.global = null; } Watcher.prototype = { update:function(){ this.get(); switch (this.node.nodeType) { case 1: this.node.value = this.value; break; case 3: this.node.nodeValue = this.value; break; default: break; } }, get:function(){ this.value = this.vm[this.name]; } } var Demo = new Vue({ el:'mvvm', data:{ text:'HelloWorld', d:'123' } })

四、回顧

我們再來通過一張圖回顧一下整個過程:

clipboard.png

從上可以看出,大概的過程是這樣的:

  1. 定義Vue對象,聲明vue的data里面的屬性值,准備初始化觸發observe方法。
  2. 在Observe定義過響應式方法Object.defineProperty()的屬性,在初始化的時候,通過Watcher對象進行addDep的操作。即每定義一個vue的data的屬性值,就添加到一個Watcher對象到訂閱者里面去。
  3. 每當形成一個Watcher對象的時候,去定義它的響應式。即Object.defineProperty()定義。這就導致了一個Observe里面的getter&setter方法與訂閱者形成一種依賴關系。
  4. 由於依賴關系的存在,每當數據的變化后,會導致setter方法,從而觸發notify通知方法,通知訂閱者我的數據改變了,你需要更新。
  5. 訂閱者會觸發內部的update方法,從而改變vm實例的值,以及每個Watcher里面對應node的nodeValue,即視圖上面顯示的值。
  6. Watcher里面接收到了消息后,會觸發改變對應對象里面的node的視圖的value值,而改變視圖上面的值。
  7. 至此,視圖的值改變了。形成了雙向綁定MVVM的效果。

五、后記

至此,我們通過解析vue的綁定原理,實現了一個非常簡單的Vue。

我們可以再借鑒此思路的情況下,進行我們需要的定制框架的二次開發。如果開發人數尚可的話,可以實現類似微信小程序自己有的一套框架。

我非常重視技術的原理,只有真正掌握技術的原理,才能在原有的技術上更好地去提高和開發。

ps:此文是較早之前寫的,不夠規范,后面會修改一個ES6的版本。下方是參考鏈接,靈感來源於其他博主,我進行了修正優化和代碼解釋。

參考鏈接:

  1. Vue.js雙向綁定的實現原理
  2. Vue 源碼解析:深入響應式原理
  3. 深入響應式原理

原文地址(原創博客):http://www.tangyida.top/detail/150


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM