前言
最近用vue在项目做一个抽奖页面的时候,在往数组添加对象数据时,因为对象是引用类型,没有进行深拷贝,引发了响应不正确,虽然不是什么大问题,但毕竟也是遇到的一个小坑,就记录一下。
抽奖逻辑
抽奖的逻辑很简单,就是类似老虎机模式抽奖,我是用滚动监听,记录滚动条的滚动距离来算出中奖的人,因为初始数据可能不够,当滚到底部还没抽奖完,就用初始数据concat进去继续抽,反复这样,直至中奖,下面是伪代码。
<template>
<div>
<ul>
<li v-for="(item, idx) in users" :class="{lucky: item.lucky}">
{{item.name}}
</li>
</ul>
<button @click="concat">concat</button>
<button @click="set">set</button>
</div>
</template>
<script>
export default {
data () {
return {
// 模拟请求回来的用户数据
mockData: [
{
name: 'tom',
id: 1
},
{
name: 'jack',
id: 2
}
],
users: []
}
},
created() {
// 保留一份数据,供底部插入用
this.clone = JSON.parse(JSON.stringify(this.mockData))
// 赋值
this.users = JSON.parse(JSON.stringify(this.mockData))
},
methods: {
concat() {
// 模拟concat数据
this.users = this.users.concat(this.clone)
},
set() {
// 假如数据插入了2次,然后索引为4的中奖了,$set一下第四项的数据,为该dom添加中奖样式的lucky类
this.$set(this.users[4], 'lucky', true)
}
}
}
</script>
看到这里,可能大神一眼就能看出来是哪里错了,是的,这样的话索引为2的那份数据也会添加了lucky类,不过当时身在其中就比较困惑,也可能是对引用类型这方面理解的不透彻,以为在created阶段深克隆了就能完事了,当然这种情况肯定是打印出来users数据出来看看。

然后$set了之后的users数据

我们知道,在数据劫持时,会为每个数据创建observe观察者对象,创建时也会绑定一个唯一id的dep,用来收集watcher,然后用__ob__来标记该对象是否已被观测过。下面是vue,observe一个值 的源码
function observe (value, asRootData) {
if (!isObject(value) || value instanceof VNode) {
return
}
var ob;
// 当有__ob__属性时,表明该对象已被观测过,直接赋值给ob
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__;
}
// 没有的话,ob = new Observe,实例化Observe
else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value);
}
if (asRootData && ob) {
ob.vmCount++;
}
return ob
}
可以看到,vue观测一个对象时,如果是已存在__ob__属性,就表明已经监测过,会直接赋值给ob,没有再创建Observe对象(这个也是vue内部做的一个性能优化手段),所以当看到有相同id的dep值的__ob__属性时,就基本可以确认是引用类型捣的鬼了。
我们concat进去的clone数组是引用类型,所以当concat进去后,vue为其里面每个数据对象都实例化了observe对象,用__ob__标记,因为clone是引用类型的关系,所以clone数组也一样变了。所以当我们第二次concat时,只是引用了上一次的clone数组,而clone数组其实已经观测过了,所以在为users数据重新遍历观测时,新加入的clone数组就直接返回__ob__属性了。
那么为什么$set之后,会为索引为2和4都添加了lucky属性呢,我们来看一下$set的源码
Vue.prototype.$set = set;
function set (target, key, val) {
.........(省略)
var ob = (target).__ob__; // 取__ob__属性,也就是Observe对象
if (!ob) {
target[key] = val;
return val
}
defineReactive?1(ob.value, key, val); //手动绑定响应式对象
ob.dep.notify(); // 手动触发notify,通知视图更新
return val
}
// 这个函数是vue实现相应式的核心函数,就是用Object.defineProperty 劫持了对象的属性(具体大家可以去vue源码再结合具体例子看看)
function defineReactive?1 (
obj,
key,
val,
customSetter,
shallow
) {
var dep = new Dep();
var property = Object.getOwnPropertyDescriptor(obj, key);
if (property && property.configurable === false) {
return
}
var getter = property && property.get;
var setter = property && property.set;
if ((!getter || setter) && arguments.length === 2) {
val = obj[key];
}
var childOb = !shallow && observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
var value = getter ? getter.call(obj) : val;
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
if (Array.isArray(value)) {
dependArray(value);
}
}
}
return value
},
set: function reactiveSetter (newVal) {
var value = getter ? getter.call(obj) : val;
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter();
}
if (getter && !setter) { return }
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = !shallow && observe(newVal);
dep.notify();
}
});
}
我们看到$set的原理就是用传进来的target值,取target的__ob__属性,然后重新为新的key值绑定了响应式对象,然后手动触发了ob.dep的notify函数,通知watcher更新,触发视图重新渲染。因为users插入的数据是同一份引用,__ob__也就相同,所以也就导致了这样的渲染结果。
所以我们在concat之前每次都应该深拷贝一份一个新的数组,在进行concat
const clone = JSON.parse(JSON.stringify(this.clone))
this.users = this.users.concat(clone)
总结
这里也是引用类型在一个小坑,所以当我们要复制一个对象(引用类型),或者在其他对象去操作这个对象(引用类型)时,为了不影响原有对象(引用类型)的值,我们应该deepCopy一份,维护一份新的内存。最后如果文章有什么问题,欢迎大佬指出。