第 2 章 Object的变化侦测

第 2 章 Object的变化侦测

大部分人不会想到ObjectArray的变化侦测采用不同的处理方式。事实上,它们的侦测方式确实不一样。在这一章中,我们将详细介绍Object的变化侦测。

2.1 什么是变化侦测

Vue.js会自动通过状态生成DOM,并将其输出到页面上显示出来,这个过程叫渲染。Vue.js的渲染过程是声明式的,我们通过模板来描述状态与DOM之间的映射关系。

通常,在运行时应用内部的状态会不断发生变化,此时需要不停地重新渲染。这时如何确定状态中发生了什么变化?

变化侦测就是用来解决这个问题的,它分为两种类型:一种是“推”(push),另一种是“拉”(pull)。

Angular和React中的变化侦测都属于“拉”,这就是说当状态发生变化时,它不知道哪个状态变了,只知道状态有可能变了,然后会发送一个信号告诉框架,框架内部收到信号后,会进行一个暴力比对来找出哪些DOM节点需要重新渲染。这在Angular中是脏检查的流程,在React中使用的是虚拟DOM。

而Vue.js的变化侦测属于“推”。当状态发生变化时,Vue.js立刻就知道了,而且在一定程度上知道哪些状态变了。因此,它知道的信息更多,也就可以进行更细粒度的更新。

所谓更细粒度的更新,就是说:假如有一个状态绑定着好多个依赖,每个依赖表示一个具体的DOM节点,那么当这个状态发生变化时,向这个状态的所有依赖发送通知,让它们进行DOM更新操作。相比较而言,“拉”的粒度是最粗的。

但是它也有一定的代价,因为粒度越细,每个状态所绑定的依赖就越多,依赖追踪在内存上的开销就会越大。因此,从Vue.js 2.0开始,它引入了虚拟DOM,将粒度调整为中等粒度,即一个状态所绑定的依赖不再是具体的DOM节点,而是一个组件。这样状态变化后,会通知到组件,组件内部再使用虚拟DOM进行比对。这可以大大降低依赖数量,从而降低依赖追踪所消耗的内存。

Vue.js之所以能随意调整粒度,本质上还要归功于变化侦测。因为“推”类型的变化侦测可以随意调整粒度。

2.2 如何追踪变化

关于变化侦测,首先要问一个问题,在JavaScript(简称JS)中,如何侦测一个对象的变化?

其实这个问题还是比较简单的。学过JavaScript的人都知道,有两种方法可以侦测到变化:使用Object.defineProperty和ES6的Proxy

由于ES6在浏览器中的支持度并不理想,到目前为止Vue.js还是使用Object.defineProperty来实现的,所以书中也会使用它来介绍变化侦测的原理。

由于使用Object.defineProperty来侦测变化会有很多缺陷,所以Vue.js的作者尤雨溪说日后会使用Proxy重写这部分代码。好在本章讲的是原理和思想,所以即便以后用Proxy重写了这部分代码,书中介绍的原理也不会变。

知道了Object.defineProperty可以侦测到对象的变化,那么我们可以写出这样的代码:

01.  function defineReactive (data, key, val) {
02.    Object.defineProperty(data, key, {
03.      enumerable: true,
04.      configurable: true,
05.      get: function () {
06.        return val
07.      },
08.      set: function (newVal) {
09.        if(val === newVal){
10.          return
11.        }
12.        val = newVal
13.      }
14.    })
15.  }

这里的函数defineReactive用来对Object.defineProperty进行封装。从函数的名字可以看出,其作用是定义一个响应式数据。也就是在这个函数中进行变化追踪,封装后只需要传递datakeyval就行了。

封装好之后,每当从datakey中读取数据时,get函数被触发;每当往datakey中设置数据时,set函数被触发。

2.3 如何收集依赖

如果只是把Object.defineProperty进行封装,那其实并没什么实际用处,真正有用的是收集依赖。

现在我要问第二个问题:如何收集依赖?

思考一下,我们之所以要观察数据,其目的是当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。

举个例子:

01.  <template>
02.    <h1>{{ name }}</h1>
03.  </template>

该模板中使用了数据name,所以当它发生变化时,要向使用了它的地方发送通知。

注意 在Vue.js 2.0中,模板使用数据等同于组件使用数据,所以当数据发生变化时,会将通知发送到组件,然后组件内部再通过虚拟DOM重新渲染。

对于上面的问题,我的回答是,先收集依赖,即把用到数据name的地方收集起来,然后等属性发生变化时,把之前收集好的依赖循环触发一遍就好了。

总结起来,其实就一句话,在getter中收集依赖,在setter中触发依赖

2.4 依赖收集在哪里

现在我们已经有了很明确的目标,就是要在getter中收集依赖,那么要把依赖收集到哪里去呢?

思考一下,首先想到的是每个key都有一个数组,用来存储当前key的依赖。假设依赖是一个函数,保存在window.target上,现在就可以把defineReactive函数稍微改造一下:

