1. 程式人生 > >Vue原理解析——自己寫個Vue

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的原理可以簡單地從下列圖示所得出

  1. 通過建立虛擬dom樹document.createDocumentFragment(),方法建立虛擬dom樹。
  2. 擷取到的資料變化,從而通過訂閱——釋出者模式,觸發Watcher(觀察者),從而改變虛擬dom的中的具體資料。
  3. 最後,通過更新虛擬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>

在這我們可以簡單的實現了一個雙向繫結。但是到這還不夠,我們的目的是實現一個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();
    this.node.nodeValue = this.value;//這裡去改變檢視的值
}
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節點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'
  }
})

四、回顧

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

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

  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。

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

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

參考連結: