響應式數據與數據依賴基本原理

前言

  首先歡迎你們關注個人Github博客,也算是對個人一點鼓勵,畢竟寫東西無法得到變現,能堅持下去也是靠的是本身的熱情和你們的鼓勵。javascript

  國內前端算是屬於Vue與React兩分天下,提到Vue,最使人印象深入的就是雙向綁定了,想要深刻的理解雙向綁定,最重要的就是明白響應式數據的原理。這篇文章不會去一字一句的分析Vue中是如何實現響應式數據的,咱們只會從原理的角度去考量如何實現一個簡單的響應式模塊,但願能對你有些許的幫助。   php

響應式數據

  響應式數據不是憑空出現的。對於前端工程而言,數據模型Model都是普通的JavsScript對象。View是Model的體現,藉助JavaScript的事件響應,View對Model的修改很是容易,好比:   html

var model = {
    click: false
};

var button = document.getElementById("button");
button.addEventListener("click", function(){
    model.click = !model.click;
})
複製代碼

  可是想要在修改Model時,View也能夠對應刷新,相對比較困難的。在這方面,React和View提供了兩個不一樣的解決方案,具體能夠參考這篇文章。其中響應式數據提供了一種可實現的思路。什麼是響應式數據?在我看來響應式數據就是修改數據的時候,能夠按照你設定的規則觸發一系列其餘的操做。咱們想實現的其實就是下面的效果:   前端

var model = {
  name: "javascript"
};
// 使傳入的數據變成響應式數據
observify(model);
//監聽數據修改
watch(model, "name", function(newValue, oldValue){
  console.log("name newValue: ", newValue,  ", oldValue: ", oldValue);
});

model.name = "php"; // languange newValue: php, oldValue: javascript
複製代碼

  從上面效果中咱們能夠看出來,咱們須要劫持修改數據的過程。好在ES5提供了描述符屬性,經過方法Object.defineProperty咱們能夠設置訪問器屬性。可是包括IE8在內的低版本瀏覽器是沒有實現Object.defineProperty而且也不能經過polyfill實現(其實IE8是實現了該功能,只不過只能對DOM對象使用,而且很是受限),所以在低版本瀏覽器中無法實現該功能。這也就是爲何Vue不支持IE8及其如下的瀏覽的緣由。經過Object.defineProperty咱們能夠實現:   java

Object.defineProperty(obj, "prop", {
    enumerable: true,
    configurable: true,
    set: function(value){
        //劫持修改的過程
    },
    get: function(){
        //劫持獲取的過程
    }
});
複製代碼

數據響應化

  根據上面的思路咱們去考慮如何實現observify函數,若是咱們想要將一個對象響應化,咱們則須要遍歷對象中的每一個屬性,而且須要對每一個屬性對應的值一樣進行響應化。代碼以下:   git

// 數據響應化
// 使用lodash
function observify(model){
  if(_.isObject(model)){
    _.each(model, function(value, key){
      defineReactive(model, key, value);
    });
  }
}

//定義對象的單個響應式屬性
function defineReactive(obj, key, value){
  observify(value);
  Object.defineProperty(obj, key, {
    configurable: true,
    enumerable: true,
    set: function(newValue){
      var oldValue = value;
      value = newValue;
      //能夠在修改數據時觸發其餘的操做
      console.log("newValue: ", newValue, " oldValue: ", oldValue);
    },
    get: function(){
      return value;
    }
  });

}
複製代碼

  上面的函數observify就實現了對象的響應化處理,例如:   github

var model = {
  name: "MrErHu",
  message: {
    languange: "javascript"
  }
};

observify(model);
model.name = "mrerhu" //newValue: mrerhu oldValue: MrErHu
model.message.languange = "php" //newValue: php oldValue: javascript
model.message = { db: "MySQL" } //newValue: {db: "MySQL"} oldValue: {languange:"javascript"}
複製代碼

   咱們知道在JavaScript中常用的不只僅是對象,數組也是很是重要的一部分。而且中還有很是的多的方法可以改變數組自己,那麼咱們如何可以監聽到數組的方法對數組帶來的變化呢?爲了解決這個問題咱們可以一種替代的方式,將原生的函數替換成咱們自定義的函數,而且在自定義的函數中調用原生的數組方法,就能夠達到咱們想要的目的。咱們接着改造咱們的defineReactive函數。   數組

function observifyArray(array){
  //須要變異的函數名列表
  var methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
  var arrayProto = Object.create(Array.prototype);
  _.each(methods, function(method){
    arrayProto[method] = function(...args){
      // 劫持修改數據
      var ret = Array.prototype[method].apply(this, args);
      //能夠在修改數據時觸發其餘的操做
      console.log("newValue: ", this);
      return ret;
    }
  });
  Object.setPrototypeOf(array, arrayProto);
}

//定義對象的單個響應式屬性
function defineReactive(obj, key, value){
  if(_.isArray(value)){
    observifyArray(value, dep);
  }else {
    observify(value);
  }
  Object.defineProperty(obj, key, {
  // 省略......
  });
}
複製代碼

  咱們能夠看到咱們將數組原生的原型替換成自定義的原型,而後調用數組的變異方法時就會調用咱們自定義的函數。例如:瀏覽器

