本文主要内容摘抄自黄轶老师的慕课网课程Vue.js 源码全方位深入解析 全面深入理解Vue实现原理,主要用于个人学习和复习,不用作其他用途。
对于一些特殊情况是需要注意的,接下来我们就从源码的角度来看 Vue 是如何处理这些特殊情况的。
对象添加属性
对于使用 Object.defineProperty 实现响应式的对象,当我们去给这个对象添加一个新的属性的时候,是不能够触发它的 setter 的,比如:
var vm = new Vue({
data:{
a:1
}
})
// vm.b 是非响应的
vm.b = 2
但是添加新属性的场景我们在平时开发中会经常遇到,那么 Vue 为了解决这个问题,定义了一个全局 API Vue.set 方法,它在 src/core/global-api/index.js 中初始化:
Vue.set = set
这个 set 方法的定义在 src/core/observer/index.js 中:
export function set(target: Array<any> | Object, key: any, val: any): any {
...
// 如果是一个数组
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;
}
// 每一个响应式对象有个__ob__属性,属性值为这个类observer的实例
const ob = target.__ob__;
if (!ob) {
target[key] = val;
return val;
}
// 把新增的val也变成响应式
defineReactive(ob.value, key, val);
// observer类的实例都有一个dep实例
ob.dep.notify();
return val;
}
set 方法接收 3个参数,target 可能是数组或者是普通对象,key 代表的是数组的下标或者是对象的键值,val 代表添加的值。
首先判断如果 target 是数组且 key 是一个合法的下标,则之前通过 splice 去添加进数组然后返回,这里的 splice 其实已经不仅仅是原生数组的 splice 了,稍后我会详细介绍数组的逻辑。
接着又判断 key 已经存在于 target 中,则直接赋值返回,因为这样的变化是可以观测到了。
接着再获取到 target.__ob__ 并赋值给 ob,之前分析过它是在 Observer 的构造函数执行的时候初始化的,表示 Observer 的一个实例,如果它不存在,则说明 target 不是一个响应式的对象,则直接赋值并返回。
最后通过 defineReactive(ob.value, key, val) 把新添加的属性变成响应式对象,然后再通过 ob.dep.notify() 手动的触发依赖通知,还记得我们在给对象添加 getter 的时候有这么一段逻辑:
export function defineReactive (obj: Object, key: string, val: any...) {
// ...
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
// ...
})
}
在 getter 过程中判断了 childOb,并调用了 childOb.dep.depend() 收集了依赖,这就是为什么执行 Vue.set 的时候通过 ob.dep.notify() 能够通知到 watcher,从而让添加新的属性到对象也可以检测到变化。这里如果 value 是个数组,那么就通过 dependArray 把数组每个元素也去做依赖收集。
数组
接着说一下数组的情况,Vue 也是不能检测到以下变动的数组:
-
当你利用索引直接设置一个项时,例如:
vm.items[indexOfItem] = newValue -
当你修改数组的长度时,例如:
vm.items.length = newLength
对于第一种情况,可以使用:Vue.set(example1.items, indexOfItem, newValue);而对于第二种情况,可以使用 vm.items.splice(newLength)。
我们刚才也分析到,对于 Vue.set 的实现,当 target 是数组的时候,也是通过 target.splice(key, 1, val) 来添加的,那么这里的 splice 到底有什么黑魔法,能让添加的对象变成响应式的呢。
其实之前我们也分析过,在通过 observe 方法去观察对象的时候会实例化 Observer,在它的构造函数中是专门对数组做了处理,它的定义在 src/core/observer/index.js 中。
const arrayProto = Array.prototype
// arrayMethods 是一个继承于数组,但是改写了其中的一些常用方法
const arrayMethods = Object.create(arrayProto)
const arrayKeys = Object.getOwnPropertyNames(arrayMethods);
// arrayKeys = ['push','pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
export class Observer {
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
// hasProto = __proto__ in {}
if (hasProto) {
protoAugment(value, arrayMethods);
} else {
copyAugment(value, arrayMethods, arrayKeys);
}
this.observeArray(value);
} else {
this.walk(value);
}
}
}
这里的 hasProto 实际上就是判断对象中是否存在 __proto__,如果存在执行 protoAugment, 否则执行 copyAugment,来看一下这两个函数的定义:
function protoAugment (target, src, keys) {
target.__proto__ = src
}
function copyAugment (target, src, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
// 把'push','pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'这些数组的方法挂载到target对象中,这些方法是改写后的方法,不是原始数组的方法
def(target, key, src[key])
}
}
protoAugment 方法是直接把 target.__proto__ 原型直接修改为 src,而 copyAugment 方法是遍历 keys,通过 def,也就是 Object.defineProperty 去定义它自身的属性值。对于大部分现代浏览器都会走到 protoAugment,那么它实际上就把 value 的原型指向了 arrayMethods,arrayMethods 的定义在 src/core/observer/array.js 中:
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto) // 声明一个新的数组,原型继承于原始数组
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
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
ob.dep.notify()
return result
})
})
function def (obj: Object, key: string, val: any, enumerable?: boolean) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
可以看到,arrayMethods 首先继承了 Array,然后对数组中所有能改变数组自身的方法,如 push、pop 等这些方法进行重写。重写后的方法会先执行它们本身原有的逻辑,并对能增加数组长度的 3 个方法 push、unshift、splice 方法做了判断,获取到插入的值,然后把新添加的值变成一个响应式对象,并且再调用 ob.dep.notify() 手动触发依赖通知,这就很好地解释了之前的示例中调用 vm.items.splice(newLength) 方法可以检测到变化。
通过这一章的分析,我们对响应式对象又有了更全面的认识,如果在实际工作中遇到了这些特殊情况,我们就可以知道如何把它们也变成响应式的对象。其实对于对象属性的删除也会用同样的问题,Vue 同样提供了 Vue.del 的全局 API,它的实现和 Vue.set 大同小异,甚至还要更简单一些。