前言
在上一篇文章中我们深入分析了响应式系统的核心源码,从 initState 初始化状态的入口函数开始,先后分析了 initProps、initData、initComputed、initWatch,以及 Vue2 中实现变化侦测的核心 API Object.defineProperty、observe 函数、Observer 类,以及异步调度的核心函数 nextTick。
这篇文章来分析 Vue2 中 Object 和 Array 侦测数据的方式有何不同,以及跟响应式有关几个函数,比如 $set、$delete、$watch。
Object 侦测数据变化的方式
Object 主要通过 Object.defineProperty 将属性转换成 getter/setter 的形式来追踪变化,读取数据时会触发 getter,修改数据时会触发 setter。
在 getter 中收集有哪些依赖使用了这个数据。当修改数据触发 setter 时,会通知在 getter 中收集到的所有依赖触发更新。
收集依赖需要一个专门存储依赖的地方,为此有了 Dep 类,每个属性对应一个 Dep 类实例,每个实例都维护着一个 subs(订阅者列表),也就是收集依赖的列表,可以用它来添加依赖、移除依赖、通知依赖等。
所谓依赖就是各种 watcher,比如 render watcher、computed watcher、自定义 watcher,这些不同的 watcher 都对应着一个共同类,Watcher 类,哪个 Watcher 读取了属性触发该属性的 getter,就把哪个 Watcher 收集到该属性对应的 Dep 实例的 subs(订阅者列表)中。待该属性发生变化后,遍历 subs(订阅者列表),通知收集的各个依赖(Watcher)。
Watcher 的原理就是先把自己设置为全局唯一的那个 Dep.target,然后读取数据,触发属性的 getter 从而将当前 Dep.target(当前读取数据的 Watcher)收集到订阅者列表中。通过这种方式,Watcher 可以主动去订阅任意一个数据的变化。
此外还有 Observer 类,给 object 添加不可枚举属性 _ob_ 属性,标志为监视对象。然后将 object 下的所有子属性都转换成响应式的,也就是它会监测 object 中所有数据(包括子数据)的变化。我们看一张大致的流程图:
通过上图我们可以看出 object 的变化侦测局限于属性的 getter/setter,也就是读取和修改,那如果要往对象身上添加/删除一个属性呢?这时候可触发不了 getter/setter,在 ES6 之前,JavaScript 未提供元编程的能力,所以在对象上新增属性或删除属性都无法侦测到。为此 Vue 提供了 $set 和 $delete 来将新增/删除的属性添加为响应式,在下面会详细分析这两个 API 的源码实现。
Array 侦测数据变化的方式
array 监测数据的方式和 object 不同,我们常调用数组身上的方法来向数组添加/删除元素,比如:
this.fruitList.push('apple')
JavaScript 内置数组的方法可不具备收集依赖的性质,故在数组发生变化后无法通知依赖触发更新,那怎么办呢?既要使用数组的方法来改变数组,又要其能实现变化侦测。
Vue 巧妙的通过拦截数组原型上的方法来扩展其他操作(实现变化侦测),我们来分析下:
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)
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
if (__DEV__) {
ob.dep.notify({
type: TriggerOpTypes.ARRAY_MUTATION,
target: this,
key: method
})
} else {
ob.dep.notify()
}
return result
})
})
- arrayProto:数组的原型
- arrayMethods:基于数组原型创建的一个新对象
- methodsToPatch:内置数组的一些能改变数组的常用方法(比如 push、unshift 等)
首先遍历 methodsToPatch 数组,定义的常量 original 是内置数组中的源方法,比如这里的 push、pop、shift 等等,然后调用 def 函数,def 函数我们之前分析过: def:第一个参数是对象,第二个参数是属性,第三个参数是属性对应的属性值,这里传的 arrayMethods 是基于内置数组原型创建出的新对象,method 是属性名(数组中的方法名),第三个参数属性值是自定义的函数,def 函数内部会将该对象的这个属性(数组的方法)设为不可枚举的,属性值是你自定义的方法 mutator,接下来我们就聚焦这个自定义的函数 mutator。
mutator:使用扩展运算符接收所有形参
original.apply 调用 original 这个函数,original 就是内置数组中的源方法,this 就是调用方法的那个数组,args 就是调用该方法传的参数,比如我们调用数组的 push 方法,这里就相当于我们调用了原生数组中的 push 方法,得到返回值 result,也就是说这里的操作和我们平常调用一个内置的数组方法是一样的。
const result = original.apply(this, args)
但自定义方法 mutator 能更灵活,在保证原有数组方法功能的基础上(上边的 original.apply(this, args)),还能继续扩展一些额外的功能,我们往下看:
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) // 监视插入的每个元素
this 就是调用方法的那个数组,很显然数组经过 observe 后就是一个监视对象了,每个监视对象上都有一个 _ob_ 属性,接着条件分支语句 switch 判断用户调用的 method 是什么,对于 push、unshift 方法,就是往数组后边或者前边推送元素,inserted 变量用来存储将要插入的元素,args 就是调用方法时传入的参数,对于 splice 方法,第一个参数是开始索引,第二个参数是要删除元素的个数,第三个参数往后就是要插入的元素了,所以通过 slice 方法截取从第三个参数往后的所有参数。判断 inserted 不为空,即有要插入的元素,那就调用监视对象 ob 上的 observeArray 方法,将 inserted 变量传入,observeArray 函数会把要插入的元素都添加为响应式。
// notify change
if (__DEV__) {
ob.dep.notify({
type: TriggerOpTypes.ARRAY_MUTATION,
target: this,
key: method
})
} else {
ob.dep.notify()
}
return result
最后调用监视对象 ob 对应 Dep 实例上的 notify 方法来通知收集的依赖触发更新,因为往数组插入元素,数组发生了变化,那么要通知所有订阅了该数组的依赖。通知完返回调用源数组方法的返回值。
我们来看下数组的大致流程图:
$set
我们来分析 Vue 原型上的 $set 函数,$set 赋值的是 set 函数(所在文件:src/core/observer/index.ts)。
入参:
- target:目标对象
- key:属性名
- val:属性值
首先是参数校验:
if (__DEV__ && (isUndef(target) || isPrimitive(target))) {
warn(
`Cannot set reactive property on undefined, null, or primitive value: ${target}`
)
}
if (isReadonly(target)) {
__DEV__ && warn(`Set operation on key "${key}" failed: target is readonly.`)
return
}
isUndef 判断值是不是 undefined 或 null,isPrimitive 判断值是不是原始类型。那第一个判断就是开发者环境且目标对象 target 是空或者是原始类型,抛出错误提示:不能将响应式属性设在空值或者原始类型的目标上。
isReadonly 判断目标是不是只读的,如果 target 目标对象是只读的,而你调用 set 函数就是想要往 target 上添加属性,那就抛出错误提示:设置属性失败,target 是只读的。
const ob = (target as any).__ob__
if (isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
// when mocking for SSR, array methods are not hijacked
if (ob && !ob.shallow && ob.mock) {
observe(val, false, true)
}
return val
}
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
if ((target as any)._isVue || (ob && ob.vmCount)) {
__DEV__ &&
warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
return val
}
if (!ob) {
target[key] = val
return val
}
- 获取 target 的监视属性 _ob_
- 判断 target 是个数组且 key 属性(在数组中代表索引)是有效的。isValidArrayIndex 就是判断传入的 index 是不是在有效的区间内,满足 target 是数组且 key 索引在有效区间内,设置 target 的新长度,然后往 target 的 key 索引位置上插入整个 val,在上边数组的变化侦测我们知道,数组上的 push、splice、reverse 等方法被自定义方法 mutator 重写了,具备拦截操作,能够在插入元素时,为元素添加响应式并触发依赖更新。但是这里 Vue 官方注释写着: when mocking for SSR, array methods are not hijacked,意思就是在 mock 的服务环境中,数组方法不具备拦截操作,所以判断是 mock 的情况,要主动调用 observe 函数监视这个 val。最后返回这个插入的 val 元素。
- 接着判断如果 key 属性已经在 target 上但不在 Object 的原型中,说明只是要修改 target 上的 key 属性对应的值,直接将 val 赋值给 target[key],然后返回这个最新的 val 值。
- 判断 target 上有没有 _isVue 属性或者这个 target 监视对象有 vmCount 属性,有 _isVue 属性证明是 Vue 实例,而有 vmCount 属性证明是根 $data 对象,显然不能往它们身上直接添加响应式属性,因此会抛出错误提示:避免往 Vue 实例或根 $data 上添加响应式属性,在 data 选项中提前明确定义好。最后返回 val 值。
- 判断没有 ob,ob 赋值为 target 的 _ob_ 属性,是作为监视对象的标识,那既然没有这个属性说明 target 不是一个监视对象,只是普通对象,那仅将 val 赋值给 target[key],然后返回 val。
- 接着就能调用 defineReactive 将 key 属性添加为响应式了,既然添加了属性,那也要触发这个 target 的依赖项更新,ob.dep.notify() 通知 target 监视对象的 Dep 实例上的所有依赖去触发更新。最后返回这个 val。
defineReactive(ob.value, key, val, undefined, ob.shallow, ob.mock)
if (__DEV__) {
ob.dep.notify({
type: TriggerOpTypes.ADD,
target: target,
key,
newValue: val,
oldValue: undefined
})
} else {
ob.dep.notify()
}
return val
$delete
$delete 也是 Vue 原型上的方法,赋值为 del 函数(文件:src/core/observer/index.ts)
入参:
- target:目标对象
- key:属性
if (__DEV__ && (isUndef(target) || isPrimitive(target))) {
warn(
`Cannot delete reactive property on undefined, null, or primitive value: ${target}`
)
}
if (isArray(target) && isValidArrayIndex(key)) {
target.splice(key, 1)
return
}
- 和 set 函数一样,先判断 target 属性是不是空或者是原始类型,如果是就抛出错误提示。
- 判断 target 是数组且 key(索引)在有效范围内,调用 splice 方法将该 key 从 target 中移除,然后返回。
const ob = (target as any).__ob__
if ((target as any)._isVue || (ob && ob.vmCount)) {
__DEV__ &&
warn(
'Avoid deleting properties on a Vue instance or its root $data ' +
'- just set it to null.'
)
return
}
if (isReadonly(target)) {
__DEV__ &&
warn(`Delete operation on key "${key}" failed: target is readonly.`)
return
}
if (!hasOwn(target, key)) {
return
}
delete target[key]
if (!ob) {
return
}
if (__DEV__) {
ob.dep.notify({
type: TriggerOpTypes.DELETE,
target: target,
key
})
} else {
ob.dep.notify()
}
- 判断 target 有没有 _isVue 或 vmCount 属性,有的话抛出错误提示(此处和 set 函数原因一样的)。
- target 是不是只读的,是的话抛出错误提示:无法对 key 属性执行删除操作,target 是只读的。
- 调用 hasOwn 函数,判断 key 属性是不是 target 自身的属性,如果不是直接返回
- 接着 delete target[key] 将属性从 target 上删除
- 判断 !ob 直接返回,说明是普通对象,删除完属性无需其他操作
- 下面逻辑就是针对 target 是监视对象的情况,监视对象的 Dep 实例的 notify 方法通知所有依赖项触发更新。
$watch
同样定义在 Vue 原型上的 $watch 方法(文件:src/core/instance/state.ts)
入参:
- expOrFn:回调函数或字符串表达式
- cb:回调函数
- options:配置选项
const vm: Component = this
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {}
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)
if (options.immediate) {
const info = `callback for immediate watcher "${watcher.expression}"`
pushTarget()
invokeWithErrorHandling(cb, vm, [watcher.value], vm, info)
popTarget()
}
return function unwatchFn() {
watcher.teardown()
}
- vm 赋值为调用 $watch 的组件实例
- 判断第二个参数 cb 是不是一个对象,是的话调用 createWatcher,其实就是将对象中的 handler 函数拿出来,然后再调用 $watch,这样就保证 $watch 的第二个参数 cb 是回调函数了
- 获取 options
- 将 options 上的 user 属性设为 true,表示这是用户自定义的 watcher
- 然后 new Watcher(传入相应参数),创建一个 Watcher 实例
- 判断 options 的 immediate 是否为 true,表示是否立即以表达式触发回调,是的话,先 pushTarget 将当前 Watcher 实例设为 Dep.target,然后调用 invokeWithErrorHandling 函数触发 handler 回调,最后调用 popTarget 将 Watcher 出栈并更新 Dep.target。
- 返回值是一个 unwatchFn 函数,内部调用当前 Watcher 实例的 teardown 函数用来解绑监听,teardown 会将该 Watcher 实例与依赖的所有 Dep 解除绑定关系,也就是和订阅的所有 Dep 断开关系。
总结
本篇文章我们先是分析了 object 和 array 实现变化侦测的不同方式,从中也知道了 Vue 的变化侦测在添加/删除的操作上是无法生效的,为此还提供了添加、删除和监视的响应式 API。 这几个 API 的实现和我们上一章分析的实现响应式的核心函数和类脱离不开,具体可看:Vue源码分析 - 响应式系统(上)- 掘金
本文对你有帮助的话,可以点个赞支持下呀,欢迎留言讨论~