Vue 2数据响应式原理


0.数据响应式的概念

使用 Vue 时,我们只需要修改数据(state),视图就能够获得相应的更新,这就是响应式系统。要实现一个自己的响应式系统,我们首先要清楚做什么:

  1. 数据劫持:当数据变化时,我们可以做一些特定的事情
  2. 依赖收集:我们要知道那些视图层的内容(DOM)依赖了哪些数据(state)
  3. 派发更新:数据变化后,如何通知依赖这些数据的 DOM

接下来,我们来一步步实现一个响应式系统

1.数据劫持

对对象的监测

首先实现一个简单的数据劫持

// defineReactive方法的作用是将数据变成响应式的,给对象上的每一个属性设置getter和setter
export default function defineReactive(data, key) {
  var val = data[key];
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get() {
      console.log("你在访问" + key + "属性", val);
      return val;
    },
    set(newVal) {
      console.log("你在改变" + key + "属性", newVal);
      if (val === newVal) return;
      val = newVal;
    },
  });
}

定义var obj = { a: 3 },再调用 defineReactive(obj,a),这样在控制台查看 obj 的 a 属性时就会输出"你在访问a属性, 3",对 a 属性重新赋值时就会输出你在改变a属性, newVal。这样就实现了简单的数据劫持。

需要注意的是这里其实使用了闭包,在 get 和 set 方法中 val 的值是函数外部的所以形成了闭包,可以在控制台查看 a 属性的 get 方法,形成的闭包就在它的 Scopes 属性中,结果如下:

Closure

接着我们要遍历 obj 的属性,给每个属性都调用defineReactive()方法,并实现对象的深度监测。创建一个 observe 函数如下

import Observer from "./Observer.js";

// observe函数的作用是返回一个Observer实例ob
export default function observe(value) {
  if (typeof value !== "object") return;
  var ob;
  // 如果对象存在__ob__属性则说明已经实现了响应式,无需再生成Observer实例
  if (typeof value.__ob__ !== "undefined") {
    ob = value.__ob__;
  } else {
    ob = new Observer(value);
  }
  return ob;
}

observe 的函数的作用是对 object 类型的数据调用 Observer 类的构造方法实现响应式。注意这句代码if (typeof value !== "object") return;,这是对对象深度监测的结束条件。这里返回的 ob 是一个 Observer 实例,可以先暂且不管。

Observer 类的实现如下:

import { def } from "./utils.js";
import defineReactive from "./defineReactive";
import observe from "./observe.js";

// Observer类的作用是是在每一层对象上生成一个__ob__属性,值为新创建的Observer实例ob,
// 可以理解为凡是有该属性的对象我们都可以监测到它的变化
export default class Observer {
  constructor(value) {
    def(value, "__ob__", this, false);
    this.walk(value);
  }
  walk(value) {
    for (let k in value) {
      defineReactive(value, k);
    }
  }
}

ES6 的 class 可以看作只是一个语法糖,它本质上和 ES5 的构造函数一样的。Observer 类的构造方法中调用了walk()方法遍历传入对象上的属性并调用defineReactive()方法实现数据劫持。这里的def()方法会在每一层对象上生成一个 ob 属性,值为新创建的 Observer 实例 ob,可以理解为凡是有该属性的对象我们都可以监测到它的变化。

def 方法的实现

export const def = function (obj, key, value, enumerable) {
  Object.defineProperty(obj, key, {
    value,
    enumerable,
    writable: true,
    configurable: true,
  });
};

接着我们还要在 defineReactive 函数中做一些修改,

import observe from "./observe";

// defineReactive方法的作用是将数据变成响应式的,给对象上的每一个属性设置getter和setter
export default function defineReactive(data, key) {
  var val = data[key];
  // 这里会递归
  observe(val);
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get() {
      console.log("你在访问" + key + "属性", val);
      return val;
    },
    set(newVal) {
      console.log("你在改变" + key + "属性", newVal);
      if (val === newVal) return;
      val = newVal;
      observe(newVal);
    },
  });
}

