Vue2 的关于 $set 的反直觉行为:为什么先赋值再 $set 不会触发 watch?

130 阅读2分钟

Vue $set 的反直觉行为:为什么先赋值再 $set 不会触发 watch?

核心问题

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 源码简化

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(✅ 触发)

// 初始状态
c = { a: 1 }  // b 不存在

// 执行
this.$set(this.c, 'b', 1000)

// 内部逻辑
'b' in c  // → false(属性不存在)
↓
添加响应式属性 b
↓
c.__ob__.dep.notify()  // ← 触发 watch!

路径 2:先赋值再 $set(❌ 不触发)

// 初始状态
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 的设计逻辑:

  1. 新属性 = 对象结构变化 → 需要通知所有观察者
  2. 已存在的属性 = 只是值变化 → 应该通过属性自己的 setter 触发

但问题是:路径 2 中的 b 虽然存在,但不是响应式的(没有 setter),所以:

  • $set 认为它已存在,不调用 notify()
  • 但它没有 setter,值变化不会触发更新
  • 结果就是:既不通知对象的观察者,也不触发属性的 setter

完整验证代码

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 的两层依赖收集

const c = {
  a: 1,
  __ob__: {
    dep: new Dep()  // ← 对象自己的 dep
  }
}

// 同时,每个响应式属性也有自己的 dep(通过闭包)
Object.defineProperty(c, 'a', {
  get() {
    // 收集依赖到 a 的 dep
  },
  set(val) {
    // 通知 a 的 dep
  }
})

区别

操作触发的 depwatch: cwatch: 'c.a'
c.a = 2a 的 dep❌ 不触发✅ 触发
$set(c, 'b', 1)c.__ob__.dep✅ 触发-
c = {}对象替换✅ 触发-

为什么不需要 deep 也能触发?

watch: {
  c(val) {  // 没有 deep: true
    console.log('触发了')
  }
}

this.$set(this.c, 'b', 1000)
// ✅ 会触发

原因:

  • $set 触发的是 c.__ob__.dep(对象自己的 dep)
  • 监听 c 的 watcher 收集的就是这个 dep
  • 不需要 deep,因为是对象结构变化

deep 的作用:

watch: {
  c: {
    handler(val) {},
    deep: true  // ← 递归收集所有属性的 dep
  }
}

// 现在修改已有属性也会触发
this.c.a = 2  // ✅ 触发(因为收集了 a 的 dep)

最佳实践

❌ 永远不要这样做

this.c.b = 111
this.$set(this.c, 'b', 1000)  // 不会触发 watch

✅ 正确做法

// 方案 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 前先赋值!