Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
MrErHu opened this issue May 28, 2018 · 1 comment
Open

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

MrErHu opened this issue May 28, 2018 · 1 comment
Labels

Comments

@MrErHu
Copy link
Owner

MrErHu commented May 28, 2018

前言

  首先欢迎大家关注我的Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。

  国内前端算是属于Vue与React两分天下,提到Vue,最令人印象深刻的就是双向绑定了,想要深入的理解双向绑定,最重要的就是明白响应式数据的原理。这篇文章不会去一字一句的分析Vue中是如何实现响应式数据的,我们只会从原理的角度去考量如何实现一个简单的响应式模块,希望能对你有些许的帮助。
  

响应式数据

  响应式数据不是凭空出现的。对于前端工程而言,数据模型Model都是普通的JavsScript对象。View是Model的体现,借助JavaScript的事件响应,View对Model的修改非常容易,比如:
  

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我们可以实现:
  

Object.defineProperty(obj, "prop", {
    enumerable: true,
    configurable: true,
    set: function(value){
        //劫持修改的过程
    },
    get: function(){
        //劫持获取的过程
    }
});

数据响应化

  根据上面的思路我们去考虑如何实现observify函数,如果我们想要将一个对象响应化,我们则需要遍历对象中的每个属性,并且需要对每个属性对应的值同样进行响应化。代码如下:
  

// 数据响应化
// 使用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就实现了对象的响应化处理,例如:
  

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的用户一定知道,当你利用索引直接设置一个项时,是不会监听到数组的变化的。比如:
  

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博客继续关注,如果有讲的不准确的地方,欢迎大家指正。

@MRzhao0379
Copy link

膜拜

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants