深入了解Vue响应式之——数组侦测

375 阅读7分钟

深入了解Vue响应式之——数组侦测

一、思路

在Vue2当中,关于对象的侦测有Object.defaultProperty()来对对象的属性进行拦截,从而进行依赖的收集与更新。但是在数组当中,我们ES5并没有为我们提供可以拦截数组变化的能力(ES6中JavaScript为我们提供了Proxy)。但是,聪明的程序员可不会被这点困难难倒,既然你不给我拦截数组的能力,那么就自己创造一个方法来拦截数组。简而言之,就是通过自定义的方法来来覆盖数组上面的原型方法,这样我们就可以在数组调用原型方法的时候对它进行拦截了。

二、自定义拦截器

经过整理。我们发下数组当中可以对自身内容进行更改的方法一共有七个,分别是push、pop、shif、unshift、sort、splice、reverse。我们需要做的就是通过自定义的方法来替换掉这七个原型方法。

/**
 * 对Array的实现
 *///获取数组原型
const arrayProto = Array.prototype;
​
//创建一个对象,对象的原型指向数组的原型
const arrayMethods = Object.create(arrayProto);
["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
  (method) => {
    //缓存原始方法
    const original = arrayProto[method];
    //为特定的方法创建拦截
     Object.defineProperty(arrayMethods, method, {
       value: function mutator(...args) {
         //创建拦截函数,当我们使用上面特定的方法时,会调用这个函数
         return original.apply(this, args);
       },
         enumerable:false,
         writable:true,
         configurable:true,
     });
  });

在上面的代码当中,我们通过arrayProto获取到了数组的原型,然后通过Object.create()来创建了一个继承于arrayProto的新对象arrayMethodsarrayMethods的原型指向arrayProto。然后利用Object.defineProperty()的方法将为我们整理出来的数组方法挂载到arrayMethods上面,并且每个数组方法都是我们自定义的,在我们自定义的数组方法当中,我们可以对数组的元素进行操作,比如依赖收集和更新。然后调用数组的原型方法,之前已经被存储在original当中,所以我们只需要将original指向this,并且将参数传进去,注意这里的this指的是调用方法的数组。

三、将拦截器覆盖数组的原型

有了拦截器之后,我们需要将拦截器挂载到我们需要拦截的数组上面去。但是我们又不能直接覆盖Array的原型,因为这样会污染全局的Array,因此我们需要找一个合适的地方来覆盖原型。当我们把一个数组变成响应式的时候,需要通过Observe来转换。因此,我们可以在Observe来覆盖指定数组的原型。

/**
 * 使用拦截器覆盖原型
 */
​
//判断对象上是否包含原型
const hasOwn = "__proto__" in {};
​
//获取arrayMethods上所有属性,包含不可枚举的
const arrayKeys = Object.getOwnPropertyNames(arrayMethods);
class Observer {
  constructor(value) {
    this.value = value;
​
    if (Array.isArray(value)) {
      //将添加响应式的数组的原型替换掉
      const augment = hasOwn ? protoAugment : copyAugment;
      augment(value, arrayMethods, arrayKeys);
    } else {
      //添加响应式的对象
      this.walk(value);
    }
  }
​
  walk(obj) {
    const keys = Object.keys(obj);
​
    for (let i = 0; i < keys.length; i++) {
      defaineReactive(obj, keys[i], obj[keys[i]]);
    }
  }
}
​
//判断__proto__是否可用
function protoAugment(target, src, keys) {
  target.__proto__ = src;
}
​
//如果__proto__不可用
function copyAugment(target, src, keys) {
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i];
    def(target, key, src[key]);
  }
}

在上面的代码当中,hasOwn是用来判断当前环境当中是否支持__proto__来获取原型,因为并不是所有的的浏览器都支持这样的做法,因此我们需要处理不能使用__proto__来获取原型的情况。

Vue当中的做法是如果不能通过__proto__来获取原型,那么就直接将拦截器挂载到数组上,这样在调用指定方法的时候,会优先调用我们设置好的拦截器,因为只有数组自身找不到的方法才会去原型上面寻找。

四、如何收集依赖

上面,我们创建并且在Observe里将拦截器覆盖了数组的原型方法,但是我们发现,现在还是什么的事情都做不了。为什么呢?因为我们之所以创建拦截器,本质上是为了得到一种能力,能实现对数组内容进行监测的能力,现在我们已经具备了这样的能力,但是我们该通知谁来处理这种变化呢?答案肯定是通知Dep当中的依赖(Watcher),但是我们该如何收集依赖呢?

