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

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 的设计逻辑:

  1. 新属性 = 对象结构变化 → 需要通知所有观察者
  2. 已存在的属性 = 只是值变化 → 应该通过属性自己的 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 前先赋值!
相关推荐
未来之窗软件服务1 小时前
一体化系统(九)智慧社区综合报表——东方仙盟练气期
大数据·前端·仙盟创梦ide·东方仙盟·东方仙盟一体化
陈天伟教授4 小时前
人工智能训练师认证教程(2)Python os入门教程
前端·数据库·python
信看5 小时前
NMEA-GNSS-RTK 定位html小工具
前端·javascript·html
Tony Bai5 小时前
【API 设计之道】04 字段掩码模式:让前端决定后端返回什么
前端
苏打水com5 小时前
第十四篇:Day40-42 前端架构设计入门——从“功能实现”到“架构思维”(对标职场“大型项目架构”需求)
前端·架构
king王一帅5 小时前
流式渲染 Incremark、ant-design-x markdown、streammarkdown-vue 全流程方案对比
前端·javascript·人工智能
苏打水com6 小时前
第十八篇:Day52-54 前端跨端开发进阶——从“多端适配”到“跨端统一”(对标职场“全栈化”需求)
前端
Bigger6 小时前
后端拒写接口?前端硬核自救:纯前端实现静态资源下载全链路解析
前端·浏览器·vite
BD_Marathon6 小时前
【JavaWeb】路径问题_前端绝对路径问题
前端