接口数据回来你赋值给了 watch 监听的对象,回调函数却一次都没触发;或者你改了对象里某个深层字段,watch 依然静悄悄——这大概是最多人被 Vue2 的 watch 坑过的地方。一句话结论:watch 的回调只在“监听目标产生了新值”时触发;对基本类型是新值不等于旧值,对对象/数组 Vue2 默认做浅层比较,引用没变就认为没变。而 immediate 管“要不要一上来先跑一次”,deep 管“要不要递归比较每一层”。本文把这三者的分工和代价讲清,点出数组索引赋值不触发、大对象开 deep 性能炸这两个坑,并提醒你 Vue3 的响应式已经换了思路,照着配监听就能精准生效。

一、watch 到底什么时候触发
watch 的回调只在“监听目标的赋值产生了新值”时触发。对于基本类型,新值不等于旧值就触发;对于对象/数组,Vue2 默认做浅层比较——如果引用没变(你只是改了里面的某个属性),它认为“没变”,不触发。基础语法可以先看 Vue.js 教程,把响应式的基本概念补齐,再看本文的触发细节会更顺。
new Vue({
data() { return { count: 0, user: { name: "a" } } },
watch: {
count(newVal, oldVal) { console.log(newVal, oldVal) }
}
})
// this.count = 1 触发;this.count = 1 再赋 1(同值)不触发
它的适用边界很清楚:watch 适合“值变了要做什么副作用”(发请求、存本地),不适合纯粹的计算派生——那该用 computed。把两者的职责搞混,是新手写出一堆不必要 watch 的常见原因。
一个直观的对比能帮助理解:基本类型(数字、字符串、布尔)的“值”就是它本身,赋新值即新值,watch 立刻感知;而对象/数组的“值”在 Vue2 里默认只看引用——你改了 user.name,引用没变,Vue2 就认为“没变”。这不是 bug,是 Vue2 为了性能做的浅层监听取舍。适用边界很清楚:你想要“内部结构变化也触发”,就必须显式告诉它 deep: true 或监听具体字段。
举个反例:很多人在 data 里放一个空对象 {},运行时才往里加属性,结果发现 watch 不触发、视图也不更新——因为 Vue2 无法对“运行时新增的属性”做响应式。正确做法是初始化时就声明好所有字段,或用 Vue.set 添加。这是 Vue2 响应式最经典的坑,记住“声明在前、新增用 set”。
二、immediate:一进来先跑一次
如果你期望“页面一进来就先跑一次逻辑”,单纯写 watch 是不够的,必须加 immediate: true。Vue2 的完整响应式说明在 Vue2 教程 里有,建议搭配阅读,里面从依赖收集的角度解释了“为什么引用没变就不触发”。
watch: {
count: {
handler(newVal) { console.log("当前值", newVal) },
immediate: true // 组件创建时先执行一次 handler
}
}
这里有个对比:不加 immediate,handler 要等 count 第一次变化才跑;加了,组件初始化时就先跑一次。所以当你需要“用初始值立刻渲染/请求”时,immediate 是刚需,而不是可选项。
immediate 的边界在于:它只保证“创建时跑一次”,不保证“数据变了之后还跑”。如果你的逻辑是“数据变化才做某件事”,加 immediate 只是让首屏也有一次,并不能替代“变化触发”。两者是正交的两个开关,常被混为一谈。
实际项目里,immediate 常配合“先拉一次初始数据、之后随筛选条件变化再拉”的模式:用 immediate 触发首次请求,后续靠 watch 响应条件变化。这样首屏和交互复用同一段 handler,代码不重复。排错时如果发现“首屏没数据”,先确认是不是忘了 immediate——这是比“watch 没触发”更隐蔽的常见遗漏。
三、deep:递归监听每一层
如果监听的是对象,且你要响应“对象内部某个字段”的变化,就得开 deep: true,否则只比较引用、内部改动不会触发。相关生态可看 Deep Learn JS,它把 JavaScript 对象与引用语义讲得更透,理解了引用才能理解“为什么引用没变就不触发”。
watch: {
user: {
handler(newVal) { console.log("user 变了", newVal) },
deep: true // 递归比较 user 的每一层属性
}
}
代价上,deep 会递归遍历对象,对象大、层级深时性能和内存开销明显,不要无脑给所有 watch 开 deep。只监听具体字段(如 user.name)通常比 deep 整个对象更高效。
deep 的代价值得专门说:它会对对象做深度遍历,对象越大、层级越深,初始化和每次变化的开销越高。一个含上千条数据的列表对象开了 deep,每次任何一条变都全量比对,页面会肉眼可见地卡。替代方案永远是“监听具体字段”——"list.0.name" 只比这一处,开销小几个数量级。
还有一个排错经验:数组用 splice/Vue.set 改元素能触发,但用 arr.length = 0 清空再 push 有时不灵,因为引用和长度变化的组合会绕开监听。稳妥起见,清空用 splice(0) 而非直接改 length。这些边角行为记住几个就好,不必背全部,关键是“改数组走变异方法或 set”。
四、数组索引与浅层比较的坑
第一个失败点:监听对象却没开 deep,改了 user.name 不触发——因为引用没变。解决要么开 deep,要么直接监听 "user.name" 字符串路径(Vue2 支持点路径监听)。关于“AI 时代怎么选开发助手、把这类排查交给工具还是自己定位”,也可以参考 AI 编程助手,思路上和“先搞清机制再上工具”是一致的。
第二个坑是数组:用索引赋值 arr[0] = x 在 Vue2 里不被监听,得用 arr.splice(0, 1, x) 或 Vue.set。这属于排错经验:遇到“明明改了数组 watch 却不触发”,先怀疑是不是用了索引直接赋值,而不是去调 deep。掌握这两个坑,绝大多数 watch 失灵都能在五分钟内定位。