01.  function defineReactive (data, key, val) {
02.    let dep = [] // 新增
03.    Object.defineProperty(data, key, {
04.      enumerable: true,
05.      configurable: true,
06.      get: function () {
07.        dep.push(window.target) // 新增
08.        return val
09.      },
10.      set: function (newVal) {
11.        if(val === newVal){
12.          return
13.        }
14.        // 新增
15.        for (let i = 0; i < dep.length; i++) {
16.          dep[i](newVal, val)
17.        }
18.        val = newVal
19.      }
20.    })
21.  }

这里我们新增了数组dep,用来存储被收集的依赖。

然后在set被触发时,循环dep以触发收集到的依赖。

但是这样写有点耦合,我们把依赖收集的代码封装成一个Dep类,它专门帮助我们管理依赖。使用这个类,我们可以收集依赖、删除依赖或者向依赖发送通知等。其代码如下:

01.  export default class Dep {
02.    constructor () {
03.      this.subs = []
04.    }
05.  
06.    addSub (sub) {
07.      this.subs.push(sub)
08.    }
09.  
10.    removeSub (sub) {
11.      remove(this.subs, sub)
12.    }
13.  
14.    depend () {
15.      if (window.target) {
16.        this.addSub(window.target)
17.      }
18.    }
19.  
20.    notify () {
21.      const subs = this.subs.slice()
22.      for (let i = 0, l = subs.length; i < l; i++) {
23.        subs[i].update()
24.      }
25.    }
26.  }
27.  
28.  function remove (arr, item) {
29.    if (arr.length) {
30.      const index = arr.indexOf(item)
31.      if (index > -1) {
32.        return arr.splice(index, 1)
33.      }
34.    }
35.  }

之后再改造一下defineReactive

01.  function defineReactive (data, key, val) {
02.    let dep = new Dep() // 修改
03.    Object.defineProperty(data, key, {
04.      enumerable: true,
05.      configurable: true,
06.      get: function () {
07.        dep.depend() // 修改
08.        return val
09.      },
10.      set: function (newVal) {
11.        if(val === newVal){
12.          return
13.        }
14.        val = newVal
15.        dep.notify() // 新增
16.      }
17.    })
18.  }

此时代码看起来清晰多了,这也顺便回答了上面的问题,依赖收集到哪儿?收集到Dep中。

2.5 依赖是谁

在上面的代码中,我们收集的依赖是window.target,那么它到底是什么?我们究竟要收集谁呢?

收集谁,换句话说,就是当属性发生变化后,通知谁。

我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch,这时需要抽象出一个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个。接着,它再负责通知其他地方。所以,我们要抽象的这个东西需要先起一个好听的名字。嗯,就叫它Watcher吧。

现在就可以回答上面的问题了,收集谁?Watcher

2.6 什么是Watcher

Watcher是一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。

关于Watcher,先看一个经典的使用方式:

01.  // keypath
02.  vm.$watch('a.b.c', function (newVal, oldVal) {
03.    // 做点什么
04.  })

这段代码表示当data.a.b.c属性发生变化时,触发第二个参数中的函数。

思考一下,怎么实现这个功能呢?好像只要把这个watcher实例添加到data.a.b.c属性的Dep中就行了。然后,当data.a.b.c的值发生变化时,通知Watcher。接着,Watcher再执行参数中的这个回调函数。

好,思考完毕,写出如下代码:

01.  export default class Watcher {
02.    constructor (vm, expOrFn, cb) {
03.      this.vm = vm
04.      // 执行this.getter(),就可以读取data.a.b.c的内容
05.      this.getter = parsePath(expOrFn)
06.      this.cb = cb
07.      this.value = this.get()
08.    }
09.  
10.    get() {
11.      window.target = this
12.      let value = this.getter.call(this.vm, this.vm)
13.      window.target = undefined
14.      return value
15.    }
16.  
17.    update () {
18.      const oldValue = this.value
19.      this.value = this.get()
20.      this.cb.call(this.vm, this.value, oldValue)
21.    }
22.  }

这段代码可以把自己主动添加到data.a.b.cDep中去,是不是很神奇?

因为我在get方法中先把window.target设置成了this,也就是当前watcher实例,然后再读一下data.a.b.c的值,这肯定会触发getter。

触发了getter,就会触发收集依赖的逻辑。而关于收集依赖,上面已经介绍了,会从window.target中读取一个依赖并添加到Dep中。

这就导致,只要先在window.target赋一个this,然后再读一下值,去触发getter,就可以把this主动添加到keypathDep中。有没有很神奇的感觉啊?

依赖注入到Dep中后,每当data.a.b.c的值发生变化时,就会让依赖列表中所有的依赖循环触发update方法,也就是Watcher中的update方法。而update方法会执行参数中的回调函数,将valueoldValue传到参数中。

所以,其实不管是用户执行的vm.$watch('a.b.c', (value, oldValue) => {}),还是模板中用到的data,都是通过Watcher来通知自己是否需要发生变化。

这里有些小伙伴可能会好奇上面代码中的parsePath是怎么读取一个字符串的keypath的,下面用一段代码来介绍其实现原理:

