自己實現一個VUE響應式--VUE響應式原理

蔣鵬飛發表於2020-01-16

這裡的響應式(Reactive)不同於CSS佈局的響應式(Responsive), 這裡的響應式是指資料和檢視的繫結,資料一旦更新,檢視會自動更新。下面讓我們來看看Vue是怎麼實現響應式的,Vue 2.0和Vue 3.0的實現原理還不一樣,我們來分開講。

Vue 2.0的響應式

Object.defineProperty

Vue 2.0的響應式主要用到了Object.defineProperty,我們先來說說這個方法。Object.defineProperty(obj, prop, descriptor)是用來定義屬性描述符的,它接收三個引數,第一個引數目標物件,第二個引數是目標物件裡面的屬性,第三個引數是想要設定的屬性描述符,包含如下幾個值及預設值

{
  value: undefined, // 屬性的值
  get: undefined,   // 獲取屬性值時觸發的方法
  set: undefined,   // 設定屬性值時觸發的方法
  writable: false,  // 屬性值是否可修改,false不可改
  enumerable: false, // 屬性是否可以用for...in 和 Object.keys()列舉
  configurable: false  // 該屬性是否可以用delete刪除,false不可刪除,為false時也不能再修改該引數
}
複製程式碼

對於一個普通的物件

var a = {b: 1}
複製程式碼

我們可以使用Object.getOwnPropertyDescriptor來獲取一個屬性的描述符

image-20200108124827294

你會發現a.b這個屬性的writable, enumerable, configurable這三個描述符都是true,但是我們前面說他們的預設值是false啊,這是怎麼回事呢?這是因為我們定義屬性的方法不一樣,我們最開始的定義這個屬性的時候已經給他賦值了,所以他已經是可寫的了。我們換一種宣告方式,用Object.defineProperty直接宣告a.c,再看看他的屬性描述符

image-20200108125615580

我們定義的時候只指定了值為2,沒有指定其他描述符,那麼writable, enumerable, configurable都是預設值false,也就意味著a.c不能修改,不能列舉,也不能再配置。即使你顯式a.c=3也沒有用,他的值還是2,而且這樣寫在嚴格模式還會報錯。因為configurablefalse,也不能通過Object.defineProperty再修改描述符,會直接報錯:

image-20200108130152526

set 和 get

這才是重頭戲,Vue就是通過setget來實現的響應式,我們通過自己實現一個簡單版的Vue來講解這個問題。首先我們先定義一個vue:

function vue(){
    this.$data = {a: 1};
  this.el = document.getElementById('app');
  this.virtualDom = '';
  this.observer(this.$data);
  this.render();
}
複製程式碼

我們需要在observer方法裡面來實現setget,因為我們要監聽的是值屬性,要是屬性本身又是一個物件,比如

{
  a: {
    b: {
      c: 1
    }
  }
}
複製程式碼

我們需要遞迴的設定setget來監聽裡面的值。我們簡單版的get就直接返回值了,其實這裡可以進行優化,後面再講。set方法接收一個引數newValue,我們直接賦值給value,然後呼叫render方法更新介面

vue.prototype.observer = function(obj){
    var value;
  var self = this;
  for(var key in obj){  // 遞迴設定set和get
    value = obj[key];
    if(typeof value === 'object'){
      this.observer(value);
    } else {
      Object.defineProperty(this.$data, key, {
        get: function(){
          return value;
        },
        set: function(newValue){
          value = newValue;
          self.render();
        }
      });
    }
  }
}
複製程式碼

下面是render方法:

vue.prototype.render = function(){
    this.virtualDom = `I am ${this.$data.a}`;
  this.el.innerHTML = this.virtualDom;
}
複製程式碼

這樣你每次修改$data.a的時候,介面就會自動更新。需要注意的是,如果你設定了get方法,但是沒有寫返回值,會預設返回undefined,你每次讀這個屬性都是undefined,如果設定了set方法,值的更新就必須自己全部實現,不實現去賦值也不會成功。事實上,getset需要優化的地方還很多,我們現在是一旦觸發set就更新了整個DOM,但實際上我們可能有100個元件,其中只有一個元件使用了set的值,這會造成很大的資源浪費。我們需要找出一個變數到底被哪些元件使用了,當變數更新的時候只去更新那些用到了的元件。這才是Vue真正的做法:

image-20200108134432297

這樣我們的getset就變成了這樣:

