Vue set 的反直觉行为:为什么先赋值再 set 不会触发 watch?
核心问题
javascript
watch: {
c(val) {
console.log('watch 触发了')
}
}
// 路径 1:直接 $set
this.$set(this.c, 'b', 1000)
// ✅ 触发 watch
// 路径 2:先赋值再 $set
this.c.b = 1000
this.$set(this.c, 'b', 1000)
// ❌ 不触发 watch
为什么同样用了 $set,结果却不同?
原因:$set 内部有属性存在性检查
$set 源码简化
javascript
function set(target, key, val) {
// 关键判断:属性是否已存在
if (key in target && !(key in Object.prototype)) {
target[key] = val // ← 只做普通赋值
return val // ← 直接返回,不触发通知
}
// 属性不存在才走这里
const ob = target.__ob__
defineReactive(target, key, val) // 定义响应式
ob.dep.notify() // ← 触发 watch!
return val
}
关键点
$set 只在属性不存在时才调用 dep.notify()
两条路径的详细分析
路径 1:直接 $set(✅ 触发)
javascript
// 初始状态
c = { a: 1 } // b 不存在
// 执行
this.$set(this.c, 'b', 1000)
// 内部逻辑
'b' in c // → false(属性不存在)
↓
添加响应式属性 b
↓
c.__ob__.dep.notify() // ← 触发 watch!
路径 2:先赋值再 $set(❌ 不触发)
javascript
// 初始状态
c = { a: 1 } // b 不存在
// 第 1 步:普通赋值
this.c.b = 1000
// 现在:c = { a: 1, b: 1000 }
// 但 b 不是响应式的!
// 第 2 步:$set
this.$set(this.c, 'b', 1000)
// 内部逻辑
'b' in c // → true(属性已存在!)
↓
只执行:c.b = 1000(普通赋值)
↓
不调用 dep.notify() // ← 不触发 watch!
Vue 为什么这样设计?
Vue 的设计逻辑:
- 新属性 = 对象结构变化 → 需要通知所有观察者
- 已存在的属性 = 只是值变化 → 应该通过属性自己的 setter 触发
但问题是:路径 2 中的 b 虽然存在,但不是响应式的(没有 setter),所以:
$set认为它已存在,不调用notify()- 但它没有 setter,值变化不会触发更新
- 结果就是:既不通知对象的观察者,也不触发属性的 setter
完整验证代码
javascript
data() {
return {
c: { a: 1 }
}
},
watch: {
c(val) {
console.log('watch 触发了!', val)
}
},
mounted() {
console.log('=== 测试 1:直接 $set ===')
this.$set(this.c, 'b', 1000)
// ✅ 打印 "watch 触发了!{a: 1, b: 1000}"
setTimeout(() => {
console.log('=== 测试 2:先赋值再 $set ===')
this.c.d = 2000 // 先创建普通属性
this.$set(this.c, 'd', 3000) // 再用 $set
// ❌ 不打印任何东西
}, 1000)
setTimeout(() => {
console.log('=== 测试 3:$set 两次 ===')
this.$set(this.c, 'e', 4000) // 第一次
// ✅ 打印 "watch 触发了!"
this.$set(this.c, 'e', 5000) // 第二次
// ✅ 也会触发(因为 e 已经是响应式的)
}, 2000)
}
dep.notify() 触发的是什么?
Vue 的两层依赖收集
javascript
const c = {
a: 1,
__ob__: {
dep: new Dep() // ← 对象自己的 dep
}
}
// 同时,每个响应式属性也有自己的 dep(通过闭包)
Object.defineProperty(c, 'a', {
get() {
// 收集依赖到 a 的 dep
},
set(val) {
// 通知 a 的 dep
}
})
区别
| 操作 | 触发的 dep | watch: c | watch: 'c.a' |
|---|---|---|---|
c.a = 2 |
a 的 dep |
❌ 不触发 | ✅ 触发 |
$set(c, 'b', 1) |
c.__ob__.dep |
✅ 触发 | - |
c = {} |
对象替换 | ✅ 触发 | - |
为什么不需要 deep 也能触发?
javascript
watch: {
c(val) { // 没有 deep: true
console.log('触发了')
}
}
this.$set(this.c, 'b', 1000)
// ✅ 会触发
原因:
$set触发的是c.__ob__.dep(对象自己的 dep)- 监听
c的 watcher 收集的就是这个 dep - 不需要 deep,因为是对象结构变化
deep 的作用:
javascript
watch: {
c: {
handler(val) {},
deep: true // ← 递归收集所有属性的 dep
}
}
// 现在修改已有属性也会触发
this.c.a = 2 // ✅ 触发(因为收集了 a 的 dep)
最佳实践
❌ 永远不要这样做
javascript
this.c.b = 111
this.$set(this.c, 'b', 1000) // 不会触发 watch
✅ 正确做法
javascript
// 方案 1:只用 $set
this.$set(this.c, 'b', 1000)
// 方案 2:在 data 中预先声明
data() {
return {
c: {
a: 1,
b: null // 预先声明
}
}
}
// 现在可以直接赋值
this.c.b = 1000 // ✅ 会触发
// 方案 3:整体替换对象
this.c = { ...this.c, b: 1000 }
// 方案 4:分两步(如果需要不同值)
this.$set(this.c, 'b', 111) // 第一次触发
this.c.b = 1000 // 第二次触发(因为 b 已是响应式)
总结表格
| 场景 | 属性状态 | $set 行为 | 是否触发 watch |
|---|---|---|---|
$set(c, 'b', 1) |
不存在 | 添加响应式 + notify | ✅ 触发 |
c.b = 1; $set(c, 'b', 2) |
存在(非响应式) | 只赋值,不 notify | ❌ 不触发 |
$set(c, 'b', 1); $set(c, 'b', 2) |
存在(响应式) | 触发 setter | ✅ 触发 |
$set(c, 'b', 1); c.b = 2 |
存在(响应式) | 触发 setter | ✅ 触发 |
记住一句话
$set只在属性不存在时才调用dep.notify()
先赋值再$set= 属性已存在 = 不会触发 watch
关键点:
$set有属性存在性检查:key in target- 普通赋值创建的属性不是响应式的
- 已存在的属性(即使非响应式)会让
$set跳过notify() - 永远不要在
$set前先赋值!