前端知库

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 个方法定义包装函数:

  1. 调用原方法得到结果。
  2. push/unshift 的全部参数、splice 从第 3 个起的参数是新插入的元素,调用 ob.observeArray(inserted) 变成响应式。
  3. 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 已经截断了数组,只是当时没有通知。

常见追问