var model = [1,2,3];
observify(model);
model.push(4); //newValue: [1, 2, 3, 4]
複製代碼

  到目前爲止咱們已經實現了咱們的需求,其實我寫到這裏的時候,我考慮到是否須要實現對數組的鍵值進行監聽,其實做爲使用過Vue的用戶必定知道,當你利用索引直接設置一個項時,是不會監聽到數組的變化的。好比:   app

vm.items[indexOfItem] = newValue
複製代碼

  若是你想要實現上面的效果,能夠經過下面的方式實現:

vm.items.splice(indexOfItem, 1, newValue);
複製代碼

  首先考慮這個是否能實現。答案是顯而易見的了。固然是能夠,數組其實能夠看作特殊的數組,而其實對於數組而言,數值類型的索引都會被最終解析成字符串類型,好比下面的代碼:

var array = [0,1,2];
array["0"] = 1; //array: [1,1,2]
複製代碼

  那要實現對數值索引對應的數據進行修改,其實也是能夠經過Object.defineProperty函數去實現,好比:

var array = [0];
Object.defineProperty(array, 0, {
    set: function(newValue){
        console.log("newValue: ", newValue);
    }
});
array[0] = 1;//newValue: 1
複製代碼

  能夠實現但卻沒有實現該功能,想來主要緣由可能就是基於性能方面的考慮(個人猜想)。可是Vue提供了另外一個全局的函數,Vue.set能夠實現   

Vue.set(vm.array, indexOfItem, newValue)
複製代碼

  咱們能夠大體猜想一下Vue.set內部怎麼實現的,對於數組而言,只須要對newValue作響應化處理並將其賦值到數組中,而後通知數組改變。對於對象而言,若是是以前不存在的屬性,首先能夠將newValue進行響應化處理(好比調用observify(newValue)),而後將對具體屬性定義監聽(好比調用函數defineReactive),最後再去作賦值,可能具體的處理過程千差萬別,可是內部實現的原理應該就是如此(僅僅是猜想)。

  不只如此,在上面的實現中咱們能夠發現,咱們並不能監聽到對象不能檢測對象屬性的添加或刪除,所以若是若是你要監聽某個屬性的值,而一開始這個屬性並不存在,最好是在數據初始化的時候就給其一個默認值,從而能監聽到該屬性的變化。

依賴收集

  上面咱們講了這麼多,但願你們不要被帶偏了,咱們上面所作的都是但願能在數據發生變化時獲得通知。回到咱們最初的問題。咱們但願的是,在Model層數據發生改變的時候,View層的數據相應發生改變,咱們已經可以監聽到數據的改變了,接下來要考慮的就是View的改變。

  對於Vue而言,即便你使用的是Template描述View層,最終都會被編譯成render函數。好比,模板中描述了:

<h1>{{ name }}</h1>
複製代碼

  其實最後會被編譯成:

render: function (createElement) {
  return createElement('h1', this.name);
}
複製代碼

  那如今就存在下面這個一個問題,假如個人Model是下面這個樣子的:   

var model = {
    name: "MrErHu",
    age: 23,
    sex: "man"
}
複製代碼

  事實上render函數中就只用到了屬性name,可是Model中卻存在其餘的屬性,當數據改變的時候,咱們怎麼知道何時才須要從新調用render函數呢。你可能會想,哪裏須要那麼麻煩,每次數據改變都去刷新render函數不就好了嗎。這樣固然能夠,其實若是朝着這個思路走,咱們就朝着React方向走了。事實上若是不借助虛擬DOM的前提下,若是每次屬性改變都去調用render效率必然是低下的,這時候咱們就引入了依賴收集,若是咱們能知道render依賴了那些屬性,那麼在這些屬性修改的時候,咱們再精準地調用render函數,那麼咱們的目的不就達到了嗎?這就是咱們所稱的依賴收集

  依賴收集的原理很是的簡單,在響應式數據中咱們一直利用的都是屬性描述符中的set方法,而咱們知道當調用某個對象的屬性時,會觸發屬性描述符的get方法,當get方法調用時,咱們將調用get的方法收集起來就能完成咱們的依賴收集的任務。

  首先咱們能夠思考要一下,若是是本身寫一個響應式數據帶依賴收集的模塊,咱們會去怎麼設計。首先咱們想要達到的相似效果就是:   

var model = {
    name: "MrErHu",
    program: {
        language: "Javascript"
    },
    favorite: ["React"]
};

//數據響應化
observify(model);
//監聽
watch(function(){
    return '<p>' + (model.name) + '</p>'
}, function(){
    console.log("name: ", model.name);
});

watch(function(){
    return '<p>' + (model.program.language) + '</p>'
}, function(){
    console.log("language: ", model.program.language);
});

watch(function(){
    return '<p>' + (model.favorite) + '</p>'
}, function(){
    console.log("favorite: ", model.favorite);
});

