Vue3中的watch到底怎么用?它和watchEffect、computed的区别在哪?踩坑点有哪些?
我之前帮朋友改Vue2转Vue3的电商系统代码,光watch这块就踩了三个大坑,差点把周末全搭进去,后来翻了不少官方文档和社区的实战案例,才彻底搞明白它的逻辑——其实Vue3的watch不是简单把Vue2的选项式API换成组合式就行,底层的响应式机制变化带来了完全不一样的使用细节,今天就把这些整理成问答,希望能帮到和我当初一样懵的人。
先搞懂:组合式API里的watch长什么样?
别慌,Vue3给我们留了两种使用场景的watch:选项式的老样子(不过官方更推荐用组合式),组合式的需要从vue里显式引入,先从最基础的组合式写法讲起,别嫌碎,细节都是坑。
最基础的单值监听
组合式里监听单个ref值,写法是直接把ref变量当第一个参数传,回调里能拿到新值和旧值,比如监听商品数量的变化:
import { ref, watch } from 'vue'
const productCount = ref(0)
// 监听单个ref
watch(productCount, (newVal, oldVal) => {
console.log(`商品数量从${oldVal}变成了${newVal}`)
// 这里可以加购物车动画、库存预警判断这些逻辑
})
这里要注意一个点:如果productCount是个对象类型的ref(比如const cart = ref({ id: 1, count: 0 })),直接传watch的话,默认只会监听cart本身的引用变化,不会监听里面id、count的属性变化,这个就是第一个容易踩的坑,后面专门讲。
进阶监听:对象属性、reactive对象
如果要监听对象的属性,有两种方法:一是用箭头函数返回该属性,二是用reactive包裹整个对象后传整个对象(但要注意是浅监听还是深监听)。
先看箭头函数监听reactive对象的单个属性:
import { reactive, watch } from 'vue'
const cart = reactive({ id: 1, count: 0, price: 99 })
// 箭头函数返回具体属性,能拿到新老值,且默认浅监听该属性的引用/值变化
watch(
() => cart.count,
(newVal, oldVal) => {
console.log(`只监听购物车中该商品的数量:${oldVal} → ${newVal}`)
}
)
这种写法的好处是精准,只有count变的时候才会触发,不会因为price或者其他属性的变化浪费性能。
再看直接传reactive对象:
watch(
cart,
(newVal, oldVal) => {
console.log('整个cart的属性变了?')
},
{ deep: true } // 默认deep是false哦
)
直接传reactive的话,第一个坑来了:不写deep: true的话,只有给cart重新赋值整个对象才会触发回调,比如cart = { id: 2, count: 1 }这种操作,改count、price这些属性完全没反应;而且写了deep: true的话,新值和旧值是同一个引用——因为Vue3的响应式是基于Proxy的,不会像Vue2那样给深拷贝一份对比后的旧值,这点太重要了,后面我朋友踩的就是这个。
多源监听
有时候我们需要同时监听多个值,只要任何一个变了就触发回调,这时候可以把第一个参数换成数组,数组里可以放ref、reactive、箭头函数,混着来也没问题:
import { ref, reactive, watch } from 'vue'
const productId = ref(1)
const cart = reactive({ count: 0 })
const userLevel = ref(3)
// 多源监听
watch(
[productId, () => cart.count, userLevel],
([newId, newCount, newLevel], [oldId, oldCount, oldLevel]) => {
// 这里的新老值也是按数组顺序对应原来的监听源
console.log('监听的多源数据变了:', [newId, newCount, newLevel])
}
)
多源监听的回调参数是两个数组,分别对应新值和旧值的集合,顺序和第一个参数的数组完全一致,这点很人性化,而且如果其中有ref对象本身的引用变化(比如给productId重新赋值了ref(2)),也会触发,不过这种操作我们一般很少做,除非是动态切换监听的变量。
watch的可选配置项,官方文档提了但没说透的细节
除了刚才提到的deep,watch还有几个常用的配置项,比如immediate、flush、onTrack、onTrigger,后面两个是调试用的,平时用得少,但前三个必须搞明白。
immediate:要不要刚挂载就执行一次?
这个配置项和Vue2的选项式API里的一样,设为true的话,组件刚挂载(或者setup刚执行到watch这里?不对,等下,setup是在beforeCreate和created之间执行的,immediate设为true的话,是在watch定义之后、组件DOM挂载之前就立即执行第一次回调),比如监听用户等级来初始化折扣逻辑:
watch(
userLevel,
(newVal, oldVal) => {
// 第一次oldVal是undefined哦
console.log(`用户等级对应的折扣:${getDiscount(newVal)}`)
},
{ immediate: true }
)
这里要注意第二个点:immediate设为true时,第一次回调的oldVal是undefined——因为此时还没有旧的数据,这个在写条件判断的时候一定要加非空校验,不然容易报错。
flush:什么时候执行回调?
这个是Vue3新增的配置项,解决了Vue2里watch回调经常在DOM更新之前执行,拿不到最新DOM的问题,flush有三个可选值:
- pre(默认值):在组件DOM更新之前执行回调,如果要在回调里修改会影响DOM渲染的响应式数据,用这个比较合适,能避免不必要的重渲染;但如果要操作DOM(比如给新增的购物车项加滚动高亮),就拿不到最新的DOM节点了。
- post:在组件DOM更新之后执行回调,刚好和pre相反,适合操作DOM的场景,刚才提到的购物车高亮就可以用这个。
- sync:同步执行回调,也就是监听的响应式数据一变化就立即触发,不管什么时机,这个要慎用,因为会打乱Vue的响应式更新队列,可能导致性能问题或者其他逻辑bug,除非是需要极低延迟的场景(比如实时监听输入框的高频变化并进行本地存储?不过watchPostEffect有时候更合适)。
我朋友当初踩的第二个坑就是这个:他用默认的pre去获取刚添加商品后的购物车列表DOM高度,结果每次都拿到旧的高度,折腾了好久才想到加flush: 'post'。
deep:深监听到底要不要开?
刚才已经提到过一点,这里再详细说,ref的基本类型(数字、字符串、布尔值等)不需要开deep,因为它们是值类型,直接比较值就行;ref的对象类型(如果不拆包的话)默认只监听引用变化,要监听属性的话,要么用箭头函数指向具体属性(精准且性能好,推荐优先用),要么开deep;reactive的对象类型默认是浅监听引用变化(其实Proxy本身是可以监听到属性变化的,但组合式API里的watch默认对reactive对象开了浅比较?不对,等下,我再理清楚:组合式API里的watch,当第一个参数是reactive对象本身时,会自动开启深层响应式追踪,但回调触发的条件默认是比较对象的引用是否变化,除非加deep: true才会比较深层属性的变化——这点很绕,我当初看官方文档也看了两遍才明白。
而且开了deep: true还有个问题:如果监听的是多层嵌套的大对象(比如电商的商品详情页数据,有十几个属性,每个属性下面又有嵌套),会消耗大量的性能,因为Vue要递归遍历整个对象的所有属性去做依赖追踪和变化比较,所以优先用箭头函数精准监听需要的属性,实在没办法再开deep: true,并且要控制监听的对象层级不要太深。
还有刚才提到的第三个坑:reactive对象开deep: true的话,回调里的新值和旧值是同一个引用!因为Proxy代理的是原对象,Vue3不会像Vue2那样在每次变化时先深拷贝一份作为旧值,再更新原对象。
const cart = reactive({ count: 0 })
watch(
cart,
(newVal, oldVal) => {
console.log(newVal === oldVal) // 输出true!
},
{ deep: true }
)
cart.count++
那如果我需要旧值怎么办?这时候可以用箭头函数结合JSON.parse(JSON.stringify())来手动深拷贝一份监听的对象,但要注意JSON.parse(JSON.stringify())有局限性(不能处理函数、正则、Date对象等),如果有这些类型,可以用lodash的cloneDeep(不过要注意lodash的版本,vue-cli5+或者vite默认安装的lodash-es是按需引入的,比较轻量)。
watch vs watchEffect vs computed,别再傻傻分不清楚
这三个是Vue3响应式API里最容易混淆的,很多时候明明用computed更合适,却写成了watch,导致代码冗余或者性能差,我们可以从三个维度来对比:依赖来源、触发时机、返回值、使用场景。
核心对比表(方便快速查找)
| 对比维度 | watch | watchEffect | computed |
|---|---|---|---|
| 依赖来源 | 显式指定(第一个参数) | 自动收集(回调里用到的所有响应式数据) | 自动收集(getter里用到的所有响应式数据) |
| 触发时机 | 显式指定的依赖变化时触发 | 定义时立即执行一次+依赖变化时触发 | getter被访问时执行+依赖变化时缓存失效 |
| 返回值 | 无 | 无 | 返回计算后的结果 |
| 新旧值获取 | 可以拿到(箭头函数/reactive浅引用除外) | 拿不到 | 拿不到(除非结合get/set和ref手动存) |
| 性能消耗 | 依赖追踪精准,默认低;开deep高 | 自动收集所有依赖,可能有冗余;但整体灵活 | 有缓存,只有依赖变化且被访问时才重新计算,性能最高 |
| 推荐使用场景 | 需要旧值、异步操作、精准控制触发条件 | 需要自动收集依赖、不需要旧值、简单的副作用 | 需要计算后的值给模板/其他响应式数据用、有缓存需求 |
分场景举例子,一看就懂
场景1:计算商品总价
这个肯定用computed!因为总价是依赖商品数量和单价的,而且模板里会频繁访问,用computed的缓存机制能避免重复计算:
import { reactive, computed } from 'vue'
const cart = reactive({ id: 1, count: 0, price: 99 })
// 计算总价,有缓存
const totalPrice = computed(() => {
console.log('计算总价了!') // 只有count或price变的时候才会打印
return cart.count * cart.price
})
如果写成watch的话,得手动定义一个totalPrice的ref,然后在count或price变化时更新它,不仅代码多,还没有缓存(不过可以手动加,但没必要):
// 不推荐的写法
import { reactive, ref, watch } from 'vue'
const cart = reactive({ id: 1, count: 0, price: 99 })
const totalPrice = ref(0)
// 多源监听
watch([() => cart.count, () => cart.price], () => {
console.log('计算总价了!')
totalPrice.value = cart.count * cart.price
}, { immediate: true })
场景2:监听搜索关键词,延迟调用接口
这个用watch最合适!因为需要精准控制只监听关键词的变化(比如忽略空格、防抖节流等),还需要异步操作(调用接口):
import { ref, watch } from 'vue'
import { searchProducts } from '@/api/product'
const searchKeyword = ref('')
const productList = ref([])
// 可以用lodash的debounce,这里手动写个简单的
let timer = null
watch(
searchKeyword,
async (newVal) => {
// 先清空之前的定时器
if (timer) clearTimeout(timer)
// 如果关键词为空,清空列表
if (!newVal.trim()) {
productList.value = []
return
}
// 延迟500ms调用接口,避免高频触发
timer = setTimeout(async () => {
const res = await searchProducts(newVal.trim())
productList.value = res.data
}, 500)
}
)
如果写成watchEffect的话,也能实现,但自动收集依赖可能会不小心收集到其他不需要的变量,而且防抖节流的逻辑得放在回调里,和watch的写法差不多,但依赖来源不够清晰。
场景3:自动保存表单数据到本地存储
这个用watchEffect更灵活!因为不需要显式指定要监听表单的哪些字段,只要表单里的任何响应式字段变了,就自动保存:
import { reactive, watchEffect, onBeforeUnmount } from 'vue'
const formData = reactive({
username: '',
password: '',
email: '',
phone: ''
})
// 自动收集formData里的所有依赖
const stopWatch = watchEffect(() => {
localStorage.setItem('userFormData', JSON.stringify(formData))
})
// 组件卸载前要停止监听,避免内存泄漏
onBeforeUnmount(() => {
stopWatch()
})
这里还要注意一个点:无论是watch还是watchEffect,都会返回一个停止监听的函数,如果监听的逻辑是在组件内定义的,组件卸载时会自动停止,但如果是在全局或者动态组件里定义的,最好手动调用停止函数,避免内存泄漏。
除了刚才说的,还有哪些容易被忽略的踩坑点?
监听ref的基本类型数组
刚才讲过ref的对象类型默认只监听引用变化,数组也是对象类型的一种,所以监听ref的数组时,如果只是push、pop、shift、unshift这些操作(也就是改变数组的元素但不改变引用),直接传watch的话不会触发回调,除非加deep: true,或者用箭头函数指向数组本身(这点和reactive对象不一样,箭头函数指向ref的数组本身时,Vue会自动追踪数组的元素变化?不对,等下,试一下:
const list = ref([])
// 写法1:直接传ref数组,默认只监听引用变化
watch(list, () => {
console.log('写法1触发了')
})
// 写法2:箭头函数指向ref数组的值(.value)
watch(() => list.value, () => {
console.log('写法2触发了')
})
// 写法3:直接传ref数组,加deep: true
watch(list, () => {
console.log('写法3触发了')
}, { deep: true })
list.value.push(1) // 写法2和3会触发,写法1不会
list.value = [2, 3] // 三个都会触发
哦对,刚才的表述有点问题,修正一下:ref的基本类型数组,如果直接传watch的话,默认只监听数组的引用变化;如果用箭头函数返回list.value的话,会自动开启深层追踪(也就是监听数组的元素变化);如果用箭头函数返回list.value.map(item => item)这种新数组的话,和直接传ref数组默认行为一样,只监听引用变化。
监听props
props是只读的,组合式API里监听props的时候,最好用箭头函数返回具体的props属性,因为如果直接传整个props对象的话,虽然会自动追踪,但可能会有不必要的依赖,而且写法不够清晰:
// 父组件传过来的props
const props = defineProps(['productId', 'productCount'])
// 推荐的写法:箭头函数返回具体属性
watch(
() => props.productId,
(newVal) => {
// 父组件传的productId变了,重新获取商品详情
getProductDetail(newVal)
}
)
这里还要注意一个点:如果props是个对象类型,父组件没有改变对象的引用,只是改变了里面的属性,直接用箭头函数返回props.productObj的话,不会自动开启深层追踪,需要加deep: true。
回调里不要直接修改监听的响应式数据
除非你加了flush: 'sync'(但不推荐),不然在pre或者post的回调里直接修改监听的响应式数据,会导致无限循环触发watch,
const count = ref(0)
// 错误的写法:无限循环
watch(count, (newVal) => {
count.value = newVal + 1
})
count.value++
这个逻辑明显有问题,count每次加1都会触发watch,watch里又加1,无限循环下去,浏览器会卡死,如果真的需要在监听的回调里修改数据,一定要加条件判断,或者监听其他数据。
Vue3的watch到底该怎么用?
最后给大家整理一个简单的使用流程,方便大家快速上手:
- 确定使用场景:先想清楚是需要计算值(用computed),还是需要做副作用(用watch或watchEffect);
- 选择合适的API:副作用场景下,如果需要旧值、异步操作、精准控制触发条件(比如防抖节流、只监听某个属性),用watch;如果需要自动收集依赖、不需要旧值、简单的副作用(比如自动保存、自动加载),用watchEffect;
- 写watch的时候:优先用箭头函数精准监听需要的属性,避免开deep: true;如果开了deep: true,要注意新值和旧值是同一个引用的问题;如果需要操作DOM,加flush: 'post';如果需要刚挂载就执行,加immediate: true;
- 记得停止监听:如果监听的逻辑是在全局或者动态组件里定义的,记得调用返回的停止函数,避免内存泄漏。
其实Vue3的响应式API比Vue2的更灵活,但也多了很多细节,只有多写多练多踩坑,才能真正掌握,希望今天的内容能帮到大家,如果还有其他问题,欢迎在评论区留言交流。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