首先会判断数据是否为对象,如果是对象则调用 observe 方法进行递归调用,直到这里的 val 不是 object 类型的数据(递归的终止条件),再对数据进行 getter 和 setter,然后递归函数返回上一层。。。这样就实现对对象的深度监听。这里还要考虑到 set 方法中新设置的值可能为对象,所以要对新设置的 val 进行 observe 数据劫持。

可以看出,主要的三个函数的调用关系如下:

函数关系图

至此我们就对对象的所有属性进行了深度监测。通过对源码的分析,我们也能理解了为什么官网说 Vue 无法检测 property 的添加或移除。就是由于 Vue 会在初始化实例时对 property 执行 getter/setter 转化,所以 property 必须在 data 对象上存在才能让 Vue 将它转换为响应式的。后添加的 property 没有 getter/setter,property 的移除也无法触发 getter/setter。

对数组的监测

Vue 对数组的监测并不是像对象那样监测它身上的每一个属性(每一个数组项),据 Vue 的作者说是因为性能问题,所以 Vue 对数组的监测方法是重写了会造成数组变化的'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'这七种原生的数组方法,也就是说数组调用这七种方法才能被 vue 监测到。先来看一张图

原型关系图

重写原生数组方法的实现如下array.js

import { def } from "./utils.js";

// 以Array.prototype为原型创建arrayMethods对象
// arrayMethods对象会继承Array.prototype上的所有方法
export const arrayMethods = Object.create(Array.prototype);
const arrayPrototype = Array.prototype;
const methodsNeedChange = [
  "push",
  "pop",
  "shift",
  "unshift",
  "splice",
  "sort",
  "reverse",
];

// 遍历上述数组方法名,依次将上述重写后的数组方法添加到arrayMethods对象上
methodsNeedChange.forEach((methodsName) => {
  // 备份原来的数组方法
  var original = arrayPrototype[methodsName];
  // 将重写后的方法定义到arrayMethods对象上,def 函数的第三个参数就是重写后的方法
  def(
    arrayMethods,
    methodsName,
    function (...args) {
      console.log("数组" + methodsName + "方法重写成功!");
      // 调用数组原始方法,并改变this指向为调用该方法的数组,传入参数args,返回执行结果
      const result = original.apply(this, args);
      // 数组身上有一个__ob__属性,它是Observer实例,继承了Observer类的方法
      const ob = this.__ob__;
      // push, unshift, splice方法会添加新数据需要进行特殊处理
      // inserted存储新添加的数据
      let inserted;
      switch (methodsName) {
        case "push":
        case "unshift":
          inserted = args;
          break;
        case "splice":
          inserted = args.slice(2);
          break;
      }
      // 对添加的数据执行observe
      if (inserted) {
        ob.observeArray(inserted);
      }
      return result;
    },
    false
  );
});

重写完数组的上述 7 种方法外,我们还需要将这些重写的方法应用到数组上,因此在 Observer 构造函数中,在监听数据时会判断数据类型是否为数组。当为数组时,则直接将当前数据的原型__proto__指向重写后的数组方法对象arrayMethods,并对数组的每一项执行observe

import { def } from "./utils.js";
import defineReactive from "./defineReactive";
import { arrayMethods } from "./array.js";
import observe from "./observe.js";

export default class Observer {
  constructor(value) {
    def(value, "__ob__", this, false);
    // 判断是否为数组,是则将该数组的原型指向重写后的数组方法对象
    if (Array.isArray(value)) {
      value.__proto__ = arrayMethods;
      // 对数组的每一项进行observe
      this.observeArray(value);
    } else {
      this.walk(value);
    }
  }
  walk(value) {
    for (let k in value) {
      defineReactive(value, k);
    }
  }
  observeArray(arr) {
    for (let i = 0, l = arr.length; i < l; i++) {
      observe(arr[i]);
    }
  }
}

至此我们就实现对数组的监测,也明白了为什么当我们利用索引直接设置一个数组项时或者修改数组的长度时 vue 不能监测到。

2.依赖收集 && 派发更新

为了实现当数据发生变化时只更新与这个数据有关的 DOM 结构,我们就需要使用订阅-发布模式。

