开篇介绍

Vue.js最独特的特性之一就是看起来并不显眼的响应式系统,数据模型仅仅是一个普通的js对象。从状态生成DOM,在输出到用户显示的界面的一系列流程叫做渲染,应用在运行过程中会不断的进行重新渲染,响应式系统赋予了框架重新渲染的能力,其中重要组成部分就是变化侦测,变化侦测是响应式系统的核心,简单来说,变化侦测的作用就是侦测数据的变化。当数据变化时,会通知视图进行相对应的更新变化。

object的变化侦测

什么是变化侦测?

通常,在运行时应用内部的状态会不断进行变化,需要重新不停的进行渲染,如何确定是否状态发生了变化成为问题。变化侦测就是用来解决这个问题的,分为了两种类型,一种是“推”(push),一种是“拉”(pull).

Angular和React的变化侦测都属于“拉”,拉的意思就是当状态发生变化时,它不知到底时哪个变了,只是知道状态有可能变了,然后他会发送一个信号来通知框架,框架接收到信号后通过暴力比对来找出哪些DOM节点需要重新渲染,这在Angular中是“脏检查”的流程,在React中使用的是虚拟DOM。
Vue的变化侦测属于“推”,推的意思是当状态发生变化时,Vue.js立马就知道了,并且在一定程度上知道是哪些状态变了,因此,它知道的更多,所以可以进行更细粒度的更新细粒度指的是假如有一个状态绑定着好多个依赖,每个依赖表示一个具体的DOM节点,那么这个状态发生变化时,会向这个状态所绑定的所有依赖发送通知,让他们进行DOM更新操作。但是细粒度的更新也会有一定的代价作为补偿,粒度越细,每个状态所绑定的依赖越多,依赖追踪在内存上的开销也会变得越来越大,所以从Vue.js2.0开始,引入了虚拟DOM的改变,将细粒度调整为中等粒度,即一个状态所绑定的依赖不再是具体的DOM节点,而是一个组件,状态变化后,会通知到组件,组件内部再使用虚拟DOM进行对比,这样可以大量降低状态的依赖数量,从而降低追踪依赖所带来的内存消耗。Vue.js之所以能随意调整粒度,本质上还是要归功于变化侦测,“推”类型的变化侦测可以随意调整粒度。

如何追踪变化

关于object的变化侦测,在JS中如何侦测一个对象发生了变化?有两种方法:通过使用Object.DefineProperty和ES6的Proxy。2.0使用的是Object.DefineProperty,而在3.0中,作者尤雨溪使用ES6中的Proxy重新了这部分代码。但是原理不变。
原理是使用了函数defineReactive来对Object.defineProperty进行了封装。每当从data中的key读取数据的时候,会触发get函数;每当往date的key中设置数据时,会触发set函数。

function defineReactive(data,key,val){
    object.defineProperty(date,key,{
        enumerable:true, //可枚举
        configurable:true, //可配置
        get: function(){
            return val;
        },
        set: function(){
            if(val == newVal){
                return; 
            }
            val = newVal;
        }
    })
}

如何收集依赖

在getter中收集依赖,在setter中触发依赖

依赖收集在哪里

假设依赖都保存在window.target中,在defineReactive中新增了一个数组dep,用来存储被收集的依赖。在set被触发时,循环dep来触发收集到的依赖。这样写会存在耦合,所以封装了一个Dep类,其中包含了添加移除更新等等操作来管理依赖,使用这个Dep类可以收集依赖,删除依赖或者向依赖发送通知。

function defineReactive(data,key,val){
    let dep = new Dep() //新增
    object.defineProperty(date,key,{
        enumerable:true, //可枚举
        configurable:true, //可配置
        get: function(){
            dep.depend()
            return val;
        },
        set: function(){
            if(val == newVal){
                return; 
            }
            val = newVal;
            dep.notify()
        }
    })
}

依赖是谁

我们收集的依赖是window.target,收集谁?换句话说,就是当状态发生变化时,通知谁?用到数据的地方有很多,而且类型也不一样,有可能是模板有可能是用户自定义的一个watch,这时需要抽象出一个能集中处理这些情况的一个类,在依赖收集阶段只收集这个封装好的类的实例,通知只通知它一个,它在负责通知其他地方。这个类就是Watcher!

什么是Watcher

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

vm.$watch('data.a.b',function(){
    //做点什么
})

在当data.a.b属性发生变化的时候,会触发第二个参数的函数。

递归侦测所有key

前面介绍的只能侦测数据中的某一个属性,我们希望把数据中的所有属性(包括子属性)都侦测到,所以需要封装一个Observer类,这个类的作用就是把一个数据内的所有属性(包括子属性)都转换为getter/setter的形式,然后去追踪他们的变化,递归遍历子属性调用defineReactive方法。Obsever类用来将正常的object转换为被侦测的object。

关于Object的问题

Vue.js2.0通过Object.defineProperty来将对象的key转换为getter/setter形式来追踪变化,但是它们只能追踪一个数据是否被修改,无法追踪新增属性(object.xxx=xxx)和删除属性(delete this.obj.xxx),所以会产生无法侦测这些属性的变化,所以不会向依赖发送通知,因为在ES6之前,没有提供元编程的能力,无法侦测到一个新属性被添加到了对象中,也无法侦测到一个属性被从对象中删除,为了解决这个问题,Vue.js提供了两个API--vm.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没有提供元编程的能力,所以在对象上新增属性和删除属性都是无法被追踪到的
图片说明
Data通过Observer转换成了getter/setter的形式来追踪变化。
当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖中。
当数据发生了变化时,会触发setter,从而Dep中的依赖(Watcher)发送通知。
Watcher接收到通知后,会向外界发送通知,变化通知到外界后可能会触发视图更新,也有可能会触发用户返回的某个回调函数。