Vue 2/3 watch 完全指南:用法、原理与避坑 上周维护一个内部后台系统遇到一个很典型的联动需求左侧筛选框选择月份右侧汇总卡片要立即重新拉数据。第一版我图省事用computed写了联动逻辑结果发现请求根本发不出去——computed不支持副作用也不是为“监听变化后做事”设计的。换上watch之后又陆续踩了oldVal引用、深度监听性能、组件卸载后异步回调等一堆坑。把 Vue2 和 Vue3 两代watch从头到尾捋了一遍之后我决定把这套完整的理解写下来。这篇东西适合两类人一是刚开始接触 Vue、只想把表单联动和异步请求跑通的新手二是用了watch很久但被“为什么监听对象没触发”“为什么 Vue3 里 deep 行为不一样”这类问题困扰的进阶开发者。我会把用法、原理、坑、版本差异一次讲透尽量不让你们再把我的踩坑路程重走一遍。1. 先搞清楚watch 到底该管什么事1.1 computed 撞墙的现场不是所有“响应”都能用计算属性很多人第一次接触watch都是因为在computed里写异步逻辑失败了。computed的核心是“派生数据”根据已有的响应式数据计算出一个新的值。它有返回值、有缓存依赖没变就不会重新计算。但计算属性是同步的也没有办法在数据变化时主动发起一次请求、写日志、重置表单、操作 DOM。你如果强行把this.$api.fetch()写进computed轻则没有效果重则触发无限循环——因为请求回调里一旦修改了响应式数据computed 的依赖又变了它又会重新执行。watch管的恰恰是另一件事我不关心你这个数据怎么被派生我只关心“它变了”之后要做什么动作。这个动作通常是副作用发请求、改其他数据、操作 DOM、埋点上报。所以判断使用场景的第一步就是看你要的是“新数据”还是“新动作”。1.2 watch 与 computed 的适用边界我自己在工作里习惯用一张表来跟同事对齐选型逻辑对比维度computedwatch定位由已有数据派生新数据监听数据变化执行副作用是否必须有返回值是一般不需要是否缓存是依赖不变不重算无缓存概念触发就执行是否支持异步不支持支持主要应用场景依赖收集方向自动收集内部响应式依赖显式指定监听源典型场景购物车总价、过滤列表搜索请求、表单联动、路由变化处理一个更直观的说法computed是“这张表格的总金额是多少”watch是“金额变成 2000 之后我要弹一个优惠提示”。如果你发现自己在一个watch里大量拼接字符串、做过滤、算总额那大概率应该改成computed因为那是在“派生新数据”而不是在“监听变化”。1.3 watch 实际负责的三类事情综合来看watch在实际项目里承担的任务基本就三类异步副作用搜索关键词变化后防抖请求、筛选条件变化后拉取新列表、选城市后请求天气数据。数据联动与重置A 改变后重置 B 的默认值、表单联动禁用、多级依赖的状态同步。初始化回填监听某个 prop 变化后回填本地状态配合immediate: true在组件创建时就先执行一次。记住这三类你的watch使用就不会跑偏。接着我们按 Vue2、Vue3 两条线把语法拆开看。2. Vue2 的 watch 全拆解三种写法、immediate、deep 与 $watch2.1 三种写法字符串、函数、对象Vue2 的watch写在options里有三种常见形式功能逐步增强export default { data() { return { keyword: , user: { profile: { age: 18 } }, form: { city: , weatherMap: {} }, }; }, watch: { // 第一种函数写法监听 data 中的属性 keyword(newVal, oldVal) { // 每次 keyword 变化这里都会执行 console.log(keyword 从, oldVal, 变成, newVal); }, // 第二种字符串路径可以监听对象嵌套属性 user.profile.age: function newVal(oldVal, newVal) { // 注意 Vue2 中这里的顺序和函数写法一致只是路径是字符串 }, // 第三种对象写法支持配置项 form: { handler(newVal, oldVal) { // 真正要执行的回调 }, deep: true, immediate: true, }, }, };第三种对象写法里的handler才是真正要执行的回调deep和immediate是配置项。为什么需要对象写法因为函数写法只能表达“监听什么”不能表达“怎么监听”。immediate、deep这种额外行为必须要用对象形式装配进去。这里还有一个容易忽略的点Vue2 的 watch 回调参数顺序是 (newVal, oldVal)。很多从其他框架转过来的开发者会写成(oldVal, newVal)导致拿到的值完全反掉。我排查过同事的 bug发现就是这个顺序问题。2.2 immediate 和 deep 的底层行为immediate: true的意思是在组件实例初始化阶段立即执行一次回调。源码层面相当于在创建 watcher 时手动调用一次handler()而且这一次触发时oldVal是undefinednewVal是当前值。它的典型用途是“页面进入时先初始化一次数据”。比如一个详情页从路由接收id参数你需要根据id拉详情同时id变化时也要重新拉。没有immediate你只能在created里写一遍、在watch里再写一遍代码冗余且容易漏。用immediate: true可以合并成一份。deep: true则是深层监听。默认情况下watch监听到的只是对象引用本身的变化对象内部某个字段改了引用没变回调就不会触发。deep开启后Vue2 会递归遍历对象的所有层级把每一层的 getter 都读一遍收集到内部所有依赖。代价立在眼前对象越深、字段越多依赖收集的开销越大。我在后面的避坑章节会专门讲这个性能问题。还有一个高频坑监听对象且deep: true时回调里拿到的oldVal和newVal往往指向同一个引用因为 Vue2 的 watch 并不会帮你做深拷贝。你想对比“变化前和变化后的对象”要自己存快照比如watch: { form: { deep: true, handler(newVal, oldVal) { // 这里 oldVal 大概率 newVal都是同一个对象引用 // 如果需要旧值快照得提前手动拷贝 }, }, },2.3 监听路径、数组特殊行为与 Vue.set 补丁Vue2 支持用字符串路径监听嵌套属性比如user.profile.age。这种方式的好处是不需要deep: true因为 watcher 只会精确收集age这个字段的依赖开销小得多。如果你只需要监听某一个深层字段优先用路径写法而不是对整个对象开启deep。数组的情况要特别留意。Vue2 的响应式系统是通过Object.defineProperty改写数组的 7 个变更方法push、pop、shift、unshift、splice、sort、reverse来实现的。这意味着直接list[0] newwatch 不会触发直接修改list.length 0watch 不会触发用list.push(new)或者list.splice(0, 1)watch 才会触发如果监听的是整个数组且需要深度监听内部元素变化必须deep: true。给对象新增属性也一样Vue2 里直接obj.newField xxx不会成为响应式watch 自然感知不到。正确做法是this.$set(obj, newField, xxx)或者一开始就把字段声明出来。这个问题在 Vue3 里被 Proxy 彻底解决后面会讲。2.4 this.$watch、unwatch 与组件销毁清理除了在options.watch里声明Vue2 还提供了实例方法this.$watch。它的使用场景主要是在created或某个事件回调里按条件动态注册监听注册时同样可以传deep、immediate。调用后会返回一个unwatch函数调用它就能手动解除监听created() { this.formWatch this.$watch( form.city, (newVal, oldVal) { this.loadWeather(newVal); }, { immediate: true } ); }, // 某些条件下要移除监听 beforeDestroy() { if (this.formWatch) { this.formWatch(); } },一个常见的观点是“watch 不用手动清理组件销毁会自己处理”。放在组件场景下确实如此Vue2 在beforeDestroy时会统一遍历实例上的 watcher 并销毁。但如果你在组件外、仓库层或者某个长期存活的模块里用$watch监听一个组件实例的数据那必须手动unwatch()否则监听器会一直存活造成隐式泄漏。3. Vue3 的 watchref、reactive 和 watchEffect 的正确姿势3.1 三种 source 类型ref、getter、reactive 的监听差异Vue3 的watch在组合式 API 中接收的第一个参数叫 source监听源可以是 ref、响应式对象、getter 函数或者把它们组合成数组。写法更灵活但也更容易混乱。import { ref, reactive, watch } from vue; setup() { const keyword ref(); // 基础类型用 ref const form reactive({ // 复杂对象用 reactive city: , weather: , }); // 类型一直接监听 ref。回调里的 newVal 会被自动解包成值 watch(keyword, async (newVal, oldVal) { const list await search(newVal); state.list list; }); // 类型二监听 getter 返回的值适合只关心对象某个字段 watch( () form.city, (newVal, oldVal) { loadWeather(newVal); } ); // 类型三直接监听 reactive 对象本身。注意默认是隐式深监听 watch(form, (newVal, oldVal) { // form 内部任何字段变化都会触发 }); // 类型四同时监听多个数据源回调参数会变成数组 watch([keyword, () form.city], ([newKey, newCity], [oldKey, oldCity]) { console.log(关键词, newKey, 城市, newCity); }); // 类型五监听 getter 返回一个对象默认深监听是 false watch( () ({ city: form.city, keyword: keyword.value }), (newObj, oldObj) { // 但如果 getter 返回新对象每次依赖变化都会得到新的引用 // 所以这里 newObj 和 oldObj 是不同引用 } ); }这里最容易踩的坑是直接监听 reactive 对象时deep默认是 true这是 Vue3 和 Vue2 的显式差异。Vue2 里所有监听默认deep: falseVue3 里如果 source 本身就是一个reactive对象内部字段的任意变化都会触发回调。而如果你用 getter 返回对象内部某个字段那就是精确监听不会深递归。反过来如果 getter 返回的是一个普通对象每次执行都新建对象比如() ({ a: form.a })那么只要form.a变化getter 执行返回的就是新对象引用watch必然触发。这在 Vue2 里是不会自动等价的Vue3 对 getter 返回值的旧值对比逻辑做了处理但对于对象类型新旧引用不同就意味着变化。3.2 watchEffect初始执行 自动收集依赖watchEffect是 Vue3 新增的 API和watch的核心区别在于watch默认是 lazy 的只有监听源变化才执行watchEffect在初始化时会立即执行一次并且在执行过程中用到的所有响应式数据都会自动成为监听源不需要你手写依赖列表。我经常用watchEffect的场景是多个响应式数据组合在一起共同决定一次请求参数。比如地图页面的中心点坐标和缩放级别任何一个变化都要重新请求地图数据用watch要手写 getter 数组用watchEffect则只要在函数体里读取相关数据框架自动收集import { ref, watchEffect } from vue; setup() { const center ref([120.1, 30.2]); const zoom ref(8); const stop watchEffect(async (onCleanup) { const [lng, lat] center.value; const level zoom.value; const controller new AbortController(); // 依赖变化或组件卸载时执行清理 onCleanup(() { controller.abort(); }); const data await fetchMapData({ center: [lng, lat], level }, { signal: controller.signal, }); state.mapData data; }); onBeforeUnmount(() { stop(); }); return { center, zoom, stop }; }watchEffect的回调函数带一个onCleanup参数这个清理函数会在“下一次执行之前”和“停止监听时”调用。使用场景非常精准清除定时器、取消未完成的请求、移除临时事件监听。这一能力比 Vue2 里手写清理逻辑优雅得多。3.3 flush 时机、手动停止与全局监听Vue3 的watch和watchEffect都支持第三个参数options其中最实用的一个是flush。默认值是pre表示在组件更新之前执行回调post表示在 DOM 更新之后再执行sync表示同步立刻执行。watch(source, callback, { flush: post, });如果你的watch回调里需要读取更新后的 DOM 尺寸或位置pre模式拿到的还是旧布局。此时要么在回调里用nextTick要么直接设成flush: post。sync则要谨慎使用它打破了批量更新机制数据一变回调立刻执行可能会有性能损耗也可能在渲染流程中插入不期望的同步操作。停止监听方面watch和watchEffect都会返回一个stop函数。在setup中创建的监听组件卸载时框架会自动停止但如果你在一个异步函数、定时器或工具函数中创建了watch它不会自动跟随组件生命周期必须手动stop()。这是 Vue3 里最容易造成监听泄漏的场景之一。4. 踩坑实录watch 失效、误触发和泄漏的真实案例4.1 Vue2 新增属性失踪Vue3 自动修复我之前维护一个模拟项目 A订单详情对象初始只有id和status后端返回完整数据后前端联调发现watch监听整个订单对象接口返回后页面状态没变化。排查后发现是前端在初始化后给订单对象新增了remark字段而 Vue2 的响应式系统只能监听初始化时已存在的字段新增字段既不是响应式的也不会触发 watcher。解决办法有两个一是初始化时就声明好所有可能出现的字段哪怕给空值二是用this.$set(order, remark, value)把新字段加入响应式系统。这个问题在 Vue3 中因 Proxy 机制被彻底修复新增属性天然响应式watcher 也能感知。类似的还有数组索引修改Vue2 里list[0] x不触发Vue3 里Proxy支持索引拦截所以会触发。4.2 深度监听大对象一次点击卡顿的定位过程另一个模拟项目 B 是一个表单配置页一个页面里放了十几个输入框所有数据集中在form对象里。功能正常但用户反馈输入框打字开始卡顿尤其当表单数据量大时每次按键都要掉帧。我定位后发现代码里写的是watch: { form: { deep: true, handler: ... } }。每次输入框变化deep: true都会对这个可能包含数千个字段的表单对象做一次递归遍历收集所有嵌套依赖。字段越多、层级越深开销越大。优化后的做法是把deep拆掉改成监听具体字段或者用计算属性做收窄computed: { formSnapshot() { return JSON.stringify(this.form); }, }, watch: { formSnapshot() { // 任何字段变化都会在字符串层面体现 // 但这里也有性能问题JSON.stringify 本身也是全量遍历 // 推荐还是精确监听具体字段 }, },我的建议是能监听字段就不要监听对象能监听 getter就不要 deep 监听整个对象。如果确实需要整表联动也要评估对象大小必要时拆分表单组件缩小监听范围。4.3 异步竞态旧请求覆盖新数据的处理办法筛选器切换非常容易触发竞态。一个典型的翻车现场是用户快速切换了三次城市条件前端发出三个请求网络波动导致后发先至最终页面上展示的是第一次请求的旧数据。watch本身不提供任何取消机制需要在回调里自己处理。我常用的方式是在回调闭包里维护一个请求序号或者用AbortControllerwatch(city, async (newCity) { const requestId fetchId.current; const res await fetchWeather(newCity); if (requestId ! fetchId.current) { // 说明已经有更新的请求发出丢弃本次结果 return; } weather.value res.data; });AbortController的好处是能真正取消网络请求节省带宽请求序号方案则更通用不限制场景。注意watchEffect自带的onCleanup也能承担类似职责前面已经写过例子。4.4 组件卸载后的更新和 watch 回调里改数据的死循环异步回调在组件卸载后才返回这件事在 Vue2 和 Vue3 里都存在。Vue2 会在控制台提示Cannot call setState on an unmounted component之类的警告Vue3 中虽然没有那么直接但如果你在回调整修改响应式数据实际上是在操作已经卸载的组件实例属于资源浪费甚至可能引发内存泄漏。我的统一做法是在onBeforeUnmount或beforeDestroy里设置一个isDestroyed标志位所有异步回调返回后先检查let isDestroyed false; onBeforeUnmount(() { isDestroyed true; }); watch(city, async () { const data await fetchWeather(city.value); if (isDestroyed) return; weather.value data; });另外要注意不要在watch回调里直接修改被监听的数据尤其是赋值新对象。比如监听一个对象回调里对这个对象整体赋值一个新对象就会再次触发 watch形成死循环。如果修改后的值和当前值不同且引用每次都是新的那么无限循环是必然结果。修正思路是把“状态变化”和“动作执行”解耦watch 只负责触发真正要变更的数据用另一个独立的响应式字段承载。4.5 多个 watch 的执行顺序与批量更新同一个 tick 内多次修改响应式数据watch回调不会每次都立即触发Vue 会把 watcher 放进调度队列批量执行。Vue2 中watcher 的执行顺序基本按创建顺序父组件的 watcher 会先于子组件Vue3 中默认flush: pre也是在当前组件的渲染之前统一执行。如果你的多个 watch 之间存在依赖关系比如 A 的 watch 回调里修改了 BB 的 watch 又修改 C最终执行顺序会受入队顺序影响。最好别让 watch 之间形成“链式修改”这种隐式数据流很难一眼看懂。更直观的做法是在一个 watch 回调里显式处理多个关联状态的联动。5. 原理级理解watch 的依赖收集、调度与触发链路5.1 Vue2 的 Watcher、dep 与调度队列watch在 Vue2 中的实现可以抽象为三步创建 watcher 时读取被监听的数据触发 getter把当前 watcher 收集进对应的 dep数据变化时触发 setterdep 通知所有 watcherwatcher 进入调度队列按顺序批量执行。看一段简化伪代码会清楚很多// 简化版 Vue2 Watcher class Watcher { constructor(vm, expOrFn, cb, options) { this.vm vm; this.cb cb; this.getter parsePath(expOrFn); this.value this.get(); // 初始化时读取一次完成依赖收集 } get() { Dep.target this; // 把当前 watcher 挂到全局 const value this.getter.call(this.vm, this.vm); Dep.target null; return value; } update() { queueWatcher(this); // 不直接执行入队等待批量处理 } } function queueWatcher(watcher) { // 去重、排序、放入微任务队列 nextTick(flushSchedulerQueue); }dep是依赖容器每个响应式属性都会有一个 dep。getter 里做了dep.depend()把当前Dep.target也就是 watcher装进去setter 里做dep.notify()把所有相关 watcher 的update推入调度队列。之所以不在每次数据变化时同步执行回调是为了避免“一次操作连续修改多个数据导致回调被调用多次”的浪费。Vue 的调度队列会去重同一个 watcher 在一个 tick 内只会执行一次。5.2 Vue3 的 effect、scheduler 与 watch 回调触发Vue3 把响应式核心重构成了effect机制。watch本质上就是创建一个特殊的 effect并给它绑定一个scheduler。当依赖变化时scheduler 被调用scheduler 里干两件事计算新的newValue然后调用用户的回调。简化伪代码大致长这样function watch(source, cb) { let getter isRef(source) ? () source.value : () source; if (isReactive(source)) { // reactive 对象默认 deep 遍历 getter () traverse(source); } let oldValue; const effect new ReactiveEffect(getter, () { const newValue effect.run(); cb(newValue, oldValue); oldValue newValue; }); oldValue effect.run(); // 初始化执行一次完成依赖收集 return () effect.stop(); }注意effect.run()实际上就是执行 getter读取响应式数据并收集依赖。对于一个watch(() form.city)getter 只读取form.city所以只收集 city 的依赖city 一变effect 的 scheduler 就会触发。watchEffect更简单它相当于一个没有 cb 的 watch执行 effect.run() 过程中读取到的所有响应式数据都会被收集为依赖。5.3 traverse 深度遍历和 onCleanup 的触发时机Vue3 中watch(reactiveObj)默认隐式 deep是因为框架对 reactive 对象做了traverse深度遍历递归读取对象的所有 key把每一层属性都收集为依赖。这就是“监听整个 reactive 对象”的开销来源。你可以自己想象一个金字塔顶层对象只有几个字段每个字段下面还有几十个字段再往下还有数组一次遍历的读操作可能上千次。onCleanup的执行时机在 Vue3 源码里是和effect.stop的解绑逻辑绑定在一起的每次 effect 重新运行之前会先执行上一次注册的 cleanup组件卸载导致 stop 时也会执行最后一次 cleanup。因此它天然适合做清除定时器、取消请求、移除事件监听这类“副作用清理”工作。从原理层面看「新增属性在 Vue2 中为何无法被 watch 感知」也一目了然Vue2 用Object.defineProperty在初始化时给已有属性绑定 getter/setter新属性没有经过这一步自然没有 depVue3 用Proxy拦截了整个对象的get/set操作新增属性也会被拦截到依赖收集和派发更新自然能覆盖。6. Vue2 与 Vue3 watch 选型速查表与个人使用习惯6.1 版本差异对照表对比项Vue2Vue3使用位置options.watchoptions.watch 或 setup 中的 watch/watchEffectimmediate支持支持watchEffect 天然自带监听 reactive 对象时 deep 默认值falsetrue监听数组索引变化不触发触发对象新增属性需要 $set自动响应式同时监听多个数据源写多个 watch 键传入数组回调参数为数组回调内部旧值对象类型可能和 newVal 同一引用对象类型同样存在引用问题停止监听$watch 返回 unwatchwatch/watchEffect 返回 stop自动随组件销毁组件销毁时统一处理setup 内创建时自动 stopflush 控制无内置配置pre / post / sync清理副作用手动实现watchEffect 的 onCleanup6.2 我沉淀下来的 watch 使用原则第一能用 computed 解决的问题绝不 watch。凡是“从状态 A 派生出状态 B”的都是 computed 的活只有“状态变化后要去执行动作”才用 watch。第二监听范围越窄越好。优先监听具体字段或 getter不要无脑deep: true也不要监听巨大对象。第三所有异步 watch 都要考虑过期请求和卸载保护。要么用请求序号要么用 AbortController要么用 watchEffect 的 onCleanup至少做其中一个。第四watch 回调里别改被监听的数据本身这是死循环的高发区尽量通过独立的本地状态承载修改结果。6.3 一个小技巧用 watch 做历史记录堆栈如果你在做编辑器或者表单类系统可以用 watch 配合数组实现简单的操作历史记录每次需要记录的状态变化把旧值 push 进 stack新值入栈撤销时再把栈顶弹出赋回给状态。由于 watch 天然能拿到oldVal这个场景比手动管理简单得多。const historyStack ref([]); watch( () form.content, (newVal, oldVal) { historyStack.value.push({ before: oldVal, after: newVal, }); // 可以做撤销/重做逻辑 } );这里注意oldVal同样存在引用问题如果监听的是对象需要先深拷贝再存历史。但作为快速实现一个“撤销栈”这个思路很顺手。最后再说一点实在的。我在多个项目里反复被 watch 坑过但从来不觉得是这个 API 设计得不好。它的灵活性决定了它非常考验人的“克制”——过度使用 watch 会把数据流搅成一团乱麻精准使用 watch配合 computed 和事件机制才真正把 Vue 的响应式能力发挥出来。这篇解析是我日常调试时的完整思路希望对你排查问题或者做技术选型有所帮助。