在对象当中,我们通过definePropertyget属性来获取依赖,当有依赖访问属性值的时候,就对触发dep.depend(),然后将依赖收集起来。在数组当中,我们也可以按照上面的思路来实现:

{
    list:[1,2,3,4,5,6]
}

如果是上面这样的数组,那么想要获取数组,那么必须访问list这个key,也就是说,不管value是什么,只要想访问Object某个属性的数据,肯定得通过key来读取这个value。因此在读取的时候,会先触发这个list的key。

function defaineReactive(data, key, val) {
  let childOb = observe(val); // 获取Observe的实例
  let dep = new Dep();
  if (typeof val === "object") {
    new Observer(val);
  }
  //添加监听响应
  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();
    },
  });
}

上面的代码显示了在那个位置去收集依赖。所以,Array在getter当中收集依赖,在拦截器当中触发依赖发生更新。

五、依赖列表存在哪?

知道了如何收集依赖之后,我们还有决定依赖存放在哪?这个存放的地点必须能让getter拦截器都可以访问到。在Vue当中,把依赖存放于Observe当中,因为在Observe当中,这两个地方都可以访问到Observe的实例。

class Observer {
  constructor(value) {
    this.value = value;
    //收集依赖,新增dep
    this.dep = new Dep();
​
    if (Array.isArray(value)) {
      //将添加响应式的数组的原型替换掉
      const augment = hasOwn ? protoAugment : copyAugment;
      augment(value, arrayMethods, arrayKeys);
      this.observeArray(value);
    } else {
      //添加响应式的对象
      this.walk(value);
    }
  }
​
  walk(obj) {
    const keys = Object.keys(obj);
​
    for (let i = 0; i < keys.length; i++) {
      defaineReactive(obj, keys[i], obj[keys[i]]);
    }
  }
​
  //将数组当中的所有子元素都变成响应式数据
  observeArray(items) {
    for (let i = 0; i < items.length; i++) {
      observe(items[i]);
    }
  }
}

六、收集依赖

Dep实例保存在Observe的属性上之后,我们可以在getter中像下面的代码当中那样收集依赖

function defaineReactive(data, key, val) {
  let childOb = observe(val); // 获取Observe的实例
  let dep = new Dep();
  if (typeof val === "object") {
    new Observer(val);
  }
  //添加监听响应
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      dep.depend();
      //新增依赖
      if (childOb) {
        childOb.dep.depend();
      }
      return val;
    },
    set: function (newVal) {
      if (val === newVal) {
        return;
      }
​
      val = newVal;
      dep.notify();
    },
  });
}
​
/**
 * 尝试为vualue创建一个Observe实例
 * 如果实例创建成功,直接返回新创建的Observe实例
 * 如果value已经存在一个Observe实例,则直接返回它
 *///判断value是否为对象
function isObject(obj) {
  if (typeof obj === "object") {
    return true;
  } else {
    return false;
  }
}
//判断value上面是否有__ob__属性,
function hasOwnProperty(obj, key) {
  if (Object.hasOwnProperty(obj, key)) {
    return true;
  } else {
    return false;
  }
}
function observe(obj) {
  if (!isObject(obj)) {
    return;
  }
​
  let ob;
  if (hasOwnProperty(obj, "__ob__") && obj.__ob__ instanceof Observer) {
    ob = obj.__ob__;
  } else {
    ob = new Observer(obj);
  }
​
  return ob;
}

上面的代码当中,首先需要获取Observe的实例,如果当前对象已经包含了Observe的实例,那么直接返回实例,如果没有实例的话,那么我们就新创建一个实例返回出去,资源是为了防止重复侦测value的变化。然后我们在get属性当中,通过childOb来获取dep,然后进行依赖收集。

七、在拦截器中获取Observe的实例

上面我们已经在getter当中获取Observe的实例,然后进行依赖收集,在拦截器当中,我们也需要获取Observe的实例,来进行依赖更新。

因为Array拦截器是对原型的一种封装,所以可以在拦截器当中访问this(当前正在被操作的数组)。

/**
 * 使用拦截器覆盖原型
 *///判断对象上是否包含原型
