一句话先回答:Vue3 里 watch 一个对象,只改它的某个属性却没触发回调,多半是因为你监听的是对象本身、而没开 deep,或者你用了箭头函数又没给 getter。最稳的处理是:只想盯某个属性就用 getter 精确监听,想盯整对象就开 deep,但要注意 deep 的性能代价。本文把三种写法和取舍讲清,照着选就能让回调按预期触发。
这个坑的适用边界很清楚:它只发生在你用 Vue3 的 watch 监听「对象或数组内部变化」时。如果监听的是基础类型(数字、字符串),改值就会触发,谈不上踩坑。下面把最小复现、根因、三种写法和常见失败一次讲清。

一、最小复现:watch 对象改属性不触发
最小场景:你写了 watch(state, cb),随后在代码里改 state.count = 5,预期行为是回调被调用、控制台打印新值,结果什么都没发生。这种「静默不触发」最坑,因为控制台不报错,你很难第一时间定位。
复现代码:
<script setup>
import { reactive, watch } from 'vue'
const state = reactive({ count: 0 })
watch(state, () => console.log('changed'))
state.count = 5 // 回调没触发
</script>
运行后检查控制台,预期结果(未在本机执行)是没有任何打印。验证方法:把 watch 第二个参数的第一参数改成 () => state.count(getter 写法),再改 count,确认这次打印了。想先把响应式基础补牢,可以跟一遍 Vue3 实战课程。
二、根因:响应式代理与引用
根因在于 Vue3 的响应式是基于 Proxy 的:当你 watch 一个 reactive 对象本身时,它比较的是「这个对象的引用」,而改属性并不改变对象引用,所以默认不触发。对比基础类型,基础类型的值变化本身就能被感知,因此一定触发。
理解这点很关键:不是 watch 坏了,而是你监听的粒度和数据变化的粒度对不上。要监听对象内部变化,要么显式告诉 Vue「我要深度监听」(deep),要么把监听目标缩小到那个具体的属性(getter)。Vue.js 教程 里响应式原理的章节对此有更完整的说明。
三、写法一:deep 整体监听(及性能取舍)
方法 A 适合「对象里多个字段都可能变、且你都关心」的场景,直接给 watch 加 deep:
watch(state, () => console.log('changed'), { deep: true })
state.count = 5 // 这次会触发
这种写法最省心,但代价是 deep 会递归遍历对象的所有嵌套属性,对象很大时每次变更都要做深层比对,有性能开销。三者的取舍核心是:对象小、字段多就开 deep;对象大、只关心个别字段就别开。
顺带一提,watch 的第三个参数还能配 flush:默认 'pre' 在组件更新前触发,改成 'post' 则在 DOM 更新后触发。如果你在回调里要读更新后的 DOM,用这个选项比手动加 nextTick 更直接,也能避免因为读取时机不对而拿到旧节点。
四、写法二:getter 精确监听某个属性
方法 B 适合「只关心某一个属性」的场景,用 getter 把监听目标缩小到具体值:
watch(() => state.count, (v) => console.log('count ->', v))
state.count = 5 // 精确触发
这种写法没有 deep 的性能负担,而且语义最清晰:你明确告诉 Vue 只在 count 变时才触发。失败点在于用箭头函数时忘了写 getter,写成 watch(() => state, cb) 等于又回到了监听引用,同样不触发。关于 JS 函数与响应式的关系,Deep Learn Javascript 里有对应的基础章节可对照。
五、写法三:先克隆再替换与失败分支
方法 C 适合「希望通过替换整个对象来触发」的场景,用展开运算或 Object.assign 生成新引用:
watch(state, () => console.log('changed'))
state = Object.assign({}, state, { count: 5 }) // 仅当 state 是 ref 才生效
失败分支一:state 是 reactive 而不是 ref,上面这种重新赋值是无效的,因为 reactive 返回的不是可替换的引用。正确做法:用 ref 包裹对象,或改用 deep / getter。失败分支二:开了 deep 却仍不触发,大概率是监听的是一个被解构后「失去响应式」的普通变量。正确做法:始终从响应式源对象上取值,不要把属性解构成局部变量再监听。失败分支三:在 setup 顶层用箭头函数却没包成 getter,回调永远不跑。正确做法:牢记「监听谁、就返回谁」。
再补一个实战建议:调试这类不触发问题时,最快的办法是在 watch 回调里加一行日志,然后在浏览器控制台直接改对象属性,看日志是否出现。如果 deep 开了仍不打印,八成是数据源本身失去了响应式——比如从接口拿到 JSON 后直接解构赋值给了普通变量。检查办法是把数据源换回 reactive/ref 包裹的对象再试;预期是重新包裹后回调恢复触发,这也说明问题出在响应式链路上,而不是 watch 的写法。确认响应式链路没问题后,再回头逐行检查 watch 的监听源写法。

总结
- watch 对象改属性不触发,根因是监听了对象引用而没感知内部变化。
- 三种写法:deep 整体监听、getter 精确监听、替换新引用。
- 对象小、字段多开 deep;只关心个别字段用 getter 最省性能。
- 箭头函数别忘了写成 getter,别解构掉响应式。
常见问题
Q:Vue3 watch 对象不触发最常见的原因是什么?
最常见的是监听了 reactive 对象本身却没开 deep,或者用了箭头函数却没把目标包成 getter,导致 Vue 比较的是引用而非内部值。
Q:deep 监听有什么性能代价?
deep 会递归遍历对象的所有嵌套属性并在每次变更时做深层比对,对象越大开销越高。只关心个别字段时,用 getter 精确监听更划算。
Q:为什么把对象属性解构出来再监听会失效?
解构会得到普通值,脱离响应式代理,之后改原对象的属性不会再通知这个局部变量,所以监听不到。应始终从响应式源对象上取值。
Q:watch 和 watchEffect 该用哪个?
watch 需要显式指定监听源,控制更精确、能拿到新旧值,适合「明确知道要盯什么」的场景;watchEffect 自动收集依赖、首次立即执行,适合依赖关系简单、想少写代码的场景。对象监听这种需要精确控制粒度的,优先 watch。
Q:deep 监听配合 immediate 有什么坑?
immediate 会让回调在初始时立即执行一次,但此时如果对象还没初始化好,容易读到空值报错。建议在回调里先判空,或确认数据在 watch 注册前已就绪再开 immediate;另外 deep 加 immediate 组合在初始执行时也会做一次全量遍历,对象大时要注意首次渲染开销。

TRAE-AI编程



