Vue2源码解析系列-响应式原理

综述

Vue的响应式利用变化侦测实现。

所谓的变化侦测,就是在数据被访问的时候收集依赖,在改变的时候通知依赖更新。

总的来说就是Object是getter里收集依赖,setter触发依赖更新;Array是getter收集依赖,拦截器触发依赖更新。

其中数组和对象的实现思路略有区别,后面再详细分析。

上述是思路,具体实现我们还要解决以下问题

  • 怎么监听数据被访问和被修改

  • 什么是依赖?

  • 如何收集依赖?

  • 如何通知依赖更新

Object的变化侦测

怎么监听数据被访问和修改

前面说过所谓的变化侦测,就是在数据被访问的时候收集依赖,在改变的时候通知依赖更新,那么如何知道数据被访问或被修改了呢?

我们知道,对于Object.defineProperty可以声明对象属性,并且可以配置属性修饰符。而属性修饰符中的setter和getter访问器函数分别会在属性被修改和被访问时触发,因为我们可以利用Object.defineProperty来实现数据的监听。

let obj = {}
Object.defineProperty(obj,'key',{
    value: "123",
    set(){
        console.log('数据被修改')
    },
    get(){
        console.log('数据被访问')
    }
})
obj.key
// 数据被访问
// 123
obj.key = 321
// 数据被修改

什么是依赖

什么是依赖?我们可以这样理解:某个数据的值需要访问另一个数据,我们就可以说这个数据依赖了另一个数据,例如计算属性 a(){return b + '123'},我们可以说a依赖b。

解决了什么是依赖,那么依赖应该存放在哪呢?我们很容易想到可以用一个数组存着,但是这样很耦合,我们抽象成一个类Dep,为这个类定义一些方法。

还有一个问题,前面我们说过,一个数据依赖另一个数据,那么就将这个依赖存起来,如果将来数据改变就通知依赖更新,那么所有依赖都要被收集吗?我们知道在methods中,如果一个方法里的响应式数据发生改变,这个方法却不会发送任何改变,而computed却会更新,这也就说明,Vue只会选择性的收集依赖,我们将这种会被当做依赖收集的数据抽象为类Watcher,收集只收集Watcher的实例。

现在可以回答前面的问题: 什么是依赖?依赖是Watcher。依赖收集后放哪里?放到Dep里。

如何收集依赖

前面说到依赖收集到Dep,那么具体如何实现呢?

我们可以利用一个Dep和数据都能访问到的唯一变量。当创造Watcher实例时,将这个唯一变量指向实例自身,然后获取一下数据,数据的getter会将这个唯一变量收集到Dep中。

注意:上述的唯一变量在本例中是window.target,而实际上是静态变量Dep.target,两者效果相同,后者的优点是不会污染全局变量。

实际代码加上了很多处理和判断,更复杂,这里进行了简化,理解核心思路即可。

如何通知依赖更新

在setter中通知Dep,Dep遍历存储依赖的数组,依次调用更新方法。

总结

Object的变化侦测在getter收集依赖,存到Dep中,setter里通知Dep中的依赖进行更新操作,依赖就是watcher,Vue在需要依赖响应式数据的地方创造watcher实例,比如计算属性、模板语法或用户的watch。

Array的变化侦测

我们知道Array其实本质上也是对象,因此也可以使用之前说的Object.defineProperty方法,但是实际上Vue并没有采用这种方法,请看下面代码。

可以看到,如果调用Object.defineProperty声明数组元素,固然使用中括号方法改变属性时可以执行setter,但是如果使用push这类数组方法,却无法触发setter。

事实上,这样写同样也会遇到性能问题,数组有些情况是需要存放大量数据的,如果每个元素都设置setter,对性能会有一定影响,因此Vue采用了另一种巧妙的方式实现数组的响应式。

拦截器

什么是拦截器?举个例子。

类A里声明了toString方法,而类B继承于A,自然也有toString方法,并且A和B的toString方法相同。

这个时候如果我们在类B中再声明一个toString方法,那么实例b调用的就是类B自身的toString方法,而不是继承过来的类A的toString,我们可以理解类B的toString被拦截了。

同理,我们声明的数组是Array的实例,调用的push等方法其实是调用数组原型对象上的push方法,因此我们只需要在我们声明的数组里添加拦截器,那么调用push方法就是调用我们自定义的push方法,而不是Array.prototype.push

https://vue-js.com/learn-vue/assets/img/2.b446ab83.png

那么什么方法才需要使用拦截器?很明显,能够改变数组自身数据的方法都需要拦截,整理后有以下几种:

  • push

  • pop

  • shift

  • unshift

  • splice

  • sort

  • reverse

明白这些之后,我们就可以开始写拦截器了。

现在我们得到了一个数组方法拦截器。

挂载拦截器

写好拦截器后下一步就是要将拦截器挂载到响应式数组上。

拦截器本质上是个包含七个特殊方法的对象,并且继承了Array的原型,因此拦截器的挂载可以直接将拦截器作为响应式数组的prototype,在浏览器环境下对象的原型被暴露为__proto__属性,因此我们可以:

收集和通知依赖

拦截器解决了数组触发依赖更新的问题,那么我们如何收集依赖呢?

答案是getter。请看以下代码:

以上代码,我们首先声明一个包含数组arr的对象(obj),这是模拟Vue中data的写法,同时配置好修饰符,在getter中收集依赖后返回真实的数据,最后测试使用数组方法和中括号访问是否能触发,很明显这种方法效果很好。

Vue中的watcher

前面说到vue会将响应式状态的依赖封装成Watcher,那么我们现在就来看看Vue中有哪些Watcher。

可以看到,vue使用响应式的地方主要有

  • 组件

  • computed

  • $watch()

  • 其他

组件

事实上,组件本身其实也是响应式的,例如你使用了一个文本插值(即{{}}),然后更新响应式状态,此时UI跟着改变。

我们来分析一下这个过程。

  • 首先vue解析文本插值语法,真正的值取出并渲染

  • 当文本插值的响应式状态改变时,通知依赖(组件)更新(即重新渲染)。

而要达到当响应式状态改变通知组件更新这一目的,就需要将组件转成watcher,作为状态的依赖。

  • 首先将组件封装成Watcher,将渲染函数作为更新时的回调传入

  • 组件第一次渲染时会获取响应式状态的值,这个时候就会将组件作为依赖加入到状态的dep中。

  • 当更改响应式状态时,会触发dep.notify(),通知状态的所有依赖更新,而组件的更新回调就是组件的渲染函数,因此就能达到重新渲染UI的目的。

在代码中的流程是这样的(省略了不重要的代码)

接着会将组件会执行渲染后函数updateComponent

先将Dep.target执行组件的watcher,然后执行updateComponent来触发状态的getter,状态的getter函数会将Dep.target加入到状态的deps中,这样就成功的将组件作为依赖传入到所有用到的状态的deps中。如果有某一个状态改变,就会触发setter,然后触发deps.notify来通知所有依赖执行回调,对于组件的wather来说,回调就是渲染函数,因此就会重新执行渲染。

computed

computed可以看做是一个特殊的响应式状态,它的值取决于对其他的响应式状态的计算,computed可以是一个函数,也可以是一个包含set或get的对象,如果是函数,那么等同于包含get函数的对象。computed真正强大之处在于它的值会被缓存,只有当其他响应式状态改变时才会重新计算,这对性能很有好处。

vue首先会在initComputed中为每个computed创建watcher

然后会使用一个中间层函数createComputedGetter,这个函数会判断是否需要重新计算,如果需要才会调用computed真实的函数。createGetterInvoker则是在无法使用缓存的情况下使用。

vue对computed的处理和对state的处理很类似,具体流程是:首先vue将每个computed创建watcher(传递lazy来表明要使用缓存),以便作为依赖被使用到的响应式状态添加到deps中,接着通过Object.defineProperty创建属性,但是get函数加了一个中间层来判断是否要重新计算(即createComputedGetter函数)。

computed每次获取时都会触发getter,也就是 createComputedGetter函数,这里会收集依赖,并且会通过watcher.dirty标记来判断是否要重新计算。如果computed的依赖更新,那就会通知依赖(也就是computed的watcher)进行更新,此时就会将watcher.dirty标记设为true,那么就会重新计算。

$watch()

$watch()方法实际上就是将Watcher主动暴露给用户使用,用户指定监听的响应式状态和回调,然后该状态会将watcher作为依赖收集到deps,更新时就会触发执行回调函数。

响应式API

Vue2.0中有三个常用响应式API,分别是vm.#watchvm.$setvm.$delete

Vue.$watch

vm.$watch 能监听一个表达式或computed函数,触发时执行特定函数。本质上也是利用Watcher。在使用vm.$watch时会为目标数据创建一个watcher,watcher的构造函数里会读取一下数据,这样就将这个watcher添加到dep中了,如果数据更新了,就会触发对应的回调函数。

vm.$watch第一个参数能接收表达式或computed函数

如果是函数,直接作为getter,如果是keypath,则需要处理

vm.$watch会返回一个函数,执行函数会取消监听。我们看到前面代码里返回了一个函数,函数里就执行了watcher.teardown()语句,因此取消监听重点是在watcher.teardown()上,它的作用是将watcher自身从所有依赖收集订阅列表删除。

要实现将watcher自身从所有依赖收集订阅列表删除,首先需要在Watcher中记录自己都订阅了谁,也就是watcher实例被收集进了哪些Dep里。然后当Watcher不想继续订阅这些Dep时,循环自己记录的订阅列表来通知它们(Dep)将自己从它们(Dep)的依赖列表中移除掉。

这个简单,我们首先为每个dep定义一个编号,每次将watcher添加到dep时就将这个dep的编号存到一个数组里。

然后再看watcher.teardown()

watcher.teardown()会遍历this.deps依次执行removeSub方法,将自身从dep上删除。

Vue.$set

Vue.$delete

最后更新于