五、验证清单(前置·操作·验证·失败处理)
前置:确认你监听的是基本类型、对象还是数组,这决定要不要 deep 或点路径。配置:按“需要初始即执行就加 immediate: true;需要响应对象内部变化就加 deep: true 或直接监听点路径;数组变化用 splice/Vue.set”的顺序写。运行:在浏览器打开页面,修改被监听的值,检查 handler 是否按预期触发。预期:正常情况基本类型变化、对象开 deep 后内部变化都会触发;加 immediate 则创建即触发一次。失败处理:若对象内部变化不触发,补 deep 或改监听 "a.b";若数组索引赋值不触发,换成 splice/Vue.set。修复:把缺的配置补上即可。验证:逐一改值确认 handler 触发次数与时机符合预期,监听即精准生效。
总结
watch 只在“被监听目标产生新值”时触发:基本类型比的是值,对象/数组 Vue2 默认浅层比引用,引用不变就不触发。immediate 控制“创建时先跑一次”,deep 控制“递归比较每一层”,两者代价不同,按需开启。两个高频坑是对象内部改动没开 deep、以及数组用索引直接赋值不被监听(改用 splice/Vue.set)。注意 Vue3 响应式已改为 Proxy,机制不同,不要照搬本文的 Vue2 写法。
补充一句给迁移场景:若你之后升级到 Vue3,响应式改成了基于 Proxy 的深层代理,本文里“引用没变就不触发”“数组索引不监听”等问题大部分自动消失,写法可以更自然;但 immediate/deep 的语义在 Vue3 的 watch 里依然保留,概念可以复用。所以把“immediate 管首跑、deep 管深度”这两句记牢,跨版本都受用。
常见问题
Q:为什么改了对象的属性 watch 没反应?
因为 Vue2 默认浅层比较引用,你只是改了对象内部属性、引用没变,所以不触发。要么给 watch 加 deep: true,要么直接监听点路径如 "user.name"。
Q:deep 可以无脑给所有 watch 都开吗?
不建议。deep 会递归遍历对象每一层,对象大、层级深时性能和内存开销明显。能监听具体字段(user.name)就别 deep 整个对象。
Q:数组赋值了为什么 watch 不触发?
Vue2 对 arr[0] = x 这种索引直接赋值无法监听。改用 arr.splice(0, 1, x) 或 Vue.set(arr, 0, x),watch 才能感知变化。

TRAE-AI编程



