响应式数据与数据依赖基本原理

前言

  首先欢迎你们关注个人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博客继续关注,若是有讲的不许确的地方,欢迎你们指正。

相关文章
相关标签/搜索