const hasOwn = "__proto__" in {};
​
//获取arrayMethods上所有属性,包含不可枚举的
const arrayKeys = Object.getOwnPropertyNames(arrayMethods);
class Observer {
  constructor(value) {
    this.value = value;
    //收集依赖
    this.dep = new Dep();
​
    //给数组对象的原型上添加不可枚举的__ob__属性,属性包含Observe实例
    def(value, "__ob__", this);
​
    if (Array.isArray(value)) {
      //将添加响应式的数组的原型替换掉
      const augment = hasOwn ? protoAugment : copyAugment;
      augment(value, arrayMethods, arrayKeys);
      this.observeArray(value);
    } else {
      //添加响应式的对象
      this.walk(value);
    }
  }
​
  walk(obj) {
    const keys = Object.keys(obj);
​
    for (let i = 0; i < keys.length; i++) {
      defaineReactive(obj, keys[i], obj[keys[i]]);
    }
  }
​
  //将数组当中的所有子元素都变成响应式数据
  observeArray(items) {
    for (let i = 0; i < items.length; i++) {
      observe(items[i]);
    }
  }
}
​
//将属性添加到对象上,并且不可枚举
function def(obj, key, val, enumerable) {
  Object.defineProperty(obj, key, {
    value: val,
    //让他不可枚举的添加到数组上去
    enumerable: !!enumerable,
    Writable: true,
    configurable: true,
  });
}

通过这样,我们就可以在value当中访问到Observe的实例,然后通过那道Observe的实例来更新依赖。def函数的作用是将一个属性通过Object.defineProperty添加到指定对象上,并且让添加的属性不可枚举。

当然,__ob__的作用不仅仅是为了在拦截器中访问Observe实例这么简单,还可以用来标记当前value是否已经被Observe转换成了响应式数据。也就是说,所有的响应式数据上面都有一个__ob__属性来表示他们是响应式的。

当value被标记为响应式的时候,就可以通过__ob__来方位Observe的实例,这样我们就可以在拦截器当中获取Observe实例。

八、向数组的依赖发送通知

当被侦测的数组发生了变化的时候,会向依赖发送通知。此时要能访问到依赖,前面我们已经讲过,我们可以通过__ob__属性来获取Observe的实例,而依赖又保存在Observe实例当中的dep当中。因此我,1可以这样操作

/**
 * 对Array的实现
 *///获取数组原型
const arrayProto = Array.prototype;
​
//创建一个对象,对象的原型指向数组的原型
const arrayMethods = Object.create(arrayProto);
["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
  (method) => {
    //缓存原始方法
    const original = arrayProto[method];
    //为特定的方法创建拦截
     Object.defineProperty(arrayMethods, method, {
       value: function mutator(...args) {
         //获取Observe的实例保存到ob上
         const ob = this.__ob__;
         //触发更新依赖
         ob.dep.notify();
         //创建拦截函数,当我们使用上面特定的方法时,会调用这个函数
         return original.apply(this, args);
       },
         enumerable:false,
         writable:true,
         configurable:true,
     });
  });

在上面的代码当中,我们调用ob.dep.notify();去更新通知依赖(Watcher)数据发生了变化。

九、侦测数组当中元素的变化

上面说到的侦测数组的变化,是侦测数组自身发生的变化,是否新增元素,删除元素等等。但是对于数组的子元素,我们并没有进行侦测,因此,我们接下来要对数组的子元素进行侦测,实现的方法也很简单,只需要把数组的每一个子元素都添加到响应式当中就可以了,很显然,这里使用递归来实现。

/**
 * 使用拦截器覆盖原型
 */
​
//判断对象上是否包含原型
const hasOwn = "__proto__" in {};
​
//获取arrayMethods上所有属性,包含不可枚举的
const arrayKeys = Object.getOwnPropertyNames(arrayMethods);
class Observer {
  constructor(value) {
    this.value = value;
    //收集依赖
    this.dep = new Dep();
​
    //给数组对象的原型上添加不可枚举的__ob__属性,属性包含Observe实例
    def(value, "__ob__", this);
​
    if (Array.isArray(value)) {
      //将添加响应式的数组的原型替换掉
      const augment = hasOwn ? protoAugment : copyAugment;
      augment(value, arrayMethods, arrayKeys);
      //将数组的子元素也添加到侦测
      this.observeArray(value);
    } else {
      //添加响应式的对象
      this.walk(value);
    }
  }
​
  walk(obj) {
    const keys = Object.keys(obj);
​
    for (let i = 0; i < keys.length; i++) {
      defaineReactive(obj, keys[i], obj[keys[i]]);
    }
  }
​
  //将数组当中的所有子元素都变成响应式数据
  observeArray(items) {
    for (let i = 0; i < items.length; i++) {
      observe(items[i]);
    }
  }
}
​
//将元素变成响应式数据
function observe(obj) {
  if (!isObject(obj)) {
    return;
  }
​
  let ob;
  if (hasOwnProperty(obj, "__ob__") && obj.__ob__ instanceof Observer) {
    ob = obj.__ob__;
  } else {
    ob = new Observer(obj);
  }
​
  return ob;
}

