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 前先赋值!
相关推荐
zhuà!19 小时前
uv-picker在页面初始化时,设置初始值无效
前端·javascript·uv
Amumu1213819 小时前
React应用
前端·react.js·前端框架
摸鱼的春哥19 小时前
实战:在 Docker (Windows) 中构建集成 yt-dlp 的“满血版” n8n 自动化工作流
前端·javascript·后端
小酒星小杜19 小时前
在AI时代,技术人应该每天都要花两小时来构建一个自身的构建系统
前端·vue.js·架构
测试游记19 小时前
基于 FastGPT 的 LangChain.js + RAG 系统实现
开发语言·前端·javascript·langchain·ecmascript
阿奇__19 小时前
elementUI table 多列排序并保持状态样式显示正确(无需修改源码)
前端·vue.js·elementui
zhengxianyi51519 小时前
数据大屏-单点登录ruoyi-vue-pro
前端·javascript·vue.js
我想回家种地19 小时前
python期末复习重点
前端·javascript·python
行者9619 小时前
Flutter适配OpenHarmony:高效数据筛选组件的设计与实现
开发语言·前端·flutter·harmonyos·鸿蒙
Serendipity-Solitude19 小时前
HTML 五子棋实现方法
前端·html