一、概述
上一篇《vue源码阅读之数据渲染过程》分析了数据是如何渲染到页面的过程,本章将继续分析当改变数据的时候,页面是如何重新进行渲染的。即数据响应式原理。首先我们看一下整个响应式的过程。如图1所示:
图1: 响应式过程
图一中涉及到data、props、computed、watch等属性的响应式过程。都是通过收集依赖(dep.depend)、检测数据变化(触发setter)、通知(dep.notify)相应的Watcher执行回调函数的过程。例如图中渲染watcher(render watcher)接收到数据变化的通知后,会执行run函数调用updateComponent函数更新视图。本章重点讨论data中数据改变的时候是如何收集依赖,然后通知渲染watcher更新视图的。这个过程可以抽象成图2的示意图。其他属性的响应过程是类似的。
图2: data响应式过程
每个组件实例有自己的watcher对象,用于记录数据依赖。
组件中的data的每个属性都有自己的getter、setter方法用于收集依赖和触发依赖。图中红色的箭头表示的是组件渲染过程访问data中数据会触发getter方法,然后通知Dep收集依赖的流程。Dep将订阅数据的Watcher保存下来,便于后面通知更新。图中绿色的箭头表示的是data中的值变化时,会触发setter方法,告诉watcher有依赖发生了变化,
watcher收到依赖变化的消息,重新渲染虚拟dom,实现页面数据的更新。因此,数据响应式原理可以概括为:
- 侦测数据的变化 ---- 数据劫持(Object.defineProperty)。
- 收集视图依赖了哪些数据 ---- 依赖收集(dep.depend)。
- 数据变化时,自动“通知”需要更新的视图部分,发布订阅模式。并进行更新 ---- 派发更新(dep.notify)。
因此本文就从以上三个步骤逐步分析vue的数据响应式原理。
二、侦测数据的变化(Object.defineProperty)
Vue数据响应式系统通过Object.defineProperty设定对象属性的 setter/getter 方法来监听数据的变化,数据渲染时通过getter进行依赖收集,而每个setter方法就是一个观察者,在数据变更的时候通知订阅者更新视图。接下来分析数据变化的侦测过程。
2.1、从ininState函数出发
先回顾一下ininState函数。在 initState 函数内部使用 initProps 函数初始化 props 属性;使用 initMethods 函数初始化 methods 属性;使用 initData 函数初始化 data 选项;使用 initComputed 函数和 initWatch 函数初始化 computed 和 watch 选项。其中initData几乎涉及了全部的数据响应相关的内容。所以以 initData 为切入点为大家讲解 Vue的响应系统。让大家在自己分析props、computed、watch 等属性的响应式原理时会更加容易。
图3: initState函数
然后进入到initData函数中,研究其工作过程。
图4: initData函数
从源码中可以看到initData主要是做两件事,一件是遍历data中定义的数据,然后通过 proxy 把每一个值 vm._data.xxx 都代理到 vm.xxx 上;这样我们就可以通过this.xxx访问到数据。另一件是调用 observe 把 data的数据 变成响应式对象。上一篇《vue源码阅读之数据渲染过程》已经分析过initData数据绑定到实例的过程。这部分就不在赘述。
图5: initData数据绑定过程
今天要研究的是通过observe(data,true)把data中的数据变成响应式对象的过程。接下来让我们来揭开vue响应式对象的面纱。
2.2、observe(data,true)
observe函数定义在 src/core/observer/index.js 中:
图6: observe函数
observe 方法的作用就是给非 VNode 的对象类型数据添加一个 Observer,如果已经添加过,表示当前数据已经是响应式对象了,则直接返回,数据没有被observe过,且数据是array或object类型,那么将执行new Observer(data),所以Observer类接收的是对象和数组。接下来我们来看一下 Observer 的做了什么事。
图7:Observer构造函数
从代码分析,Observe构造函数做了三件事:
- 调用def函数为对象添加__ob__属性,__ob__中包含value数据对象本身、dep依赖收集器、vmCount。此时数据经过这个步骤以后的结构如下:
图8:为对象添加__ob__属性
- 若data的数据是object类型,
此时会执行this.walk(value)方法。获取对象中的所有key值,并将每个key以及对应的值传入到defineReactive函数中。其实defineReactive就是创建响应式对象,是对definePropoty的一层封装。defineReactive方法主要做了以下几件事:
- 为每个属性实例化一个dep依赖收集器,用于收集该属性的相关依赖。
- 缓存属性原有的get和set方法。
- 对属性进行进行observe递归的过程,并将结果保存在childOb中。比如传入的data是{ a: { b:{ c: 1 } } },b:{ c: 1 }对象也会被observe到,以此类推,不管对象的结构有多深它的所有子属性也能变成响应式的对象
- 通过Object.defineProperty为对象中的每一个属性都加上getter、setter方法。当我们访问一个值时,就会调用get方法并返回该属性对应的值。当我们修改某个属性的值时就会调用set方法就会将新值赋给当前属性。这样就完成了响应对象的创建。
图9:defineReactive创建响应式对象
经过defineReactive处理的数据data:{message:"我是一条消息"}变为如下结构, 每个属性都有自己的dep、childOb、getter、setter,并且每个object类型的属性都有__ob__。
图10:defineReactive处理后的data
接下来再分析当传入data为数组的时候,如何创建响应式对象。我们发现Object.defineProperty对数组进行响应式化是有缺陷的。虽然我们可以监听到索引的改变。但是defineProperty不能检测到数组长度的变化,这是因为数组的length属性是不能添加getter和setter,所有无法通过观察length来判断。准确的说是通过改变length而增加的长度不能监测到。这种情况无法触发任何改变。举个例子:
图11:Object.defineProperty不能检测到数组长度的变化
因此Vue使用了重写原型的方案代替。拦截了数组的一些方法,在这个过程中再去做通知变化等操作。Vue监听数组的变化可以概括为以下三步:
- 获取原生 Array 的原型方法,Array.prototype。因为即使拦截后同样需要数组原生的方法来改变数组。
- 对 Array 的原型方法使用 Object.defineProperty 做一些拦截操作。
- 把需要被拦截的 Array 类型的数据原型指向改造后原型。
Vue中数组只有7个常用方法可以触发视图的更新:push(), pop(), shift(), unshift(), splice(), sort(), reverse()。这是因为Vue对这些方法进行了增强。vue中实现的方法实际是对数组的属性重写,重写过后的方法不仅能实现原有的功能,还能发布消息给订阅者。
图12:对数组的方法的重写
上面的代码截断了数组的原型链,我们新创建了一个对象arrayMethods,这个对象继承自Array.prototype,这样做的好处是避免影响全局属性。然后改写数组的7个方法。同时记录插入的值,如果是调用了push、unshift、splice,则尝试对新插入的值进行响应式绑定,因为插入的值有可能是对象(Object)或者数组(Array)。然后会调用notify()函数向所有依赖发送通知,告诉它们数组的值发生变化了可以更新视图了。最后将操作后的结果result返回给当前数组。notify()会放在分析派发更新的时候研究。arrayMethod会在当前传入的是数组的时候调用。如下:
图13:数组的处理
首先会判断对象中是否存在__proto__属性。因为某些浏览器中对象是没有__proto__属性的。若存在__proto__就将数组的__proto__指向增强后的数组的prototype。若没有就把加强后的数组方法加到属性自身上。这样数组就能正常的访问增加后的方法了。然后会调用observeArray(value)方法做observe的操作。
图14:observeArray函数
可以看到其实 observeArray 方法就是对数组进行遍历,递归调用 observe 方法,最终都会调用 walk 方法观察单个元素创建响应式对象。至此侦测数据的变化就分析完了。下一节分析依赖收集过程。
三、依赖收集
通过上一节的分析我们了解 Vue 会把普通对象变成响应式对象,在获取数据时会触发相应的getter函数返回该值 并做依赖收集,这一节我们来详细分析这个过程。
图15:依赖收集
上述代码中我们主要关注两个地方。一个是 const dep = new Dep() 实例化一个 Dep 的实例,另一个是在 get 函数中通过 dep.depend 做依赖收集。
3.1、Dep
Dep 是整个 getter 依赖收集的核心,它的定义在 src/core/observer/dep.js 中。它实际是建立数据和watcher之间的桥梁。
在上一篇《vue源码阅读之数据渲染过程》介绍过 Vue 的 mount 过程是通过 mountComponent 函数实现的,其中有一段很重要的逻辑:
图16:mountComponent关键逻辑
当我们去实例化一个渲染 watcher 的时候,首先进入 watcher 的构造函数逻辑,然后会执行它的 this.get() 方法,进入 get 函数,会执行:
图17:get函数
pushTarget 的定义在 src/core/observer/dep.js 中:
图18:pushTarget函数
实际上就是把 Dep.target 赋值为当前的渲染 watcher 并压栈。接着又执行了this.getter.call(vm, vm)。this.getter 对应就是 updateComponent 函数,这实际上就是在执行:vm._update(vm._render(), hydrating)函数。它会先执行 vm._render() 方法,因为之前分析过这个方法会生成 渲染 VNode,并且在这个过程中会对 vm 上的数据访问,这个时候就触发了数据对象的 getter。那么每个对象值的 getter 都持有一个 dep,在触发 getter 的时候会调用 dep.depend() 方法,也就会执行 Dep.target.addDep(this)。
图19:depend函数
刚才我们提到这个时候 Dep.target 已经被赋值为渲染 watcher,那么就执行到 addDep 方法:
图20:addDep函数
也就是说把当前的 watcher 订阅到这个数据持有的 dep 的 subs 中,这个目的是为后续数据变化时候能通知到哪些 subs 做准备。下图是依赖收集的流程。
图21:依赖收集的流程
所以在 vm._render() 过程中,会触发所有数据的getter,这样实际上已经完成了一个依赖收集的过程。收集依赖的目的是为了当这些响应式数据发生变化,触发它们的setter,从而通知watcher重新计算,从而致使与之相关联的组件得以更新。
四、派发更新
这一节将分析当改变数据的时候,vue是如何通知相应的watcher去更新视图的。我们知道当修改data中的值时会触发数据的setter。setter 主要做了三件事。
图22:setter函数
-
把当前属性的值设置为新值
-
一个是childOb = !shallow && observe(newVal),如果shallow为 false 的情况,会把新设置的值变成一个响应式对象。
-
另一个是dep.notify(),通知所有的订阅者更新视图。
接下来看看派发更新的是怎么完成的。
图23:notify函数
dep.notify()函数关键就是循环遍历所有订阅者。也就是数组subs中存放的watcher。然后调用每个watcher的update函数。
图24:update函数
实际就是每个watcher执行queueWatcher()方法。
图25:queueWatcher函数
首先分析一下非生产环境且非异步的情况,会直接执行flushSchedulerQueue。
图26:flushSchedulerQueue函数
首先对队列wacher根据id进行排序,主要为了处理父子组件,userWatcher等情况,然后调用watcher.run方法。
图27:run函数
run方法中会执行this.get()。就会执行this.getter.call(vm, vm)。而this.getter函数指向updateComponent。
图28:updateComponent函数
然后就会调用render函数和update函数更新视图。就这样完成了派发更新的工作。
接下来分析生产环境下,调用nextTick(flushSchedulerQueue)。Vue官方文档中是这样说明的:Vue异步执行DOM更新。只要观察到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。如果同一个watcher被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和DOM操作上非常重要。然后,在下一个的事件循环“tick”中,Vue刷新队列并执行实际 (已去重的) 工作。简单来说,就是在一个事件循环中发生的所有数据改变都会在下一个事件循环的Tick中来触发视图更新,这也是一个“批量处理”的过程。在分析nextTick之前,先简单分析一下什么是宏任务(macrotask)和微任务(microtask)。
macrotask 和 microtask 表示异步任务的两种分类。JS 引擎会将所有任务按照类别分到这两个队列中,首先在 macrotask 的队列(这个队列也被叫做 task queue)中取出第一个任务,执行完毕后取出 microtask 队列中的所有任务顺序执行;之后再取 macrotask 任务,周而复始,直至两个队列的任务都取完。宏任务和微任务之间的关系可以用下图表示:
图29:宏任务和微任务关系图
宏任务一般包括:setTimeout、setInterval、setImmediate、requestAnimationFrame。微任务包括:process.nextTick、MutationObserver、Promise.then catch finally。这样说不太好理解,下面举一个例子。
图30:宏任务和微任务实例
上段代码的执行过程可以概括为:
-
进入第一个宏任务也就是主线程, 遇到 setTimeout就分发到宏任务事件队列中
-
遇到 console.log() 直接执行 输出 外层宏事件1
-
遇到 Promise, new Promise 直接执行 输出 外层宏事件2。
-
执行then 被分发到微任务事件队列中
-
第一轮宏任务执行结束。此时有未执行的微任务,开始执行微任务 打印 '微事件1' '微事件2'
-
第一轮微任务执行完毕,执行第二轮宏事件,打印setTimeout里面内容'内层宏事件3'
知道了宏任务和微任务的区别,现在让我们正式进入nextTick(flushSchedulerQueue)的分析。这里用到了microTimerFunc和macroTimerFunc2 个变量,它们分别对应的是 microtask的函数和 macrotask的函数。对于macrotask的实现,会先检测是否支持原生setImmediate,因为setImmediate只有高版本的IE 和Edge才支持,如果不支持setImmediate就检测是否支持原生的MessageChannel,如果也不支持的话就会降级为setTimeout;而对于 microtask 的实现,则检测浏览器是否原生支持 Promise,不支持的话直接指向 macrotask 的实现。
图31:宏任务和微任务的实现
nextTick把传入的回调函数cb(这里cb就是flushSchedulerQueue函数)压入callbacks数组,最后一次性地根据useMacroTask条件执行macroTimerFunc或者是microTimerFunc,
图32:nextTick函数
而不管是执行macroTimerFunc或者是microTimerFunc都会在下一个 tick 执行flushCallbacks,flushCallbacks的逻辑非常简单,对callbacks遍历,然后执行相应的回调函数。
图33:flushCallbacks函数
也就是执行flushSchedulerQueue调用watcher.run方法。最终调用updateComponent更新视图。至此派发更新的整个过程也分析完了。
五、总结
-
在 newVue() 后, Vue 会调用 _init 函数进行初始化,也就是init 过程,在 这个过程Data通过Observer转换成了getter/setter的形式,来对数据追踪变化,当被设置的对象被读取的时候会执行 getter 函数,而在当被赋值的时候会执行 setter函数。
-
当render function 执行的时候,因为会读取所需对象的值,所以会触发getter函数从而将Watcher添加到依赖中进行依赖收集。
-
在修改对象的值的时候,会触发对应的 setter, setter通知之前依赖收集得到的 Dep 中的每一个 Watcher,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher就会开始调用 update 来更新视图。
至此vue响应式原理就分析完了。只是该过程的简要分析,其中很多细节部分还需要大家自己去梳理。在梳理过程中才能更好的理解整个响应式过程。希望本文能起到抛砖引玉的作用。感谢大家的阅读。