model.name = "mrerhu"; //name: mrerhu
model.program.language = "php"; //language: php
model.favorite.push("Vue"); //favorite: [React, Vue]
複製代碼

  咱們所須要實現的watch函數的第一個參數能夠認爲是render函數,經過執行render函數咱們能夠收集到render函數內部使用了那些響應式數據屬性。而後在對應的響應式數據屬性改變的時候,觸發咱們註冊的第二個函數。這樣看咱們監聽屬性的粒度就是響應數據的每個屬性。按照單一職責的概念,咱們將監聽訂閱通知發佈的職責分離出去,由單獨的Dep類負責。因爲監聽的粒度是響應式數據的每個屬性,所以咱們會爲每個屬性維護一個Dep。與此相對應,咱們建立Watcher類,負責向Dep註冊,並在收到通知後調用回調函數。以下圖所示:   

  首先咱們實現DepWatcher類:   

//引入lodash庫
class Dep {
  constructor(){
    this.listeners = [];
  }

  // 添加Watcher
  addWatcher(watcher){
    var find = _.find(this.listeners, v => v === watcher);
    if(!find){
      //防止重複註冊
      this.listeners.push(watcher);
    }
  }
  // 移除Watcher
  removeWatcher(watcher){
    var find = _.findIndex(this.listeners, v => v === fn);
    if(find !== -1){
      this.listeners.splice(watcher, 1);
    }
  }
  // 通知
  notify(){
    _.each(this.listeners, function(watcher){
      watcher.update();
    });
  }
}

Dep.target = null;

class Watcher {
  constructor(callback){
    this.callback = callback;
  }
  //獲得Dep通知調用相應的回調函數
  update(){
    this.callback();
  }
}
複製代碼

  接着咱們建立watcher函數而且改造以前響應式相關的函數:   

// 數據響應化
function observify(model){
  if(_.isObject(model)){
    _.each(model, function(value, key){
      defineReactive(model, key, value);
    });
  }
}

//定義對象的單個響應式屬性
function defineReactive(obj, key, value){
  var dep = new Dep();
  if(_.isArray(value)){
    observifyArray(value, dep);
  }else {
    observify(value);
  }
  Object.defineProperty(obj, key, {
    configurable: true,
    enumerable: true,
    set: function(newValue){
      observify(value);
      var oldValue = value;
      value = newValue;
      //能夠在修改數據時觸發其餘的操做
      dep.notify(value);
    },
    get: function(){
      if(!_.isNull(Dep.target)){
        dep.addWatcher(Dep.target);
      }
      return value;
    }
  });
}
// 數據響應化
function observify(model){
  if(_.isObject(model)){
    _.each(model, function(value, key){
      defineReactive(model, key, value);
    });
  }
}

//定義對象的單個響應式屬性
function defineReactive(obj, key, value){
  var dep = new Dep();
  if(_.isArray(value)){
    observifyArray(value, dep);
  }else {
    observify(value);
  }
  Object.defineProperty(obj, key, {
    configurable: true,
    enumerable: true,
    set: function(newValue){
      observify(value);
      var oldValue = value;
      value = newValue;
      //能夠在修改數據時觸發其餘的操做
      dep.notify(value);
    },
    get: function(){
      if(!_.isNull(Dep.target)){
        dep.addWatcher(Dep.target);
      }
      return value;
    }
  });
}

function observifyArray(array, dep){
  //須要變異的函數名列表
  var methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
  var arrayProto = Object.create(Array.prototype);
  _.each(methods, function(method){
    arrayProto[method] = function(...args){
      var ret = Array.prototype[method].apply(this, args);
      dep.notify(this);
      return ret;
    }
  });
  Object.setPrototypeOf(array, arrayProto);
}

function watch(render, callback){
  var watcher = new Watcher(callback);
  Dep.target = watcher;
  render();
  Dep.target = null;
}
複製代碼

  接下來咱們就能夠實驗一下咱們的watch函數了:

var model = {
  name: "MrErHu",
  message: {
    languange: "javascript"
  },
  love: ["Vue"]
};

observify(model);

watch(function(){
    return '<p>' + (model.name) + '</p>'
}, function(){
    console.log("name: ", model.name);
});

watch(function(){
    return '<p>' + (model.message.languange) + '</p>'
}, function(){
    console.log("message: ", model.message);
});

watch(function(){
    return '<p>' + (model.love) + '</p>'
}, function(){
    console.log("love: ", model.love);
});

model.name = "mrerhu"; // name: mrerhu
model.message.languange = "php"; // message: { languange: "php"}
model.message = {
  target: "javascript"
}; // message: { languange: "php"}

model.love.push("React"); // love: ["Vue", "React"]
複製代碼

  到此爲止咱們已經基本實現了咱們想要的效果,固然上面的例子並不完備,可是也基本能展現出響應式數據與數據依賴的基本原理。固然上面僅僅只是採用ES5的數據描述符實現的,隨着ES6的普及,咱們也能夠用Proxy(代理)和Reflect(反射)去實現。做爲本系列的第一篇文章,還有其餘的點沒有一一列舉出來,你們能夠關注個人Github博客繼續關注,若是有講的不許確的地方,歡迎你們指正。

相關文章
相關標籤/搜索