记录一次使用vue因引用类型引发的__ob__属性的小坑

884 阅读3分钟

前言

最近用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数据出来看看。

avatar

然后$set了之后的users数据

avatar
乍一看,索引2和4的数据的__ob__属性里的dep的id是相同的。

我们知道,在数据劫持时,会为每个数据创建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一份,维护一份新的内存。最后如果文章有什么问题,欢迎大佬指出。