Vue 2 响应式有哪些限制?如何绕过?
Vue 2 基于 Object.defineProperty,无法检测对象属性的新增和删除,也无法检测通过下标赋值和修改 length 改变数组。用 Vue.set/Vue.delete 或 splice 绕过;push、splice 等 7 个数组方法通过替换数组原型被拦截。
参考答案
简答
- Vue 2 已于 2023-12-31 停止维护,本题以 Vue 2.7 为准。
- getter/setter 只在初始化时为已存在的属性定义,所以新增、删除对象属性都检测不到。
- 数组
items[i] = v和items.length = n检测不到;用Vue.set/vm.$set、splice代替。 Vue.set对新属性调用defineReactive并通过__ob__.dep通知;Vue.delete删除后同样通知。两者都不能用于 Vue 实例或根级$data。push、pop、shift、unshift、splice、sort、reverse被包装在响应式数组的原型上,执行原方法后通知更新,并对新插入的元素做 observe。
对象:新增与删除属性
官方文档说明,Vue 在实例初始化时做 getter/setter 转换,属性必须一开始就在 data 中才是响应式的。Vue 也不允许在已创建的实例上动态添加根级响应式属性,需要时应在 data 中预先声明(可以设为空值)。
JavaScript
Vue.set(vm.user, 'age', 18); // 或 this.$set(this.user, 'age', 18)
Vue.delete(vm.user, 'age'); // 或 this.$delete(...)
// 一次加多个属性:用新对象替换,而不是 Object.assign 到原对象
this.user = Object.assign({}, this.user, { a: 1, b: 2 });
set 的源码行为:
- 属性已存在于目标上:直接赋值后返回,由原有 setter 负责通知。
- 目标不是响应式对象(没有
__ob__):只赋值,不通知。 - 目标是 Vue 实例或根
$data(ob.vmCount > 0):开发环境警告并返回。 - 否则
defineReactive新属性,再ob.dep.notify()。这个 dep 就是读取该对象时由childOb.dep.depend()收集的依赖。
数组:下标与 length
JavaScript
vm.items[1] = 'x'; // 不是响应式
vm.items.length = 2; // 不是响应式
Vue.set(vm.items, 1, 'x'); // 或 vm.items.splice(1, 1, 'x')
vm.items.splice(2); // 截断数组
对数组下标,Vue.set 内部会先扩展 length,再调用 target.splice(key, 1, val),依靠被拦截的 splice 触发更新。Vue 2 没有为数组下标逐个定义 getter/setter,length 本身也是不可配置属性,无法用 defineProperty 改成访问器。
7 个变异方法的拦截
array.ts 以 Array.prototype 为原型创建 arrayMethods,在它上面为 7 个方法定义包装函数:
- 调用原方法得到结果。
push/unshift的全部参数、splice从第 3 个起的参数是新插入的元素,调用ob.observeArray(inserted)变成响应式。ob.dep.notify()通知依赖这个数组的 Watcher。
Observer 处理数组时把它的原型替换为 arrayMethods(环境不支持 __proto__ 时把方法逐个定义到数组上)。filter、concat、slice 等返回新数组的方法不会被拦截,用返回值替换原数组即可,官方文档说明 Vue 会尽量复用已有 DOM,替换不会导致整个列表重新渲染。
示例
JavaScript
// 用 Node 运行,vue@2.7.16
const Vue = require('vue');
const vm = new Vue({ data: { user: { name: 'a' }, items: ['a', 'b', 'c'] } });
const log = [];
vm.$watch(() => JSON.stringify(vm.user), (v) => log.push('user:' + v));
vm.$watch(() => vm.items.join(), (v) => log.push('items:' + v));
vm.user.age = 1;
vm.items[1] = 'x';
vm.items.length = 2;
Vue.nextTick(() => {
console.log(log); // [],三次修改都未被检测到
vm.$set(vm.user, 'city', 'x');
vm.items.splice(1, 1, 'y');
Vue.nextTick(() => console.log(log));
// [ 'user:{"name":"a","age":1,"city":"x"}', 'items:a,y' ]
});
第二次输出里 items 是 a,y:length = 2 已经截断了数组,只是当时没有通知。
常见追问
- 为什么
this.$set(this.user, 'age', 2)在age已被普通赋值添加后不更新?属性已存在时set只赋值,而这个属性没有 setter,不会通知。 - Vue 3 还有这些限制吗?没有,见《defineProperty 和 Proxy 实现的响应式有什么区别?》。