Vue原理解析——自己寫個Vue
Vue由於其高效的效能和靈活入門簡單、輕量的特點下變得火熱。在當今前端越來越普遍的使用,今天來剖析一下Vue的深入響應式原理。
tips:轉自我的部落格唐益達部落格,此為原創。轉載請註明出處,原文連結
一、Vue對比其他框架原理
Vue相對於React,Angular更加綜合一點。AngularJS則使用了“髒值檢測”。
React則採用避免直接操作DOM的虛擬dom樹。而Vue則採用的是 Object.defineProperty特性(這在ES5中是無法slim的,這就是為什麼vue2.0不支援ie8以下的瀏覽器)
Vue可以說是尤雨溪從Angular中提煉出來的,又參照了React的效能思路,而集大成的一種輕量、高效,靈活的框架。
二、Vue的原理
Vue的原理可以簡單地從下列圖示所得出
- 通過建立虛擬dom樹
document.createDocumentFragment()
,方法建立虛擬dom樹。 - 擷取到的資料變化,從而通過訂閱——釋出者模式,觸發Watcher(觀察者),從而改變虛擬dom的中的具體資料。
- 最後,通過更新虛擬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方法被呼叫,修改了新值
輸出結果如下:
可以從這裡看到,這是在對更底層的物件屬性進行程式設計。簡單地說,也就是我們對其更底層物件屬性的修改或獲取的階段進行了攔截(物件屬性更改的鉤子函式)。
在這資料攔截的基礎上,我們可以做到資料的雙向繫結:
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>
在這我們可以簡單的實現了一個雙向繫結。但是到這還不夠,我們的目的是實現一個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;
}
這裡幾個注意的點:
-
while(child = node.firstChild)
把node的firstChild賦值成while的條件,可以看做是遍歷所有的dom節點。一旦遍歷到底了,node的firstChild就會未定義成undefined就跳出while。 -
document.createDocumentFragment();
是一個虛擬節點的容器樹,可以存放我們的虛擬節點。 - 上面的函式是個迭代,一直迴圈到節點的終點為止。
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];
}
}
}
程式碼解釋:
- 當nodeType為1的時候,表示是個元素。同時我們進行判斷,如果節點中的指令含有
v-model
這個指令,那麼我們就初始化,進行對節點的值的賦值。 - 如果nodeType為3的時候,也就是text節點屬性。表示你的節點到了終點,一般都是節點的前後末端。我們常常在這裡定義我們的雙綁值。此時一旦匹配到了雙綁(雙大括號),即進行值的初始化。
至此,我們的Vue初始化已經完成。
線上演示: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
實現效果:
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();//這裡的結果是跟上面一樣的
實現效果:
到這,我們已經實現了:
- 修改輸入框內容 => 觸發修改vm例項裡的屬性值 => 觸發set&get方法
- 訂閱成功 => 釋出者發出通知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();
this.node.nodeValue = this.value;//這裡去改變檢視的值
}
Watcher.prototype.get = function(){
this.value = this.vm[this.name];//這裡把this的value值賦值,觸發data的defineProperty方法中的get方法!
}
以上需要注意的點:
- 在Watcher函式物件的原型方法update裡面更新檢視的值(實現watcher到檢視層的改變)。
- Watcher函式物件的原型方法get,是為了觸發defineProperty方法中的get方法!
- 在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節點new Watcher的方法來觸發以上的內容:
//如果節點型別為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.4 最終效果
線上演示: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();
this.node.nodeValue = this.value;
},
get:function(){
this.value = this.vm[this.name];
}
}
var Demo = new Vue({
el:'mvvm',
data:{
text:'HelloWorld',
d:'123'
}
})
四、回顧
我們再來通過一張圖回顧一下整個過程:
從上可以看出,大概的過程是這樣的:
- 定義Vue物件,宣告vue的data裡面的屬性值,準備初始化觸發observe方法。
- 在Observe定義過響應式方法Object.defineProperty()的屬性,在初始化的時候,通過Watcher物件進行addDep的操作。即每定義一個vue的data的屬性值,就新增到一個Watcher物件到訂閱者裡面去。
- 每當形成一個Watcher物件的時候,去定義它的響應式。即
Object.defineProperty()
定義。這就導致了一個Observe裡面的getter&setter方法與訂閱者形成一種依賴關係。 - 由於依賴關係的存在,每當資料的變化後,會導致setter方法,從而觸發notify通知方法,通知訂閱者我的資料改變了,你需要更新。
- 訂閱者會觸發內部的update方法,從而改變vm例項的值,以及每個Watcher裡面對應node的nodeValue,即檢視上面顯示的值。
- Watcher裡面接收到了訊息後,會觸發改變對應物件裡面的node的檢視的value值,而改變檢視上面的值。
- 至此,檢視的值改變了。形成了雙向繫結MVVM的效果。
五、後記
至此,我們通過解析vue的繫結原理,實現了一個非常簡單的Vue。
我們可以再借鑑此思路的情況下,進行我們需要的定製框架的二次開發。如果開發人數尚可的話,可以實現類似微信小程式自己有的一套框架。
我非常重視技術的原理,只有真正掌握技術的原理,才能在原有的技術上更好地去提高和開發。
參考連結: