Vue 为什么要用 vm.$set() 解决对象新增属性不能响应的问题?
本地原文只保留了 GitHub 面试题链接。本文保留原问题和 Vue 2 的实现思路,并补充 Vue 3 的对应写法。
问题背景
Vue.set(object, propertyName, value)
vm.$set(object, propertyName, value)
在 Vue 2 中,响应式对象的已有属性会在初始化时被转换成 getter/setter。初始化之后直接给普通对象新增属性,Vue 没有机会为这个新属性安装 getter/setter,因此依赖它的视图不会自动更新:
下面两段 mounted 是对照示例,不能在同一次执行中连续运行;否则直接赋值后属性已经存在,后面的 $set 只会命中“已有属性”分支:
// 反例:Vue 2 不会为这个新属性自动安装响应式 getter/setter
mounted() {
this.user.age = 18
}
// 正确的 Vue 2 历史写法
mounted() {
this.$set(this.user, 'age', 18)
}
这里需要区分两个概念:Object.defineProperty() 负责 Vue 2 的响应式依赖收集与变更通知;v-model 等功能才通常被称为“双向绑定”。不能简单说 Object.defineProperty() 本身实现了完整的双向数据绑定。
Vue 2 的 Vue.set / vm.$set
Vue.set(target, key, value)是全局方法。vm.$set(target, key, value)是实例方法别名。- 两者主要用于已经被 Vue 2 Observer 观察的嵌套对象,或者用于通过索引更新数组。
- 根实例已经创建后,不能依靠
$set随意增加根级响应式属性;根级属性应在data中预先声明。
对象新增属性
this.$set(this.user, 'age', 18)
// 等价于 Vue.set(this.user, 'age', 18)
如果要一次加入多个属性,不要把新属性直接交给 Object.assign() 后期待它们自动响应;可以创建一个新对象替换旧引用:
this.user = Object.assign({}, this.user, {
age: 18,
city: 'London'
})
数组索引和 length
Vue 2 不能检测以下两种数组变化:
this.items[index] = value
this.items.length = newLength
可以使用 $set 或 splice:
this.$set(this.items, index, value)
this.items.splice(index, 1, value)
this.items.splice(newLength)
Vue 2 会包装 push、pop、shift、unshift、splice、sort、reverse 七个变异方法,使这些方法可以通知依赖。
Vue 2.7 set 的核心源码
下面根据 Vue 2.7.16 src/core/observer/index.ts 整理核心分支。它是源码阅读用的简化版,不是完整文件:
export function set(target, key, val) {
// 数组索引:通过 splice 触发 Vue 2 的数组响应式通知
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
return val
}
// 已有属性:直接赋值即可走已有 setter
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
const ob = target.__ob__
// 非响应式对象:只能普通赋值,Vue 不会为它建立依赖
if (!ob) {
target[key] = val
return val
}
// 为新属性安装 getter/setter,并通知依赖该对象的订阅者
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}
真实的 Vue 2.7 源码还会处理 Vue 实例、根 $data、开发环境警告、非法数组索引等情况。核心过程可以概括为:
- 数组索引使用
splice,绕过直接索引赋值无法被拦截的限制。 - 已有属性直接赋值,使用它原本已经存在的 setter。
- 对响应式对象的新属性调用
defineReactive,动态安装 getter/setter。 - 通过对象的依赖管理器通知更新。
defineReactive 会为属性创建 getter/setter。getter 在读取时收集依赖,setter 在赋值时通知依赖;这就是 $set 能让 Vue 2 视图更新的原因。
Vue 3 为什么不需要 $set
Vue 3 使用 Proxy 代理对象,可以拦截属性新增、修改、删除以及数组索引和 length 等操作:
import { reactive } from 'vue'
const state = reactive({
user: { name: 'Ada' },
items: ['a', 'b']
})
state.user.age = 18
state.items[1] = 'Vue'
state.items.length = 1
delete state.user.name
Vue 3 已移除 Vue.set、vm.$set、Vue.delete 和 vm.$delete。使用 reactive() 创建的对象直接赋值即可;如果使用普通对象而没有经过 Vue 的响应式 API,普通赋值当然也不会自动触发 Vue 更新。
常见边界
- Vue 2 的根级 data 属性要在初始化时声明。
Vue.set只能对可观察的 Vue 2 数据建立响应式;对一个普通未观察对象调用它,最终仍只是普通赋值。Object.assign()不会把新增键自动变成 Vue 2 响应式属性。- Vue 3 的
reactive()只接收对象类型,原始值通常使用ref()。 - Vue 2 项目升级到 2.7 并不等于拥有 Vue 3 的 Proxy 响应式;Vue 2.7 仍受 Vue 2 的响应式限制。