概况
在 Vue 开发的过程中,多少都会遇到数据更新后,页面没有更新渲染这类问题。而在上两篇文章《Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(上)》和《Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(下)》中,从「实例化」、「渲染」、「数据更新」三条线完整地讲述了 Vue「响应式」的工作原理,本文正是基于这些原理去解决一些常见的数据更新相关问题。
对象数据的某些修改无法被检听?
如下的一个场景,obj.message
赋值时能否被监听响应呢?
var vm = new Vue({
data: {
obj: {
a: 1
},
},
template: '{{ obj.message }}'
});
vm.obj.message = 'modified';
答案是不能被监听的。原因:对象属性的添加和删除无法被 Object.defineProperty
监听,正如前文所述,Vue 的数据响应式基于 Object.defineProperty
实现,因此也受限。
解决办法: Vue 提供了特定的方法 vm.$set(obj, propertyName, newValue)
来处理这种情况,至于该方法的具体逻辑,后面会详细展开说明。
数组数据的某些修改无法被监听?
如下的一个场景,三个赋值语句里面,哪个能被监听响应呢?
const vm = new Vue({
data: {
items: [1, 2, 3, 4, 5],
},
});
vm.items[1] = 8;
vm.items[5] = 6;
vm.items.length = 2;
答案是三个操作都不能被监听到。原因:
- 第二个操作
vm.items[5] = 6
应该是比较明显的,与上面对象添加和删除属性类似,数组新添加的元素和删除元素无法被Object.defineProperty
监听。 - 第三个操作
vm.items.length = 2
也是由于Object.defineProperty
的限制,数组的长度直接修改也无法被监听。 - 最容易误判的可能是第一个操作
vm.items[1] = 8
,一种较为常规的说法是Object.defineProperty
不支持监听数组元素的变化,要验证这个说法可以直接用一个例子说明真实的情况。
如下的一个例子:
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
console.log('读取 index ' + key, '当前值是 ' + val)
return val;
},
set(newVal) {
if (val === newVal) {
return;
}
val = newVal
console.log('修改 index ' + key, '新值是 ' + val)
}
})
}
const testArray = [1, 2, 3, 4, 5]
testArray.forEach((c, index) => {
defineReactive(testArray, index, c)
});
testArray[0] = 100;
testArray[5] = 600;
也可以点击这里打开示例查看控制台输出。可以看到,超出范围的数组元素操作 Object.defineProperty
确实无法监听,但范围内的元素重新赋值是可以被监听的。那么为什么在 Vue 中对数组类型的 data
,直接使用下标赋值无法被监听呢?
答案是出于性能考虑,从上面的基础例子中可以看到,对象和数组如果需要监听每个属性和元素,实际上是对每个属性或者元素进行 Object.defineProperty
劫持,对象是监听 key
而数组则是以数字下标作为 key
,数组的数据量可能会很大,因此 Vue 出于性能考虑,并没有对元素下标进行响应式处理。作为补充,Vue 对数组原型链上的几个方法进行劫持,对于会导致元素新增的3个方法 push
、pop
、unshift
会在内部获取新增的元素,执行响应式的处理:
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)
ob.dep.notify()
return result
})
})
与对象类似,如果需要为数字元素重新赋值,可以使用 vm.$set(arr, indexOfItem, newValue)
方法,这里展示一下 $set
的具体实现:
// 精简了非 production 逻辑
export function set (target: Array | 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
}
const ob = (target: any).__ob__
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && 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
}
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}
主要的逻辑包括以下操作:
- 这个方法同时用于对象和数组,因此第一步会检验传入的
target
是否为数组,并且传入的key
(即数组的数字下标)是否符合数组的长度范围(如上面所述,Object.defineProperty
不支持劫持新添加的元素),符合的元素会调用splice
插入到数组中,由于splice
已经被劫持,新增加的元素会进行「响应式」处理。 - 判断如果
key
原先已存在,则无需再监听。 - 判断是根节点 Vue,即最外层的 Vue,或者已经有
__ob__
属性(表示已经进行响应式处理,详情可以浏览前文),则无需再进行监听。 - 如果不符合前面的条件,则表明该属性需要执行「响应式」处理,会调用
defineReactive
方法(响应式数据封装的入口方法,详情可以浏览前文)。