发布订阅模式

从图我们可以简单理解:Dep 可以看做是书店,Watcher 就是书店订阅者,而 Observer 就是书店的书,订阅者在书店订阅书籍,就可以添加订阅者信息,一旦有新书就会通过书店给订阅者发送消息。

首先实现 Watcher 类

import Dep from "./Dep";

export default class Watcher {
  constructor(data, expression, cb) {
    // data: 数据对象,如obj
    // expression:表达式,如a.b.c,根据data和expression就可以获取watcher依赖的数据
    // cb:依赖变化时触发的回调
    this.data = data;
    this.expression = expression;
    this.cb = cb;
    // 初始化watcher实例时订阅数据
    this.value = this.get();
  }

  get() {
    // 进入依赖收集阶段,把实例化的watcher存在Dep.target中,在getter时取出放入dep.subs数组中
    Dep.target = this;
    const value = parsePath(this.data, this.expression);
    // 依赖收集完成后把Dep.target重置为null
    Dep.target = null;
    return value;
  }

  // 当收到数据变化的消息时执行该方法,从而调用cb回调完成视图更新
  update() {
    // 对依赖的数据进行更新
    this.value = parsePath(this.data, this.expression);
    this.cb();
  }
}

function parsePath(obj, expression) {
  const segments = expression.split(".");
  for (let key of segments) {
    if (!obj) return;
    obj = obj[key];
  }
  return obj;
}

这里的 watcher 可以看作是一个插值表达式,一个数据可能会被多个 watcher 订阅,我们要创建一个数组把它们收集起来。上面代码可以看到需要在 getter 方法执行时收集 watcher,所以在defineReactive方法中添加操作

// 这是省略后的代码
export default function defineReactive(data, key) {
  // 创建dep实例对象,用来为每一个数据存储watcher,dep会存在闭包中
  const dep = new Dep();
  get() {
    // 这里的Dep.target存放了watcher实例
    // 如果处在watcher收集阶段则调用depend方法将该watcher放入dep.subs数组中
    if(Dep.target){
      dep.depend();
    }
    return val;
  },
  set(newVal) {
    // 发布订阅模式,val发生变化时通知dep
    dep.notify();
  }
}

Dep 类用来依赖收集和派发更新,即收集 watcher 和当数据变化时通知 watcher,它的实现如下

export default class Dep {
  constructor() {
    this.subs = [];
  }

  depend() {
    this.addSub(Dep.target);
  }
  notify() {
    const subs = [...this.subs];
    subs.forEach((s) => s.update());
  }
  addSub(sub) {
    this.subs.push(sub);
  }
}

至此我们就完成了依赖的收集和派发更新,就是把每一个数据的订阅者(watcher)收集起来,放在 dep.subs 数组中。当数据发生变化时通知 Dep,Dep 再遍历 subs 数组通知每一个 watcher 从而更新视图。具体实现可详细阅读上面的代码。

总结

  1. Vue 在 Init 初始化的时候会对 data 执行 observe 方法,在 Observer 类的构造器中用了 Object.defineProperty 方法实现了 data 的 getter/setter 操作,将 data 变为可以成观察的,这样就完成了数据劫持。
  2. 接着在模板编译过程中的指令或者数据绑定(插值)都会实例化一个 Watcher 实例,实例化过程中会触发 get() 方法将该实例存放在 Dep.target 中,接着会对依赖的数据进行求值,求值就会触发数据的getter,在getter方法中就会取出Dep.target的值从而将依赖该数据的 watcher 存放在dep.subs数组中,这样就完成了依赖收集。
  3. 最后当 data 中某个属性值变化时,我们在setter使用dep.notify方法通知 Dep,dep.notify方法则会遍历 subs 数组通知依赖该数据的所有 watcher 执行update()方法派发更新,从而完成视图的更新。

以上我们就对 Vue 的响应式原理中的部分代码进行了简单的分析和实现,有很多地方纯属个人理解,作者目前水平有限,文章一些地方可能还存在不足或有误,不足为训。


文章作者: Cubby
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Cubby !
  目录