一篇文章教你深入了解Vue响应式原理

150 阅读4分钟

一、响应式的基本思路

众所周知,vue的核心就是响应式,那么响应式是在vue当中怎么实现的呢?在vue2当中,响应式是靠Object.defineProperty()来实现响应式的,虽然vue3已经使用Proxy重写了这部分响应式的代码,但是响应式的基本思路并没有发生变化。响应式的基本思想是先在访问数据的时候收集依赖,然后当数据发生修改的时候,通知到收集的依赖当中,这样就可以实现对数据的响应式处理。

二、响应式的代码实现

2.1、利用Object.defineProperty()实现拦截

/**
*@params data 需要实现响应式的对象
*@params key 需要实现响应式的属性
*@params val 属性的初始化值
*/
function defaineReactive(data, key, val) {
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    //当获取key属性的值时,会触发get函数
    get: function () {
      return val;
    },
    //当对key属性设置新的值时,会触发set函数
    set: function (newVal) {
      if (val == newVal) {
        return;
      }
      val = newVal;
    },
  });
}

首先,创建一个函数,通过函数来封装Object.defineProperty()属性,传入三个参数data、key、valObject.defineProperty()接收三个参数,第一个参数是需要拦截的对象,第二个参数是需要拦截对象当中的属性,第三个参数是关于属性的配置,而我们的拦截方法也是在配置当中实现的。当一个对象的属性通过Object.defineProperty()去拦截之后,我们再访问这个属性,会触发get方法,同时执行当中的代码,而当我们对拦截对象当中的属性设置新的值的时候,会触发set()方法。这样我们就实现了对对象当中的某个属性进行拦截的操作。

2.2、实现依赖收集

首先,我们需要明白什么是依赖,这里的依赖就是对于需要获取我们拦截的属性的值的地方,可以是一个函数、一个表达式、模版等,只要是需要获取我们拦截对象的属性的值,我们都称为依赖,因为这些东西都依赖于拦截属性。那么我们该怎么样来收集依赖呢?我们可以想一想在哪里收集依赖比较好呢?比如,当一个函数需要使用我们对象当中的属性,首先需要获取属性的值,这时候就会触发我们刚刚设置好的get方法了,因此,我们在get方法当中收集依赖是比较好的。那么收集依赖的地方有了,那么我们该用什么来存储我们收集到的依赖呢?这里我们使用数组来存储收集到的依赖。好了,现在大致的思路就已经有了,下面让我们来实现一下:


/**
 * 创建一个dep类来收集依赖
 */class Dep {
  constructor() {
    this.subs = [];
  }
  //添加依赖
  addSub(sub) {
    this.subs.push(sub);
  }
  //删除依赖
  removeSub(sub) {
    if (this.subs.indexOf(sub) !== -1) {
      const index = this.subs.indexOf(sub);
      this.subs.splice(index, 1);
    }
  }
  //收集依赖
  depend() {
    if (window.target) {
      this.addSub(window.target);
    }
  }
  //通知更新
  notify() {
    const subs = this.subs.slice();
    for (let i = 0; i < subs.length; i++) {
      subs[i].update();
    }
  }
}

在上面的代码当中,我们定义了一个Dep类,在类的构造函数当中,我们初始化了一个空数组,这个数组之后就是我们用来存储依赖的地方。在类当中,我们定义了四个方法,addSub()、removeSub()、depend()、notify(),他们分别代表着添加、删除、收集、通知的功能。我们现在把这些方法和Object.defineProperty()结合起来,这样我们就可以收集到依赖了。

function defaineReactive(data, key, val) {
  //创建dep实例
  let dep = new Dep();
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      //收集依赖
      dep.depend();
      return val;
    },
    set: function (newVal) {
      if (val == newVal) {
        return;
      }
      val = newVal;
      //更新依赖
      dep.notify();
    },
  });
}

2.3实现Watcher

上面我们已经实现了单个属性的依赖收集。但是,一个响应式的属性有很多依赖,我们需要抽取出一个能通知到所有依赖的类,而我们在收集依赖的时候,只需要收集那个依赖的实例就好了。下面我们来实现一下Watcher:

/**
* 简单的匹配路径
*/
const baseRE = /[^\w.$]/;
function parsePath(path) {
  if (baseRE.test(path)) {
    return;
  }
​
  const subMessage = path.split(".");
  //返回一个函数,函数返回属性的值
  return function (obj) {
    for (let i = 0; i < subMessage.length; i++) {
      if (!obj) {
        return;
      }
      obj = obj[subMessage[i]];
    }
​
    return obj;
  };
}
​
/**
 * 观察者
 * @params vm 需要进行监听的对象
 * @params path 需要进行监听对象当中的属性
 * @params cb 回调函数,当数据发生更新的时候,会调用这个回调函数
 */
class Watcher {
  constructor(vm, path, cb) {
    this.vm = vm;
    this.getter = parsePath(path);
    this.cb = cb;
    this.value = this.get();
  }
​
  get() {
    window.target = this;
    let value = this.getter.call(this.vm, this.vm);
    return value;
  }
​
  update() {
    const oldValue = this.value;
    this.value = this.get();
    this.cb.call(this.vm, this.value, oldValue);
  }
}

这个代码可以把对象自动添加到Dep类当中去,因为我们在get()方法当中把this放到了window.target当中,而我们在Dep当中收集依赖的时候我们收集的是window.target当中的依赖,也就是说,当收集依赖的时候,会把Wacter的实例收集进去。这样我们就实现了Watcher的注入。假如我们需要监听data.a.b.c的变化情况那么我们需要先对其进行监听,当其发生变化的时候,让Dep类当中notify()调用实例的update()的方法。然后触发一开始传入进去的回调函数,完成一个闭环。

2.4、实现多个属性监听

其实上面已经实现了单个属性的监听,但是当我们要监听对象当中所有的属性的时候就太麻烦了,甚至所有属性的子属性,因此,我们需要封装一个Observer类来实现递归侦测对象当中的所有属性

/**
 * 将对象的所有子属性全部解析称为响应式
 */
function defaineReactive(data, prop, val) {
    //判断当前的键值是否是对象,如果是对象,那么继续通过Observer来进行处理
  if (typeof val === "object") {
    new Observer(val);
  }
​
  let dep = new Dep();
  Object.defineProperty(data, prop, {
    enumerable: true,
    configurable: true,
    get: function () {
      // 添加依赖
      dep.depend();
      return val;
    },
    set: function (newVal) {
      if (val === newVal) {
        return;
      }
      val = newVal;
      // 通知依赖更改
      dep.notify();
    },
  });
}
​
class Observer {
  constructor(value) {
    // 将外界传入的对象存储在类当中
    this.value = value;
    //判断是否是数组,不是数组调用walk方法
    if (!Array.isArray(value)) {
      this.walk(value);
    }
  }
​
  walk(value) {
      //获取对象的所有键
    let keys = Object.keys(value);
    //遍历所有的键,把键统统使用defaineReactive来实现监听响应
    for (let i = 0; i < keys.length; i++) {
      defaineReactive(value, keys[i], value[keys[i]]);
    }
  }
}

2.5、流程图示

avatar

三、总结

在vue当中监听对象的操作大致就像上面那样,但是还有缺陷,就是无法监听对象的删除和增减,因为新增加的对象并没有进行响应式的包装处理。因此,vue2给我吗提供了vm.$setvm.$delete这两个API来进行操作。还有这个响应式处理无法处理数组。