在上面的代码当中,我们早Observe当中添加了一个新的方法observeArray通过遍历数组的所有子元素,将他们都添加到observe当中变成响应式数据。限制我们只需要将一个数据丢进去,Observe就会将这个数据的所有子元素全部转换为响应式数据。

十、侦测数组新增元素的变化

数组当中的一些新增数组元素的,比如push等,而新增的内容我们也需要对它进行响应式来侦测变化。基本的实现思路就是当调用push、unshif、splice的时候,对新的的元素进行响应式处理。

/**
 * 对Array的实现
 *///获取数组原型
const arrayProto = Array.prototype;
​
//创建一个对象,对象的原型指向数组的原型
const arrayMethods = Object.create(arrayProto);
["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
  (method) => {
    //缓存原始方法
    const original = arrayProto[method];
    def(arrayMethods, method, function mutator(...args) {
      const ob = this.__ob__;
      let inserted;
      //对新增数组元素进行响应式梳理
      switch (method) {
        case "push":
        case "unshift":
          inserted = args;
          break;
        case "splice":
          inserted = args.slice(2);
          break;
      }
      //是新增元素添加搭到响应式监听
      if (inserted) {
        ob.observeArray(inserted);
      }
      const retult = original.apply(this, args);
      //发送消息,告诉watcher这里发生了变化
      ob.dep.notify();
      return retult;
    });
  }
);

上面的代码当中,我们通过单独对push、unshift、splice的判断,来获取新增元素inserted,再对新增元素进行判断,如果有新增元素,就通过Observe实例来调用observeArray方法,将新增元素也进行响应式处理。

十一、问题

上面整体下来,我们已经对Array的变化进行了侦测,而变化侦测的实现是通过拦截原型的方式进行的。但是,正式因为这种方式,我们有些数组的变化是拦截不到的,比如

this.arr.length = 0;

这种通过调用length直接把数组清空的操作是没办法拦截的,也不会触发Watcherre-render

或者当使用下标修改数组的元素的时候,也是无法侦测到数组变化的

this.arr[2] = 2;

这是方式也无法进行拦截。

十二、总结

Array的变化侦测和Object的不一样,因为他没有通过javascript本身来监测数组属性变化的能力,所以我们需要自定义拦截器来覆盖数组的原型来侦测变化的。

为了不污染全局的Array原型,我们通过在Observe当中获取需要侦测的数组,然后通过判断__proto__来决定是覆盖原型还是直接挂载到被侦测的数组上面。

Array的依赖收集也和Object的一样,都是在访问Object.definePropertyget属性的时候来收集依赖。但是,存储依赖的地方不一样,Object存储在defaultReactive当中,但是Array保存在Observe当中。为什么会有这样的区别?因为Array使用依赖的地方不一样,Array使用依赖的地方在拦截器当中,要在拦截器当中向依赖发送消息。所以必须保存在一个getter拦截器都可以访问到的地方。

在Observe当中,我们把每一个需要侦测数据变化的对象都添加一个__ob__属性,属性当中保存着Observ的实例。这主要有两个作用,一方面是问了放在数据被重复侦测(我们只需要判断被侦测的数据上是否存在__ob__就可以了),另一方面,我们还可以在getter拦截器当中通过__ob__来访问Observe实例,然后进行依赖的收集和通知。

当然,除了侦测自身的变化之外,数组的子元素也需要侦测变化,因此,在Oberve当中,我们对数组的子元素进行了遍历,并且将每个子元素通过observe函数来对它进行响应式处理,并且侦测变化。

除了已经在数组当中存在的数据之外,我们还需要侦测新增加的子元素,因此我们需要对push、unshif、splice这几个可以增加数组元素的方法进行筛选,然后获取到新增加的元素。通过调用Observe实例上的observeArray方法来进行响应式处理,其本质也是调用observe

在ES6之前,javascript并没有元编程的能力,所以对于数组类型的数据,一些语法无法追踪到变化,只能拦截原型上的方法,而无法拦截数组特有的方法,例如使用length清空数组和使用下标增加数组元素等就无法进行拦截。