为什么Vue2中无法检测Object和Array的变化

Yukino 1,061 2021-12-29

为什么Vue2中无法检测Object和Array的变化

一、前言

Vue官方文档中有这么一句话——由于 JavaScript 的限制,Vue 不能检测数组和对象的变化。尽管如此我们还是有一些办法来回避这些限制并保证它们的响应性
文中的例子都在codesandbox中有演示样例,可以对照着看,记得更改右边的路由

二、问题

拿对象(Object)来举例,由于 Vue 会在初始化实例时对 property 执行 getter/setter 转化,所以 property 必须在 data 对象上存在才能让 Vue 将它转换为响应式的,Vue无法检测property的添加或移除,这个又分为几种情况。

首先,对于已经创建的实例,Vue不允许动态添加根级别的响应式property,看看下面的例子

<template>
	<div>
    <div>{{ a }}</div>
    <button @click="setA">change</button>
  </div>
</template>
<script>
  export default {
    methods: {
      setA() {
        this.a = 1;
      }
    }
  }
</script>

当然,上面的代码首先会报出warning,因为在渲染过程中使用了未声明的变量,然后在点击了按钮之后,页面还是没有显示出数字1,也就是说在实例创建后添加的根property是无法变为响应式的,同样的对于嵌套对象,也是无法添加新的响应式的property的,来看看下面的例子

<template>
  <div>
    <div>{{ form.a }}</div>
    <button @click="setA">change</button>
  </div>
</template>
<script>
export default {
  data() {
    return {
      form: {},
    };
  },
  created() {
    this.form.a = 2;
  },
  methods: {
    setA() {
      this.form.a = 1;
    },
  },
};
</script>

这里与上面有些许差别,我们在created的时候给form添加了一个a的property,并且被成功的渲染了出来,但是点击按钮时,依然无法更新视图,也就说明了这个a也不是响应式的。

对于数组同样的,我们直接使用索引去更改数组中的内容或者添加元素时,都无法被检测到,即非响应式。

三、怎么解决

前面说到了问题,官方也给出了解决方法,对于Object来说,可以使用Vue.set(object, propertyName, value) 方法向嵌套对象添加响应式 property,对于根级属性依然没有方法,对于Array也可以使用Vue.set(vm.items, indexOfItem, newValue)来更新数组,值得一提的是Vue对Array的以下方法进行了包装:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

所以使用这些方法去更新数组也是可以的。最后还有一种方法,我们可以先把对象复制出来,对副本进行更改(添加或者删除),再将副本赋回给原本“对象”,之后我们再对新添加的property进行修改,也是可以更新视图的,看下面的例子:

<template>
  <div>
    <div>{{ form.a }}</div>
    <button @click="setA">change</button>
    <button @click="setA2">change2</button>
  </div>
</template>
<script>
export default {
  data() {
    return {
      form: {},
    };
  },
  created() {
    this.form.a = 2;
  },
  methods: {
    setA() {
      let form = JSON.parse(JSON.stringify(this.form));
      form.a = 1;
      this.form = form;
    },
    setA2() {
      this.form.a = 11;
    }
  },
};
</script>

先点击以下change按钮,a会由2变为1,再点击change2按钮,a会由1变为11。

四、为什么?

在第三节中,我们提到了如何给Object和Array添加响应式的property,算上Vue包装的Array的方法,总共由三种,前两种暂且不提,因为前两种都是由Vue提供的方法,那么第三种只是将对象复制出来再赋值回去是怎么做到添加响应式对象的呢?

其实在文档中也有提及到——Vue 会在初始化实例时对 property 执行 getter/setter 转化,这么说可能也不是很明晰,下面进入源码时间(我之前的文章中有响应式的完整源码解析,但是我觉得写的其实并不是特别好,整体看起来还是会很头疼,这次我会拣重点的讲,同时会将多个文件中的函数集中起来,所以不会有源码所在的文件,如果有兴趣可以看我之前的文章):

function initData (vm: Component) {
    let data = vm.$options.data;
    // ...
    observe(data, true /* asRootData */);
}

/**
* 尝试为value创建一个observer实例,并返回这个observer
*/
export function observe(value: any, asRootData: ?boolean): Observer | void {
    if (!isObject(value) || value instanceof VNode) {
    	return
  	}
  	// ...
    ob = new Observer(value);
    return ob;
}

export class Observer {
    value: any;
    dep: Dep;

    constructor (value: any) {
        this.value = value;
        // ...
        if (Array.isArray(value)) {
            // ...
            this.observeArray(value);
        } else {
            this.walk(value);
        }
    }

    walk (obj: Object) {
        const keys = Object.keys(obj);
        for (let i = 0; i< keys.length; i++) {
            defineReactive(obj, keys[i], obj[keys[i]]);
        }
    }

    observeArray (items: Array<any>) {
        for (let i = 0, l = items.length; i < l; i++) {
            observe(items[I]);
        }
    }
}

export function defineReactive (...) {
    const dep = new Dep();
    // ...
    // 这里的shallow是一个标志,表示是否要递归给对象的property创建Observe实例,默认是false
    let childOb = !shallow && observe(val);
    Object.defineProperty(obj, key, {
        get: function reactiveGetter () { dep.depend(); },
        set: function reactiveSetter (newVal) { 
          childOb = !shallow && observe(newVal);
          dep.notify(); 
        }
    });
    //  ...
}

我们来梳理一下上面代码做的事情:

  1. 首先是调用了initData去初始化组件中的data“对象”,我们需要关注的是最后的observe(data)
  2. 进入到observe中,我们可以看到observe函数就是尝试为传入的对象,创建一个Observer实例,如果不是对象就直接返回
  3. 来到Observer的构造函数,Observer的构造函数就是对对象中的所有的property调用一次defineReactive,而在defineReactive中,首先是创建了一个Dep(这个可以理解为一个“依赖”的集合,它和Watcher是订阅发布设计模式的实现,这里不用深究)并对传入的val调用一次observe以此对Object类型的property递归进行响应式处理,然后是用defineProperty重写了property的get和set方法,在get方法中”收集““依赖”,即使用了此参数的方法,在set方法中一方面赋值并调用observe使新的值获得响应式能力,同时”触发“这些“依赖”,以此达成视图的响应式更新,这也就是第三种方法也能生效的原因。

五、总结

总的来说,对于根property,若要使其获得响应式能力,一定要在data中声明这个property,对于嵌套对象,可以使用Vue.set方法增加新的具备响应式能力的property,或者直接给这个嵌套对象重新赋值。

六、小彩蛋?

我们回过来看看第二个例子,我们再给form添加一个b属性,像下面这样

<template>
  <div>
    <div>{{ form.a }}</div>
    <div>{{ form.b }}</div>
    <button @click="setA">change</button>
  </div>
</template>
<script>
export default {
  data() {
    return {
      form: {
        b: 1,
      },
    };
  },
  created() {
    this.form.a = 2;
  },
  methods: {
    setA() {
      this.form.a = 1;
      this.form.b = 2;
    },
  },
};
</script>

试试看,这个会和第二个例子有什么不同吧。


# 前端 # Vue