01.  /**
02.   * 解析简单路径
03.   */
04.  const bailRE = /[^\w.$]/
05.  export function parsePath (path) {
06.    if (bailRE.test(path)) {
07.      return
08.    }
09.    const segments = path.split('.')
10.    return function (obj) {
11.      for (let i = 0; i < segments.length; i++) {
12.        if (!obj) return
13.        obj = obj[segments[i]]
14.      }
15.      return obj
16.    }
17.  }

可以看到,这其实并不复杂。先将keypath. 分割成数组,然后循环数组一层一层去读数据,最后拿到的obj就是keypath中想要读的数据。

2.7 递归侦测所有key

现在,其实已经可以实现变化侦测的功能了,但是前面介绍的代码只能侦测数据中的某一个属性,我们希望把数据中的所有属性(包括子属性)都侦测到,所以要封装一个Observer类。这个类的作用是将一个数据内的所有属性(包括子属性)都转换成getter/setter的形式,然后去追踪它们的变化:

01.  /**
02.   * Observer类会附加到每一个被侦测的object上。
03.   * 一旦被附加上,Observer会将object的所有属性转换为getter/setter的形式
04.   * 来收集属性的依赖,并且当属性发生变化时会通知这些依赖
05.   */
06.  export class Observer {
07.    constructor (value) {
08.      this.value = value
09.  
10.      if (!Array.isArray(value)) {
11.        this.walk(value)
12.      }
13.    }
14.  
15.    /**
16.     * walk会将每一个属性都转换成getter/setter的形式来侦测变化
17.     * 这个方法只有在数据类型为Object时被调用
18.     */
19.    walk (obj) {
20.      const keys = Object.keys(obj)
21.      for (let i = 0; i < keys.length; i++) {
22.        defineReactive(obj, keys[i], obj[keys[i]])
23.      }
24.    }
25.  }
26.  
27.  function defineReactive (data, key, val) {
28.    // 新增,递归子属性
29.    if (typeof val === 'object') {
30.      new Observer(val)
31.    }
32.    let dep = new Dep()
33.    Object.defineProperty(data, key, {
34.      enumerable: true,
35.      configurable: true,
36.      get: function () {
37.        dep.depend()
38.        return val
39.      },
40.      set: function (newVal) {
41.        if(val === newVal){
42.          return
43.        }
44.  
45.        val = newVal
46.        dep.notify()
47.      }
48.    })
49.  }

在上面的代码中,我们定义了Observer类,它用来将一个正常的object转换成被侦测的object

然后判断数据的类型,只有Object类型的数据才会调用walk将每一个属性转换成getter/setter的形式来侦测变化。

最后,在defineReactive中新增new Observer(val)来递归子属性,这样我们就可以把data中的所有属性(包括子属性)都转换成getter/setter的形式来侦测变化。

data中的属性发生变化时,与这个属性对应的依赖就会接收到通知。

也就是说,只要我们将一个object传到Observer中,那么这个object就会变成响应式的object

2.8 关于Object的问题

前面介绍了Object类型数据的变化侦测原理,了解了数据的变化是通过getter/setter来追踪的。也正是由于这种追踪方式,有些语法中即便是数据发生了变化,Vue.js也追踪不到。

比如,向object添加属性:

01.  var vm = new Vue({
02.    el: '#el',
03.    template: '#demo-template',
04.    methods: {
05.      action () {
06.        this.obj.name = 'berwin'
07.      }
08.    },
09.    data: {
10.      obj: {}
11.    }
12.  })

action方法中,我们在obj上面新增了name属性,Vue.js无法侦测到这个变化,所以不会向依赖发送通知。

再比如,从obj中删除一个属性:

01.  var vm = new Vue({
02.    el: '#el',
03.    template: '#demo-template',
04.    methods: {
05.      action () {
06.        delete this.obj.name
07.      }
08.    },
09.    data: {
10.      obj: {
11.        name: 'berwin'
12.      }
13.    }
14.  })

在上面的代码中,我们在action方法中删除了obj中的name属性,而Vue.js无法侦测到这个变化,所以不会向依赖发送通知。

Vue.js通过Object.defineProperty来将对象的key转换成getter/setter的形式来追踪变化,但getter/setter只能追踪一个数据是否被修改,无法追踪新增属性和删除属性,所以才会导致上面例子中提到的问题。

但这也是没有办法的事,因为在ES6之前,JavaScript没有提供元编程的能力,无法侦测到一个新属性被添加到了对象中,也无法侦测到一个属性从对象中删除了。为了解决这个问题,Vue.js提供了两个API——vm.$setvm.$delete,第4章会详细介绍它们。

2.9 总结

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

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之前JavaScript并没有提供元编程的能力,所以在对象上新增属性和删除属性都无法被追踪到。

图2-1给出了DataObserverDepWatcher之间的关系。

图2-1 DataObserverDepWatcher之间的关系

Data通过Observer转换成了getter/setter的形式来追踪变化。

当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖中。

当数据发生了变化时,会触发setter,从而向Dep中的依赖(Watcher)发送通知。

Watcher接收到通知后,会向外界发送通知,变化通知到外界后可能会触发视图更新,也有可能触发用户的某个回调函数等。

目录