已经使用vue2两年多了,一直想要去了解它的内部的实现原理是如何的,以前总是看一些博客,零零散散的吸收一些知识点,碎片拼凑起来,总觉得还是没有对它形成一个系统化的了解,心里总是不踏实的。正好近期有一些空闲时间,就去详细看了《深入浅出Vue.js》这本书,再结合源码去比较细致的探究,终于觉得在脑海里形成了一个系统化认知,总算是踏实了。本篇文章主要分享了我在探究源码后,对响应式原理这一部分的一个认知,也算是一个总结吧。
响应式
通常,在运行时应用内部的状态会不断发生变化,当状态发生变化后,需要重新渲染,得到最新的视图。而响应式系统赋予了视图被重新渲染的能力,其核心组成部分就是状态的变化观测以及高效的DOM更新渲染,接下来,让我们一起从源码的角度上,来理解一下响应式的运作原理吧!
变化侦测
重新渲染的前提是状态发生了变化,那么,这时如何去确定这些状态的发生了变化,从而去发出变化通知呢?
变化侦测就是用来解决这个问题的。
在JS中侦测一个对象的变化,可以使用Object.defineProperty和Proxy,而由于ES6在浏览器的支持度并不理想(vue2.0在2016年10月1日发布),所以在vue2版本中还是选择使用Object.defineProperty来实现。
在官方文档中有这样一句话:由于 JavaScript 的限制,Vue 不能检测数组和对象的变化。那么,这个限制是什么呢?vue2中对数组和对象变化侦测的实现方式有什么不一样呢?基于这两个疑问,我们结合源码一起来探究一下。
Object的变化侦测
首先,在涉及到数据响应式的这部分源码中,我们可以看到几个比较重要的类,它们分别是Observer、Dep、Watcher
职能介绍:
-
如何收集依赖:即在哪里去做数据劫持,从而收集依赖?==> Observer
-
依赖收集到哪里:每一个对象、每一个key的依赖都需要集中管理 ==> Dep
-
依赖是谁:换句话说,属性发生变化后,通知到谁?使用到这个数据的地方可能有模版、用户定义的watch或者computed,所以定义一个集中处理这些情况的类,数据变化后通知到它,它再负责通知到其他地方 ==> watcher
Observer、Dep和Watcher之间的功能关系
-
Data通过Observer转换成了getter/setter的形式来追踪变化。
-
当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖管理(Dep)中。
-
当数据发生了变化时,会触发setter,从而向Dep中的依赖(Watcher)发送通知。
-
Watcher接收到通知后,会向外界发送通知,变化通知到外界后可能会触发视图更新,也有可能触发用户的某个回调函数等。
这里需要解释一下,外界是如何通过watcher去读数据的,有三种情况
-
组件挂载时,会创建watcher,传入组件更新函数
-
为计算属性computed创建watcher,传入计算属性的getter
-
会$watch创建watcher,传入监听的表达式,如'a.b.c'
watcher内部会去读取这些函数或者表达式,从而触发响应式数据的getter。可以看一下源码中的体现:
Array的变化侦测
我们知道,在vue2中,当我们直接改变数组下标的方式来设置数组项时是不具有响应式的vm.items[index] = newValue,那么是因为Object.defineProperty不能检测到变化吗?
针对这个问题,我们来做一下测试:
function defineReactive(obj, key, value) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function() {
console.log(key,'---触发get---', value);
return value;
},
set: function(newValue) {
console.log(key,'---触发set---', newValue);
value= newValue;
}
});
}
function Observer(value) {
const keys = Object.keys(value)
keys.forEach(k => {
defineReactive(value, k, value[k]);
});
}
const arr = ['a','b']
Observer(arr)
// 检测下标
arr[0]
arr[0]='aa'
可以看到输出结果,把数组的下标索引看作为一个key,Object.defineProperty是可以检测数组变化的,那么为什么不直接使用它来做数组的数据响应式?
嘶~!是个好问题!在vue的issue中,有人也提出了这样的问题,尤大的回答是:性能代价和获得的用户体验收益不成正比。很精简的回答,明白了但没完全明白!
思考一下,性能代价从何而来?如果使用Object.defineProperty来对数组属性实现监听,为什么会出现性能问题呢?
通常,除了通过下标的方式,我们一般是使用数组的7个方法来变更数组( push,pop,shift,unshift,splice,sort,reverse),当使用Object.defineProperty对数组的每个下标key实现getter/setter之后,再使用这七个方法来变更数组会发生什么,让我们来测试一下:
push
使用push增加数组项时,并为触发getter/setter,把数组下标当做key值,这和对象的表现形式一样,新增的key需要再次使用Object.defineProperty做一次拦截
unshift
使用unshift在数组开头增加数组项,触发了多次getter/setter,先读取数组中的每一项,再重新设值
pop
使用pop删除数组最后一项,当最后一项的下标key被拦截过,则会触发get
shift
使用shift删除数组第一项,当最后一项的下标key被拦截过,则会触发多次getter,一次setter
splice
使用splice修改某一项值,触发一次getter/setter;增加和删除项,触发了多次getter/setter
sort|reverse
使用sort和reverse排序,触发了多次getter/setter
总结:
对数组的每一个下标做Object.defineProperty拦截,当使用数组方法时,除了push,pop,其他方法都会触发多次getter/setter,考虑数组量级比较大时,递归遍历数组,为每一个下标做getter/setter,每一个下标key都对应创建一个Dep来管理依赖,使用数组方法改变数组时,多次触发getter收集依赖(watcher),setter通知依赖。
这里的创建Dep的内存开销、多次触发getter/setter、watcher的开销是有必要的吗?显然不是的,我们实际的业务场景中,对数组的更新,只需要知道数组本身发生了变化,从而去触发视图更新。所以当我们使用数组的方法来改变数组时,就可以知道数组已经发生了改变,此时去做拦截更新就可以了,放弃对数组下标进行getter/setter,避免额外的性能开销。
这里需要考虑的是,既然不使用Object.defineProperty拦截了,那怎么去触发数组的getter/setter呢?
那接下来,我们来源码中寻找一下答案
- 对于需要响应式处理的数组,覆盖其数组原型上的7个方法。==> setter
import { def } from '../util/index'
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
// cache original method
// 原始方法
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
// 执行原始行为
const result = original.apply(this, args)
// 获取ob实例
const ob = this.__ob__
// 如果是新增元素的方法,需要取出新增的元素做响应式处理
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
// 新加入的对象仍然需要响应式处理
if (inserted) ob.observeArray(inserted)
// notify change
// 让内部的dep通知更新
ob.dep.notify()
return result
})
})
- 执行原始行为时,数组已经发生了变化,那么怎么去通知依赖发生了变化呢
看源码,我们可以发现,在创建Observer实例时,会在实例中创建这个观测对象所对应的依赖管理Dep实例,并为这个观测对象创建一个属性__ob__,指向当前的Observer实例,那么以后我们就可以在已经被观测到的对象中,通过__ob__去拿到Observer实例,从而获取这个对象的dep并通知变更
看一下Observer的constructor实现
export class Observer {
value: any;
dep: Dep;
vmCount: number; // number of vms that have this object as root $data
constructor (value: any) {
this.value = value
// 创建对象一一对应的dep
this.dep = new Dep()
this.vmCount = 0
// 在观测对象中绑定__ob__属性指向Observer实例
def(value, '__ob__', this)
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
} else {
this.walk(value)
}
}
}
- 既然对一个对象做了Dep管理,那么哪里去使用到对象的依赖收集? ==> getter
我们可以看到,对每一个key做侦测时,也会递归遍历对应的value,如果value为对象,那么就会进行侦测,即我们可以拿到ob实例,从而调用ob实例上的Dep去收集依赖
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
// 创建key一一对应的dep
const dep = new Dep()
...为此处省略部分...
// 递归遍历,当value为对象时,也需要观测
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
// Dep.target就是当前的watcher
if (Dep.target) {
dep.depend()
if (childOb) {
// 添加依赖
childOb.dep.depend()
// 如果是数组,数组内部所有项都要做相同处理
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
...为此处省略部分...
childOb = !shallow && observe(newVal)
// 变更通知
dep.notify()
}
})
}
$set、$delete
对于对象,由于Object.defineProperty不能检测对象上一个属性的新增和删除,所以针对这两种情况实现响应式,vue2提供了 $set来支持对象属性的新增, $delete支持属性的删除。通过判断一个对象是否被响应式处理过,可以直接访问对象上的__ob__属性,若存在,则可以拿到这个对象的依赖,并发出通知。
对于数组,从性能/体验的性价比考虑,放弃了Object.defineProperty对于数组下标做getter/setter,所以也对数组提供了 $set来支持下标的方式设置数组项,不同于对象的处理方式,数组是调用splice方法来对下标进行增删改,因为响应式处理后的数组splice方法,已经具有可以getter/setter的功能。
$set部分源码实现如下:
export function set (target: Array<any> | Object, key: any, val: any): any {
// 数组:使用splice方法
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
return val
}
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
const ob = (target: any).__ob__ // Observer实例
// 没有被响应式处理过,直接原生处理后返回
if (!ob) {
target[key] = val
return val
}
defineReactive(ob.value, key, val) // 对新增的key做响应式处理
ob.dep.notify() // 通知依赖变更
return val
}
$delete实现同$set,但不需要进行响应响应式处理了,只需要删除key即可,$delete部分源码实现如下:
export function del (target: Array<any> | Object, key: any) {
// 数组:使用splice方法
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.splice(key, 1)
return
}
const ob = (target: any).__ob__
if (!hasOwn(target, key)) {
return
}
delete target[key] //直接删除
if (!ob) {
return
}
ob.dep.notify() // 通知依赖变更
}
批量异步更新
具体实现
批量异步更新,主要利用了浏览器的事件循环机制,只要侦听到数据变化,就会开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。
如果同一个watcher被多次触发,只会被推入到队列中一次,避免不必要的计算和 DOM 操作,然后在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。
$nextTick
当我们使用$nextTick API时,实际上也是使用的异步更新中的nextTick,将回调函数添加到回调的任务队列中。当我们在数据变化之后立即使用 $nextTick(callback),回调函数将在 DOM 更新完成后被调用。
nextTick源码部分
const callbacks = []
let pending = false
// 刷新回调
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
let timerFunc
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
// 异步执行
timerFunc = () => {
p.then(flushCallbacks)
}
isUsingMicroTask = true
} ...此处省略其他执行环境判断代码...
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
// 将回调存入callbacks的数组中
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
// 启动异步执行
pending = true
timerFunc()
}
}
DOM更新
渲染函数render
在上一步的执行异步更新阶段,会通过watcher.run()调用最终的更新函数vm._update(vnode: VNode, hydrating?: boolean),其中vnode由render函数执行后返回。
updateComponent = () => {
// 执行vm._render()渲染函数,返回VNode
vm._update(vm._render(), hydrating)
}
需要注意的是,在创建vnode时,如果为自定义组件标签,会使用vue.component创建该自定义组件的构造函数,并生成组件的钩子函数(这里面hooks在patch过程中触发)
来看一下模版编译后的render函数:
执行render函数,生成的vnode
虚拟DOM更新
vm._update()执行更新,调用patch函数进行新旧vnode的对比,得到最小的dom操作量,配合异步更新策略减少刷新频率,从而提升性能。
patch过程(同层比较,深度优先)
- 首先进行树级比较,三种情况
- newVnode不存在,oldVnode存在,删除
- newVnode存在,oldVnode不存在,新增
- 新旧vNode都存在,执行patchVnode,开始对比更新
-
patchVnode对比更新,包括:文本更新,属性更新,子节点更新
-
子节点对比更新
在实际的业务场景中,不是所有子节点的位置都会发生变化,总有一些节点是没有发生移动的,对于这些位置不变或者说可以预测的,我们可以采用更快的查找方式==> 判断相同位置的节点是否为同一节点,如果刚好能够匹配到,就可以直接进行更新节点的操作,如果尝试失败了,再使用循环的方式。这种方式可以在很大程度上避免循环来查找节点,从而提升执行速度。
实际遍历过程:
- 在新旧的子vnode中做首尾标记,当oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx时结束循环,首尾节点两两交叉比较寻找相同节点,都不符合时则使用在oldVnode数组中找与newStartIdx相同的节点
- 当oldStartVnode/newStartVnode或者oldEndVnode/newEndVnode满足sameVnode时,直接执行patchVnode更新
- 当oldStartVnode/newEndVnode满足条件时,说明节点位置发生移动,在进行patchVnode更新后,需要将oldStartVnode.elm移动到oldEndVnode.elm后面
- 当oldEndVnode/newStartVnode满足条件时,说明节点位置发生移动,在进行patchVnode更新后,需要将oldEndVnode.elm移动到oldStartVnode.elm前面
- 如果以上条件都不满足,即首尾没有找到相同节点
-
如果newStartVnode存在key,则直接通过key值去查找old Vnode中的相同节点;
-
没有找到,则在old Vnode中寻找与newStartVnode满足sameVnode节点;
-
若存在vnodeToMove,则将vnodeToMove.elm节点移动到oldStartVnode.elm前面;
- 若不存在,则创建新的DOM节点插入到oldStartVnode.elm前面
相同节点判断逻辑sameVnode:
// 对比两个节点是否相同
// 1.key
// 2.tag
// 3.isComment都为注释节点
// 4.data属性存在
// 5.input type
// 6.异步组件
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
循环结束
- 当oldStartIdx > oldEndIdx时,说明旧节点已经遍历结束,判断新节点数组中是否还剩下,批量创建并插入DOM中;
- 当newStartIdx > newEndIdx时,新节点已经遍历结束,旧节点数组中还有剩下,从DOM中移除。
结束语
vue2通过模版来描述状态与视图的映射关系,先将模版编译为渲染函数,然后执行渲染函数生成虚拟节点,为了避免不必要的DOM操作,将虚拟节点与上一次的虚拟节点做对比,找出真正需要更新的节点来进行DOM操作。
之所以需要使用虚拟DOM的方式来更新视图,是因为vue的变化侦测可以在一定程度上知道具体哪些数据发生了变化,也就是说它可以在一定程度上知道哪些节点使用了这些状态,如果每一个节点都绑定了一个watcher来观察状态的变更,这就存在一定的内存开销,当状态被越多的节点使用,开销就越大。引入虚拟DOM,每个组件对应一个watcher,当数据变化后,通知到组件watcher,然后组件再去通过虚拟DOM对比,完成视图的更新,这是一个比较折中的方案!