资讯详情 Vue watch 与 watchEffect 实战指南:从响应式原理到性能优化
📅 2026/10/9 21:29:40
1. 从“数据变了但页面没变”说起如果你写过一阵子 Vue大概率遇到过这种场景明明接口返回了新数据console 里也打印出来了但页面上的数字就是纹丝不动。你刷新页面它好了再切换一下条件又不对了。这种问题十有八九不是 Vue 本身坏了而是你没用好watch——监视器。watch是 Vue 响应式系统里最朴素的工具它的核心用途只有一句话当某个数据源发生变化时执行你想要的副作用。所谓“副作用”包括重新请求接口、更新另一个变量、操作 DOM、写 localStorage、触发日志上报等等。它和computed的边界要分清楚computed是根据已有数据推导出新数据讲究“纯函数、无副作用、有缓存”watch则是“数据变了我要去做点别的事”不追求返回值更多时候是把“变化”当作触发信号。这篇文章不打算从源码讲起而是按“写业务到底要看哪些 API、遇到哪些坑、怎么排查”这条线把watch的前世今生讲明白。适合刚学完 Vue 基础语法、开始写真实项目的同学也适合用了一两年 Vue 但遇到“监听不触发”“监听对象失效”这类问题没想透的人。2. 先搞懂 watch 的四种写法watch的写法在 Vue 2 和 Vue 3 里略有差异但只要理解它的本质换个框架版本也就是换个壳。先看 Vue 3 组合式 API 下的常见写法因为这是目前新项目的主流。2.1 监听一个 ref 或 reactive 属性import { ref, watch } from vue const keyword ref() watch(keyword, (newVal, oldVal) { console.log(搜索词变了, newVal, oldVal) })这是最基础的用法第一个参数是数据源第二个参数是回调。回调里能拿到新值和旧值。注意如果你监听的是ref对象回调里拿到的newVal是ref.value解包后的值不需要再手动.value一次。如果你监听的是reactive对象里的某个属性写法稍有区别import { reactive, watch } from vue const state reactive({ page: 1, size: 20 }) watch( () state.page, (newVal, oldVal) { console.log(页码变化, newVal, oldVal) } )这里用了一个箭头函数() state.page这是 Vue 3 里监听“reactive 对象内部某个属性”的标准姿势。很多人刚入门时会直接写watch(state.page, ...)结果发现永远不触发就是因为传进去的是一个“快照值”而不是一个“响应式数据源引用”。2.2 监听多个数据源业务里最常见的是“筛选条件变化后重新请求列表”这时候往往要同时盯好几个条件。watch的第一个参数支持数组watch( [() state.page, () state.size, keyword], ([newPage, newSize, newKeyword], [oldPage, oldSize, oldKeyword]) { console.log(筛选条件变化, newPage, newSize, newKeyword) } )注意新旧值此时也是数组结构一一对应。假如回调里只关心“有没有变化”不关心具体是哪个变了还可以配合flush: post做节流优化后面会讲。2.3 监听整个 reactive 对象直接用watch(state, callback)可以监听整个响应式对象的任何属性变化。但要记住两点第一newVal和oldVal在这里其实是同一个对象引用因为 reactive 对象本身没有“替换”概念只有内部属性变更所以别指望用oldVal做“变化前快照对比”第二这种写法默认是深度的性能开销比监听单个属性大能精确到属性就尽量精确到属性。2.4 使用 watchEffect 的场景watchEffect是watch的“自动收集依赖”版本不需要手动指定监听谁它会在函数体执行过程中自动把用到的响应式数据全部视为依赖import { watchEffect, ref } from vue const userId ref(1) const userInfo ref(null) watchEffect(async () { const res await fetch(/api/user/${userId.value}) userInfo.value await res.json() })这里userId一变化函数就会重新执行。它和watch最核心的区别是watchEffect初始化时会立即执行一次而watch默认不会立即执行。所以在“初始化就要拉数据后续变化也要拉数据”的场景里watchEffect往往更省事。3. 绕不开的三个配置项deep、immediate、flush光会写基础语法还不够watch的很多“诡异行为”都出在配置项上。我把这三个配置放在一起讲是因为它们几乎承包了 80% 的 watch 坑位。3.1 deep深度监听的代价reactive对象默认深层响应但watch监听ref或函数返回值时默认只做“浅比较”。看这个例子const form ref({ name: , address: { city: } }) watch(form, (newVal) { console.log(触发了) })如果直接修改form.value.name这个回调不会触发因为ref替换的是整个引用而你改的是内部属性。解决办法是加上deep: truewatch(form, (newVal) { console.log(触发了) }, { deep: true })一旦加了deep: trueVue 内部就要递归遍历这个对象的所有属性为每一个嵌套属性建立依赖追踪。对象层级越深、数据量越大开销越明显。一个包含几百条列表数据的对象每次变更都重新遍历一遍在低端设备上能明显感觉到卡顿。我的建议是能不用 deep 就不用。如果确实需要监听某个嵌套字段优先用“监听具体路径”的方式watch( () form.value.address.city, (newVal) { console.log(城市变了, newVal) } )这样既不用深度遍历又能精准触发性能上是最优解。3.2 immediate初始化要不要执行watch默认只在“数据变化之后”触发初始化时不会执行。但很多业务场景需要“刚进页面就加载一次之后每次都重新加载”这时候加个immediate: true就行watch( () state.page, fetchList, { immediate: true } )加了之后组件创建时会立即执行一次回调。要注意的是此时oldVal是undefined你在回调里如果有类似“把旧值和新值做对比再判断”的逻辑需要先处理oldVal undefined的情况。3.3 flush回调时机的选择这个配置在面试和实战里都容易忽略。flush控制的是“回调在什么阶段执行”有三个可选值pre、post、sync。pre默认在组件更新前执行。也就是说如果你在回调里修改了另一个响应式数据这个修改会跟当前的更新合并不会额外触发一次渲染。post在组件更新后执行。此时可以访问更新后的 DOM适合“数据变了我要操作一下 DOM”的场景。sync一旦数据变化立即同步执行和 Vue 2 里的某些行为类似一般不建议使用因为会破坏响应式系统的批处理优化。实际开发里flush: post最常见的用途是和 DOM 操作配合。比如一个列表高度根据数据变化而自适应你就需要在 DOM 更新完成后去量高度watch( () state.list, async () { await nextTick() // 此时 DOM 已更新可以安全操作 }, { flush: post } )其实flush: post内部已经帮你做了类似nextTick的排队写法上会更干净。4. 监听对象和数组的“重灾区”这部分几乎每个 Vue 开发者都会踩坑我单独拿出来讲。核心要记住一个原则watch触发与否取决于你“替换了引用”还是“修改了内部结构”。4.1 对象属性新增/删除在 Vue 2 里给对象新增一个属性响应式系统是感知不到的因为Object.defineProperty只能拦截已有属性。Vue 3 改用Proxy之后新增和删除属性都能被拦截到但如果你的项目还在维护 Vue 2 代码请记住新增属性要用的Vue.set或者this.$set否则 watch 怎么都不触发。Vue 3 里也并非高枕无忧。如果你监听的是“整个reactive对象”新增属性确实能触发如果你监听的是“对象内部某个属性”而那个属性初始不存在那么即使后来赋值了也可能因为依赖收集时没有建立追踪而失效。解决办法是把它放进watch的函数返回值里watch( () state.dynamicKey, (newVal) { console.log(动态字段变化, newVal) } )只要确实访问了state.dynamicKey就会建立依赖关系。4.2 数组的 push/splice 触发问题数组的监听相对直观push、splice、pop这些变异方法会触发响应式更新watch默认也能感知到。但有一类特殊情况要注意——用索引直接赋值const list ref([1, 2, 3]) list.value[0] 99在 Vue 2 里这种写法无法触发Vue 3 用Proxy后可以触发。如果你还在维护旧项目记住统一用splice或Vue.set去改数组不要用索引赋值。还有一个更隐蔽的问题如果你监听的是() list.value.length然后在回调里根据长度变化去操作 DOM那就必须小心“连续变化”带来的多次触发。比如先push了一条数据又立刻splice掉中间这个过程可能触发两次回调而第二次回调拿到的数据其实已经和最终状态不一致。这种情况下加一个简单的防抖或用watchEffect配合flush: post能有效规避。4.3 引用类型的新旧值对比前面已经说过监听整个对象时newVal和oldVal指向同一个引用。那如果业务里必须拿到“变化前的对象快照”怎么办我的土办法是在回调里手动深拷贝一份旧值留存。let prevForm JSON.parse(JSON.stringify(form.value)) watch( () form.value, (newVal) { console.log(改前, prevForm) console.log(改后, newVal) prevForm JSON.parse(JSON.stringify(form.value)) }, { deep: true } )这个方案能解决 90% 的需求但要注意如果对象里包含Date、Map、Set、函数等无法被 JSON 序列化的内容就会出错。更稳妥的做法是用lodash的cloneDeep或者干脆从业务上避免依赖oldVal——大多数场景你只需要关注“新值是什么”对旧值的依赖其实是设计上的坏味道。5. 实战中的几个典型场景理论知识讲完接下来说几个我在项目里真实遇到过的场景每个场景都附上解决思路和最终写法。5.1 搜索框防抖 请求竞态控制搜索框是watch的高频场景。如果一个一个字符触发请求后端压力大不说还会产生竞态——用户先输入“苹果”请求发出又改成“苹果手机”第二个请求发出。如果第一个请求返回得慢页面会被旧数据覆盖。我的做法是在watch回调里加防抖并用一个请求序号做竞态控制。import { ref, watch, onBeforeUnmount } from vue const keyword ref() let timer null let reqSeq 0 watch(keyword, () { clearTimeout(timer) timer setTimeout(async () { const seq reqSeq const res await fetch(/api/search?q${keyword.value}) if (seq reqSeq) { // 只有最后一次请求才更新数据 searchResult.value await res.json() } }, 300) }) onBeforeUnmount(() { clearTimeout(timer) })有人会问“为什么不用watch的immediate那样刚进页面也要请求一次。”对如果搜索页需要首屏默认列表就加上immediate: true防抖逻辑同样生效。5.2 路由参数变化时的数据加载在 Vue Router 里从/user/1跳转到/user/2组件实例会被复用created钩子不会重新执行。这时候必须用watch监听路由参数import { useRoute } from vue-router const route useRoute() watch( () route.params.id, async (newId, oldId) { if (newId oldId) return currentUser.value await fetchUser(newId) }, { immediate: true } )这个写法基本是 SPA 项目的模板代码。需要注意的点是监听的是route.params.id而不是整个route因为路由对象上有query、hash、name等一堆属性如果直接watch(route)只要 URL 有任何无关变化都会触发请求比如只改了query里一个不影响业务的参数。5.3 子组件内部状态同步到父组件Vue 官方推荐“单向数据流”但总有场景需要子组件把内部状态实时同步给父组件。除了用emit外还可以用watch监听 props 变化做反向同步。最常见的是一个表单抽屉组件父组件打开抽屉时传入一个初始表单对象子组件负责编辑。问题是如果父组件在“打开抽屉”和“编辑完成后重新打开”时传入了同一个对象引用子组件里的 watch 就感知不到变化。解决办法是用immediate: true加监听“整个 props”watch( () props.formData, (newVal) { localForm.value JSON.parse(JSON.stringify(newVal)) }, { immediate: true, deep: true } )这里的JSON.parse(JSON.stringify(...))其实做了一个浅克隆目的是切断父子组件共享同一个响应式对象的引用避免子组件内部修改时意外改动父组件的数据。如果你的表单对象深层嵌套较多记得换成cloneDeep。6. 关于性能优化的几个实操心得watch本身不是一个高开销的操作但用得太随意会在不知不觉中拖垮页面。我总结了三个在真实项目中验证过的心得。6.1 回调里避免重活watch的回调是同步执行的如果回调里有大循环、复杂计算、同步请求比如XMLHttpRequest的同步模式会直接阻塞渲染线程。正确的做法是把耗时的操作放进异步任务里或者交给 Web Worker。举一个反面教材// 不推荐回调里做大量同步计算 watch(list, () { const bigArray [] for (let i 0; i 100000; i) { bigArray.push(i * i) } result.value bigArray })这样写list一变页面就卡顿。更好的是只更新一个“脏标记”让computed或watchEffect在渲染时再去算。6.2 什么时候用 computed 代替 watch很多人刚学会watch就到处用其实有一类场景用computed更优雅。例如“根据筛选条件过滤列表”// watch 版本维护两份数据容易出错 const keyword ref() const rawList ref([]) const filteredList ref([]) watch(keyword, () { filteredList.value rawList.value.filter(...) })// computed 版本一份数据源自动推导 const keyword ref() const rawList ref([]) const filteredList computed(() rawList.value.filter(...) )看到差别了吗computed不需要手动维护同步状态也不存在“忘记更新”的问题。能用computed推导的优先用computed只有在“变化需要触发副作用”时才用watch。这个判断标准能帮你省掉很多隐藏 bug。6.3 组件销毁时记得清理watch在组件销毁时通常会自动清理但如果是在自定义组合式函数里创建的 watch或者手动调用的watch就必须手动停止监听。watch的返回值是一个停止函数const stopWatch watch(source, callback) // 某个时刻 stopWatch()在组合式函数内部如果创建了不受组件生命周期约束的 watch一定要记得在合适的时机关掉否则会造成内存泄漏尤其是监听全局状态或 localStorage 的场景。7. 面试高频题你知道 watch 和 watchEffect 的区别吗最近两年的 Vue 面试题里“watch和watchEffect的区别”几乎成了必考题。这里给你一个从底层原理出发的理解方式比背答案有用得多。watchEffect的核心是“依赖自动收集”。它执行你传入的函数函数里访问了哪些响应式数据哪些就成了它的依赖。函数立即执行一次之后只要依赖数据变化它就会重新执行。它的回调里不需要显式声明“监听谁”因为代码本身已经告诉了 Vue。watch的核心是“显式指定数据源”。你需要手动列出要监听的内容然后告诉 Vue“当它变化时执行什么”。它默认不会立即执行除非你加immediate: true。它的回调里能拿到新值和旧值所以在需要“前后对比”的场景中不可替代。从原理上想watchEffect其实更接近 Vue 3 的响应式系统本质——一切都是副作用依赖变化自动重放。watch则保留了一个更传统的“事件订阅”心智模型。两者不是替代关系而是互补关系维度watchwatchEffect指定数据源手动显式指定自动收集立即执行默认不执行总是立即执行拿到新旧值可以不能适合场景需要条件判断/前后对比只需执行副作用性能控制更精准依赖分析自动但可能过度触发实战中我的经验是如果回调里只关心“某个数据变了就重新请求”用watchEffect更简洁如果还需要在回调里做条件分支或者需要oldVal就用watch。两个都熟练了写代码时自然知道怎么选。8. 排查 watch 不触发的套路最后给一份我自己用的排查清单遇到“watch 不触发”别慌按顺序过一遍基本都能定位。确认数据源是不是响应式的。如果是普通变量比如let count 0那无论怎么改都不会触发。必须用ref、reactive或computed包一层。听的是属性路径还是整个对象。如果你写的是watch(() state.a.b, ...)要确保state.a不是undefined否则求值提前报错watch 自然没法建立依赖。对象内部变更是否足够触发。如果监听整个ref对象但内部属性变了又没有加deep: true就不会触发。优先用“精确到属性”的监听方式。回调里是否有立即执行需求。如果页面初始化时需要执行一次别忘了immediate: true。是不是被防抖/节流吞掉了。检查回调里有没有setTimeout或clearTimeout的竞争关系特别是连续触发时容易丢最后一次。组件是否被 keep-alive 缓存。activated和deactivated生命周期会影响 watch 的触发时机如果组件被缓存watch 不会在重新进入时自动触发要在activated里手动调用一次。Vue 2 的特有问题。旧项目里对象新增属性、数组索引赋值不触发是Object.defineProperty的天花板改用Vue.set或$set能快速验证问题。这七条排查下来我遇到的 watch 诡异问题 95% 都能解决。剩下 5% 大多出在更新时序上配合nextTick和flush: post基本也能定位。watch这个 API 看起来不起眼但在真实项目里就是这些不起眼的细节在决定体验和稳定性。多写几次、多踩几次坑慢慢就有了手感。如果你在项目里也遇到过什么奇葩的 watch 问题欢迎按这套思路去排查一遍——大概率不是 Vue 的问题而是数据源本身在设计上没想清楚。