一文看懂Vue2的数据侦测原理

732 阅读10分钟

1.Object的变化侦测:

什么是变化侦测


Vue的状态发生改变的时候,框架知道是哪个状态发生改变(这点和react区别是react并不知道是哪一个状态发生了改变,只能通过vdom暴力比对来找出哪些Dom节点需要重新渲染),可以进行更细粒度的更新;所谓更细粒度更新就是说有一个状态绑定着n个依赖组件,一旦这个状态发生变化,这n个依赖组件内部再根据vdom比对进行重新渲染;

如何追踪变化


目前为止vue用Object.defineProperty来侦测变化,Vue3将使用Proxy来侦测变化。

Object.defineProperty(data, key, {
  enumerabletrue,
  configurabletrue,
  getfunction({
    return val
  },
  setfunction(newVal{
    if(newVal === val) {
      return
    }
    val = newVal
  }
})

依赖收集

<template>
  {{ name }}
</template>


收集依赖就是把组件中用到name的地方都收集起来,当name发生变化的时候把之前收集起来的依赖循环通知一下更新就好;在getter中收集依赖,在setter中触发更新;
那么应该把依赖收集到哪里去呢?首先。每个属性都应该有一个依赖数组Dep

let dep = [] // 新增
Object.defineProperty(data, key, {
  enumerabletrue,
  configurabletrue,
  getfunction({
    dep.push(window.target) // 新增depend
    return val
  },
  setfunction(newVal{
    if(newVal === val) {
      return
    }
    // 新增 notify
    for(let i = 0;i < dep.length;i++) {
      dep[i](newVal, val)
    }
    val = newVal
  }
})


window.target是全局唯一的watcher,每一个data里的属性都有一个watcher,每一个watch也会生成一个watcher。watcher在实例化的时候会把window.target设置成自己的实例,然后再读一下该属性的值,这会触发该属性的getter把window.target添加到属性的依赖数组Dep中去,之后每当属性值发生变化时,就会循环遍历Dep数组触发update方法,也就是watcher中的update方法,而update方法会执行回调函数将newvalue和oldvalue传入。
现在已经可以侦测数据的变化了,但是只有单一属性做到了,我们希望把数据中所有属性都侦测到,现在需要一个Observer类,用来给数据所有子属性添加getter和setter,在这里只针对类型为Object的属性进行处理(不包括数组)。

Object数据侦测的问题


我们在这之前都是直接对属性进行修改并成功的监测到了变化,但是有一个问题出现了,当我们对属性进行增加或者删除某个子属性的时候,我们的setter并不会监测到变化。为了解决这个问题,vue提供了两个API:vm.$set和$delete。

总结


变化侦测就是侦测数据的变化。当数据发生变化时,要能侦测到并发出通知。

Object可以通过Object.defineProperty将属性转换成getter/setter的形式来追踪变化。读取数据时会触发getter,修改数据时会触发setter。

我们需要在getter中收集有哪些依赖使用了数据。当setter被触发时,去通知getter中收集的依赖数据发生了变化。

收集依赖需要为依赖找一个存储依赖的地方,为此我们创建了Dep类,它用来收集依赖、删除依赖和向依赖发送消息等;

所谓的依赖,其实就是Watcher。只有Watcher触发的getter才会收集依赖。哪个Watcher触发了getter,就把哪个Watcher收集到Dep中。当数据发生变化时,会循环依赖列表,把所有的Watcher通知一遍。

Watcher的原理是先把自己设置到全局唯一的指定位置(window.target),然后读取数据。因为读取了数据,所以会触发这个数据的getter。接着,在getter中就会从全局唯一的那个位置读取当前正在读取数据的Watcher,并把这个Watcher收集到Dep中。通过这样的方式,Watcher可以主动去订阅任意一个数据的变化。

此外,我们创建了Observer类,它的作用是把一个object中的所有数据(包括子数据)都转换成响应式的,也就是它会侦测object中所有数据(包括子数据)的变化。

由于在ES6之前js没有元编程的能力,所以在对象上新增属性和删除属性无法被追踪到,但是Vue提供了两个API来解决这个问题--vm.$set和$delete。

2.Array的变化侦测:


通过push等方法来改变数组的时候,实现变化侦测的手段只能通过在Array.prototype上对相关方法进行拦截(因为ES6前没有元编程的能力);

拦截器


拦截器是和Array.prototype一样的Object,里边包含的属性也是一样的,只不过里边改变数组的方法是我们处理过的。

经过整理,改变数组的方法一共有7个,分别是push、pop、shift、unshift、splice、sort、reverse。

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, {
    valuefunction mutator (...args){
      return original.apply(this, args)
    },
    enumerablefalse,
    writabletrue,
    configurabletrue
  })
})


我们创建了arrayMethods拦截方法,它继承自Array.prototype,接下来使用Object.defineProperty来封装改变数组的7个方法,当使用push的时候实际上是调用的arrayMethods.push,而arrayMethods.push是函数mutator,在mutator中执行original原生方法来做它该做的事。因此,我们就可以在mutator中做一些其他的事,比如说发送变化通知!

使用拦截器覆盖Array原型


拦截器实现之后,需要让他覆盖Array.prototype才能生效,但是又不能直接覆盖,因为会污染全局的Array原型链,我们希望只拦截被转换成响应式数据的Array的原型!所以我们可以在Observer中进行覆盖:

export class Observer {
  constructor(value) {
    this.value = value
    if(Array.isArray(value)) {
      value.__proto__ = arrayMethods
    } else {
      this.walk(value)
    }
  }
}


通过proto可以很巧妙的覆盖Array的原型,但是有些浏览器并不支持这个属性,所以Vue在不支持proto的时候把拦截器上的方法(push、shift...)挂载到数组的属性上(非常粗暴)。

// 判断是否支持__proto__
const hasProto = '__proto__' in {}

依赖收集


我们现在可以拦截数组的变化了,但是变化之后还需要通知Watcher,这需要先把Watcher收集起来。和Object一样的是,Array也通过getter收集依赖!和Object不一样的是,Array通过拦截器通知Watcher(Object通过setter)!

Array的依赖(Watcher)只能收集到Observer中。原因是因为只有Observer能在getter和拦截器中都能访问到。将Dep保存在Observer属性上后,可以在getter中通过Observer实例的dep方法将依赖加入到Dep中。那么怎么在拦截器中访问到Observer呢,Vue的做法是在Array的属性上挂载了一个叫做'ob'的属性,它的值就是Observer实例。既然在拦截器中拿到了Observer,Observer上也有了Dep依赖集,那么我们就可以在拦截器中对依赖进行通知了。

在这里我们已经知道了数组自身变化的侦测是如何实现的了,但是我们还要对数组内部元素进行变化侦测,和Object一样,也是通过Observer类进行递归操作对所有子元素进行处理转换成响应式数据。

但是如果在数组中新增了元素,这些新增的元素也是需要转换成响应式的。这其实并不难,只需要获取新增的元素,并且用Observer来将他进行转换就可以了。只需要在拦截器中判断操作数组的方法是否是push、unshift、splice就可以了,如果是的话就把新增加的元素缓存下来,然后用Observer进行处理。

Array数据侦测的问题


我们已经了解了Vue是如何侦测Array的变化了--原型方法拦截器。

但是正是用了拦截器拦截了原型上的方法,出现了一些问题。因为我们只对数据的原型方法进行了拦截,但是数组本身的一些属性却没有办法拦截到,比如:

this.list[0] = 2
this.list.length = 0


以上对数组的改变是无法侦测到的。所以也不会触发re-render或者watch等。

总结


Array追踪变化的方式和Object不一样。因为它是通过方法来改变内容的,所以我们通过创建拦截器去覆盖数组原型的方式来追踪变化。

为了不污染全局Array.prototype,我们在Observer中只针对那些需要侦测变化的数组使用proto来覆盖原型方法,但proto在ES6之前并不是标准属性,不是所有浏览器都支持它。因此,针对不支持proto的浏览器,我们直接循环遍历出拦截器中的方法,然后直接挂载到数组的属性上。

Array收集依赖的方式和Object一样。都是在getter中收集。但是由于使用依赖的位置不同,数组要在拦截器中向依赖发消息,所以依赖不能像Object那样保存在defineReactive中,而是把依赖保存在了Observer实例上。

在Observer中,我们队每个侦测了变化的数据都标上了印记ob,并把Observer实例保存在ob上。这主要有两个作用,一方面是为了标记数据是否被侦测了变化(保证同一个数据只被侦测一次),另一方面可以很方便的通过数据取到ob,从而拿到Observer实例中的Dep依赖集。当拦截到数组发生变化时,向依赖发出通知。

除了侦测数组自身的变化外,数组中元素发生的变化也要侦测。我们在Observer中判断如果当前被侦测的数据是数组,则调用ObserverArray方法将数组每一个元素都转换成响应式的并侦测变化。

除了侦测已有数据外,当用户使用push等方法向数组中新增数据时,新增的数据也要进行变化侦测。我们使用当前操作数组的方法来进行判断,如果是push、unshift和splice,则从参数中将新增数据提取出来,然后用ObserverArray对新增的数据进行变化侦测。

由于在ES6之前,js并没有元编程的能力,所以对于数组类型的数据,一些语法无法追踪到变化,只能拦截原型上的方法,而无法拦截数组特有的语法,比如使用length清空数组的操作就无法拦截到。