Object.defineProperty(this.$data, key, {
  get: function(){
    dep.depend(); // 這裡進行依賴收集
    return value;
  },
  set: function(newValue){
    value = newValue;
    // self.render();
    dep.notify();  // 這裡進行virtualDom更新,通知需要更新的元件render
  }
});
複製程式碼

dep是Vue負責管理依賴的一個類,後面單獨開一篇文章講。

陣列的處理

陣列不能用Object.defineProperty來處理,應該怎麼辦呢?Vue裡面運算元組,直接用下標更改,是沒有用的,必須使用push, shift等方法來操作,為什麼呢?

var a = [1, 2, 3];
a[0] = 10;  // 這樣不能更新檢視
複製程式碼

其實Vue用裝飾者模式來重寫了陣列這些方法,在講這個之前我們先講講Object.create

Object.create

這個方法建立一個新物件,使用現有的物件來提供新建立的物件的__proto__,接收兩個引數Object.create(proto[, propertiesObject])。第一個引數是新建立物件的原型物件,第二個引數可選,如果沒有指定為 undefined,則是要新增到新建立物件的不可列舉(預設)屬性(即其自身定義的屬性,而不是其原型鏈上的列舉屬性)物件的屬性描述符以及相應的屬性名稱。這些屬性對應Object.defineProperties()的第二個引數。看實現類式繼承的例子:

// Shape - 父類(superclass)
function Shape() {
  this.x = 0;
  this.y = 0;
}

// 父類的方法
Shape.prototype.move = function(x, y) {
  this.x += x;
  this.y += y;
  console.info('Shape moved.');
};

// Rectangle - 子類(subclass)
function Rectangle() {
  Shape.call(this); // call super constructor.
}

// 子類續承父類
Rectangle.prototype = Object.create(Shape.prototype);
// 此時 Rectangle.prototype !== Shape.prototype
// 但是 Rectangle.prototype.__prpto__ === Shape.prototype
Rectangle.prototype.constructor = Rectangle;  // 前面會改掉建構函式,重新設定建構函式

var rect = new Rectangle();

console.log('Is rect an instance of Rectangle?',
  rect instanceof Rectangle); // true
console.log('Is rect an instance of Shape?',
  rect instanceof Shape); // true
rect.move(1, 1); // Outputs, 'Shape moved.'
複製程式碼

多繼承例子:

function MyClass() {
     SuperClass.call(this);
     OtherSuperClass.call(this);
}

// 繼承一個類
MyClass.prototype = Object.create(SuperClass.prototype);
// 混合其它
Object.assign(MyClass.prototype, OtherSuperClass.prototype);
// 重新指定constructor
MyClass.prototype.constructor = MyClass;

MyClass.prototype.myMethod = function() {
     // do a thing
};
複製程式碼

我們回到Vue, 看看它陣列的裝飾者模式:

var arraypro = Array.prototype;     // 獲取Array的原型
var arrob = Object.create(arraypro); // 用Array的原型建立一個新物件,arrob.__proto__ === arraypro,免得汙染原生Array;
var arr=['push', 'pop', 'shift'];   // 需要重寫的方法

arr.forEach(function(method){
  arrob[method] = function(){
    arraypro[method].apply(this, arguments);  // 重寫時先呼叫原生方法
    dep.notify();                             // 並且同時更新
  }
});

// 對於使用者定義的陣列,手動將陣列的__proto__指向我們修改過的原型
var a = [1, 2, 3];
a.__proto__ = arrob;
複製程式碼

上面對於新物件arrob的方法,我們是直接賦值的,這樣會有一個問題,就是使用者可能會不小心改掉我們的物件,所以我們可以用到我們前面講到的Object.defineProperty來規避這個問題,我們建立一個公用方法def專門來設定不能修改值的屬性

function def (obj, key, value) {
  Object.defineProperty(obj, key, {
    // 這裡我們沒有指定writeable,預設為false,即不可修改
    enumerable: true,
    configurable: true,
    value: value,
  });
}

// 陣列方法重寫改為
arr.forEach(function(method){
  def(arrob, method, function(){
    arraypro[method].apply(this, arguments);  // 重寫時先呼叫原生方法
    dep.notify();                             // 並且同時更新
  });
});
複製程式碼

Vue 3.0的響應式

3.0的響應式原理跟2.0類似,也是在get的時候收集依賴,在set的時候更新檢視。但是3.0使用了ES6的新API ProxyReflect,使用Proxy相對於Object.defineProperty有如下好處:

1. Object.defineProperty需要指定物件和屬性,對於多層巢狀的物件需要遞迴監聽,Proxy可以直接監聽整個物件,不需要遞迴;
2. Object.defineProperty的get方法沒有傳入引數,如果我們需要返回原值,需要在外部快取一遍之前的值,Proxy的get方法會傳入物件和屬性,可以直接在函式內部操作,不需要外部變數;
3. set方法也有類似的問題,Object.defineProperty的set方法傳入引數只有newValue,也需要手動將newValue賦給外部變數,Proxy的set也會傳入物件和屬性,可以直接在函式內部操作;
4. new Proxy()會返回一個新物件,不會汙染源原物件
5. Proxy可以監聽陣列,不用單獨處理陣列
複製程式碼

上面的vue.prototype.observer可以改為:

vue.prototype.observer = function(obj){
  var self = this;
  this.$data = new Proxy(this.$data, {
    get: function(target, key){
      return target[key];
    },
    set: function(target, key, newValue){
      target[key] = newValue;
      self.render();
    }
  });
}
複製程式碼

Proxy還可以做資料校驗

// 需求,校驗一個人名字必須是中文,年齡必須大於18歲

var validator = {
  name: function(value){
    var reg = /^[\u4E00-\u9FA5]+$/;

    if(typeof value === "string" && reg.test(value)){
      return true;
    }

    return false;
  },
  age: function(value){
    if(typeof value==='number' && value >= 18){
      return true;
    }

    return false;
  }
}

function person(age, name){
  this.age = age;
  this.name = name;

  // 使用Proxy的set校驗,每次給物件屬性賦值或修改都會校驗
  return new Proxy(this, {
    get: function(target, key){
      return target[key];
    },
    set: function(target, key, newValue){
      // set的時候呼叫前面定義好的驗證規則,這其實就是策略模式
      if(validator[key](newValue)){
        return Reflect.set(target, key, newValue);
      }else{
        throw new Error(`${key} is wrong.`)
      }
    }
  });
}
複製程式碼

let p = new Proxy(target, handler);的第二個引數handler不僅可以在get和set時觸發,還可以在下列方法時觸發:

getPrototypeOf()
setPrototypeOf()
isExtensible()
preventExtensions()
getOwnPropertyDescriptor()
defineProperty()
has()
get()
set()
deleteProperty()
ownKeys()
apply()
construct()
複製程式碼

淺談虛擬DOM和diff演算法

我們有這樣一個模板:

<template>
  <div id="123">
    <p>
      <span>111</span>
    </p>
    <p>
      123
    </p>
  </div>
  <div>
    <p>456</p>
  </div>
</template>
複製程式碼

這一段模板轉化為虛擬DOM的虛擬碼,以第一個div為例的虛擬碼:

{
  dom: 'div',
  props: {
    id: 123
  },
  children: [
    {
      dom: 'p',
      children: [
        dom: 'span',
        text: "111"
      ]
    },
    {
      dom: 'p',
      text: "123"
    }
  ]
}
複製程式碼

每個節點都可以有多個children,每個child都是一個單獨的節點,結構是一樣的,也可以有自己的children。

在進行節點比對的時候,Vue只會進行同層的比較,比如有一個節點之前是:

<div>
    <p>123</p>
</div>
複製程式碼

後面變成了

<div>
    <span>456</span>
</div>
複製程式碼

比對是隻會比對第一層的div, 第二層是p和span比對,不會拿div和span進行比對,如下圖:

image-20200108173018255

從資料改變的set方法開始的diff演算法如下圖所示:

image-20200108181340241

如果新舊兩個節點完全不一樣了isSameVnode返回false,則整個節點更新,如果節點本身是一樣的,就比較他們的子節點,下面是虛擬碼:

patchVnode(oldVnode, vnode){
    const el = vnode.el = oldVnode;
  let i, oldCh = oldVnode.children, ch = vnode.children;

  if(oldVnode === vnode) return;

  if(oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text){
    // 僅僅是文字改變,更新文字
    setTextContext(el, vnode.text);
  }else{
    updateEle();

    if(oldCh&&ch&&oldCh!==ch){
       // 都有子元素,但是變化了
       updateChildren();
    }else if(ch){
      // 新的有子元素, 老的沒有,建立新元素
      createEl(vnode);
    }else if(oldCh){
      // 老的有子元素,新的沒有,刪除老元素
      removeChildren(el);
    }
  }
}複製程式碼

原創不易,每篇文章都耗費了作者大量的時間和心血,如果本文對你有幫助,請點贊支援作者,也讓更多人看到本文~~

更多文章請看我的掘金文章彙總


相關文章