Vue3 watch怎么正确使用 常见坑点全解析
最近打开前端开发群,总能刷到大家在吐槽Vue3的watch:要么监听值没触发,要么触发了两次,要么数据深层嵌套改了半天没反应……明明Vue3官方文档写得挺清楚的,怎么一上手就出问题?其实很多时候是没搞懂watch的底层适配逻辑,还有Composition API和Options API混用的细节,今天就用大家日常开发能碰到的场景,把Vue3 watch的用法、坑点、优化方案全说透,新手看完能直接用,老手也能避避之前没注意的小雷。
先搞懂Vue3 watch和Vue2 watch的本质区别
很多人一开始上手Vue3还带着Vue2的思维惯性,比如不管什么都习惯加deep:true,或者分不清watch和watchEffect,这其实是因为两个版本的响应式系统不一样,Vue2用的是Object.defineProperty,只能监听对象的已有属性,新增、删除属性要靠$set/$delete;Vue3换成了Proxy和Reflect,能直接监听整个对象的属性变化,包括新增、删除,还有数组的索引和length,不过这里有个前提——监听的是响应式数据本身还是值类型。
举个简单的例子,如果你在Vue2里写:
data() {
return {
user: { name: '小明' }
}
},
watch: {
user(newVal) {
console.log('user变了', newVal)
}
}
直接改user.name是不会触发的,必须加deep:true;但在Vue3的Composition API里,如果你用ref定义了对象:
const user = ref({ name: '小明' })
watch(user, (newVal) => {
console.log('user变了', newVal)
})
哎?不对,等下——直接改user.name好像也不会触发?哦对,这里踩了第一个小雷:ref包裹的对象/数组,默认监听的是ref.value的引用,也就是你只有把整个user.value重新赋值成{name:'小红'},watch才会有反应,那Vue3怎么监听深层嵌套的ref对象呢?别急,后面会讲三种监听方式的区别。
Vue3三种核心监听方式:watch/watchEffect/watchPostEffect,分别什么时候用?
很多文档会把watch和watchEffect放在一起对比,但很少有人会提watchPostEffect和watchSyncEffect,其实这两个在处理DOM更新、计算属性依赖的场景里特别有用,先把这三个的基础功能说清楚,再给大家几个场景化的使用建议。
场景1:需要明确指定监听源,并且拿到旧值——用普通watch
普通watch是Vue3里最常用的监听方式,它的核心特点是惰性执行——只有监听源真的发生变化时才会触发回调,而且可以拿到变化前和变化后的值,完美适配大部分“数据变了要做逻辑处理”的场景,比如表单验证、搜索关键词延迟查询、路由参数变化加载数据。
不过Vue3的普通watch支持的监听源比Vue2多很多,除了单一的ref/reactive数据,还可以是数组、函数返回值、甚至多个数据的组合,先看几个官方文档没举过,但日常开发常用的监听源写法:
import { ref, reactive, watch } from 'vue'
// 单一ref值类型监听(这时候不需要deep)
const count = ref(0)
watch(count, (newCount, oldCount) => {
console.log(`count从${oldCount}变成${newCount}`)
})
// 单一ref引用类型监听引用变化(默认deep:false,整体赋值才触发)
const user = ref({ name: '小明', age: 18, address: { city: '北京' } })
watch(user, (newUser) => {
console.log('user整体引用变了', newUser)
})
// 单一ref引用类型监听深层变化(加deep:true,引用不变但属性/子属性变也触发,旧值可能有坑!后面讲)
watch(user, (newUser, oldUser) => {
console.log('user深层变化了', newUser, oldUser)
}, { deep: true })
// 单一reactive数据监听(这里注意!reactive数据默认是深层监听的,不需要加deep:true!)
const car = reactive({ brand: '特斯拉', model: 'Model 3', config: { color: '白色' } })
watch(car, (newCar) => {
console.log('car深层变化了', newCar) // 改car.model、car.config.color都会触发
})
// 多个监听源组合(数组,任意一个变都会触发)
const searchKey = ref('')
const page = ref(1)
watch([searchKey, page], ([newKey, newPage], [oldKey, oldPage]) => {
console.log(`搜索词${oldKey}→${newKey},页码${oldPage}→${newPage}`)
// 这里可以写请求接口的逻辑,记得加防抖哦
})
// 函数返回值监听(有时候不想监听整个reactive对象,只想监听其中某个深层属性,用这个比数组更灵活)
const watchAddress = () => user.value.address.city
watch(watchAddress, (newCity, oldCity) => {
console.log(`城市从${oldCity}变成${newCity}`)
})
这里单独提一下reactive的默认深层监听:很多人刚从Vue2过来,不管ref还是reactive都加deep:true,其实完全没必要,reactive本身就是用Proxy包裹的,所有嵌套属性都是响应式的,默认deep就是true,加了反而不会报错,但会浪费一点点性能(虽然微乎其微,但能省则省嘛)。
场景2:不需要旧值,只要依赖的数据变了就执行,而且希望初始化立即执行一次——用watchEffect
watchEffect和普通watch最大的区别是自动收集依赖、非惰性执行——不用你明确指定监听什么,只要回调函数里用到了响应式数据,这些数据变了就会触发;而且组件挂载后会立即执行一次,不需要加immediate:true(哦对了普通watch也有immediate,和Vue2用法一样,组件挂载后先跑一遍回调)。
举个搜索延迟查询的对比例子:
// 用普通watch实现(得加immediate,得指定searchKey和page)
watch([searchKey, page], async ([newKey, newPage]) => {
if (newKey.trim()) {
const res = await fetchList(newKey, newPage)
list.value = res.data
} else {
list.value = []
}
}, { immediate: true })
// 用watchEffect实现(自动收集searchKey、page、list?不,list是赋值,没用到它的getter,只收集searchKey和page)
watchEffect(async () => {
if (searchKey.value.trim()) {
const res = await fetchList(searchKey.value, page.value)
list.value = res.data
} else {
list.value = []
}
})
看起来watchEffect更简洁对吧?但它也有缺点:拿不到旧值,而且依赖收集是实时动态的——如果回调里的响应式数据是条件性使用的,
const isSearchOpen = ref(false)
watchEffect(async () => {
if (isSearchOpen.value && searchKey.value.trim()) {
const res = await fetchList(searchKey.value, page.value)
list.value = res.data
}
})
那当isSearchOpen.value为false的时候,watchEffect只会收集isSearchOpen这一个依赖,searchKey和page变了都不会触发;只有当isSearchOpen变成true,回调里用到了searchKey和page,才会把这两个加进依赖列表,这个特性有利有弊,利的是能减少不必要的监听,弊的是有时候依赖太灵活,会出现“明明数据变了为什么没触发”的情况,新手可以先多用普通watch,熟悉了再用watchEffect。
场景3:需要等DOM更新完再执行逻辑——用watchPostEffect(或者flush: 'post')
不知道大家有没有碰到过这种情况:数据变了,你在watch/watchEffect里立刻获取DOM元素的高度/宽度,拿到的还是旧值?这是因为Vue的响应式更新是异步批处理的——当你修改多个响应式数据时,Vue不会立刻更新DOM,而是把这些更新放进一个微任务队列里,等下一个tick再统一执行。
那怎么拿到更新后的DOM呢?Vue2里用的是$nextTick,Vue3里可以用nextTick(),但还有更方便的写法:给普通watch加flush: 'post',或者直接用watchPostEffect(其实watchPostEffect就是flush: 'post'的watchEffect)。
举个例子:比如有一个文本编辑器,输入内容后要自动调整textarea的高度,让它刚好显示所有内容,不需要滚动条:
import { ref, watchPostEffect, nextTick } from 'vue'
const content = ref('')
const textareaRef = ref(null)
// 写法1:watchPostEffect(自动收集content依赖,初始化就调整高度)
watchPostEffect(() => {
if (textareaRef.value) {
// 先把高度设为auto,获取真实的scrollHeight,再设回去
textareaRef.value.style.height = 'auto'
textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`
}
})
// 写法2:普通watch + flush: 'post' + immediate: true
watch(content, () => {
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`
}
}, { flush: 'post', immediate: true })
// 写法3:普通watch + nextTick(也行,但稍微啰嗦一点)
watch(content, async () => {
await nextTick()
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`
}
}, { immediate: true })
这三种写法效果是一样的,但watchPostEffect最简洁,而且自动收集依赖,不用你指定content,那什么时候用flush: 'sync'呢?哦对还有个同步刷新的选项,这个选项不推荐大家常用,因为它会破坏Vue的异步批处理机制,导致性能下降,只有在非常特殊的场景下(比如需要拦截某个事件,在事件冒泡前修改DOM)才用。
Vue3 watch最容易踩的5个坑,90%的开发者都中过
刚才讲基础用法的时候提到了几个小雷,现在把所有常见的坑点整理出来,每个坑都给大家写复现场景和解决方案:
坑1:ref包裹的引用类型,默认监听引用,改属性不触发
复现场景:
const user = ref({ name: '小明' })
watch(user, (newVal) => {
console.log('user变了', newVal) // 这里不会打印!
})
user.value.name = '小红'
解决方案:刚才讲过,有三种方法:
- 加deep:true
watch(user, (newVal) => { console.log('user变了', newVal) // 改name会触发 }, { deep: true }) - 监听具体的属性(用函数返回值)
const watchUserName = () => user.value.name watch(watchUserName, (newName) => { console.log('name变了', newName) }) - 用reactive代替ref包裹引用类型(推荐!因为reactive默认深层监听,而且不需要写.value)
const user = reactive({ name: '小明' }) watch(user, (newVal) => { console.log('user变了', newVal) // 改name会触发 })这里说一下ref和reactive的使用建议:值类型(number、string、boolean、null、undefined、symbol)用ref,引用类型(object、array)用reactive,这样既不需要写很多.value,也不会踩默认监听引用的坑。
坑2:加了deep:true后,旧值和新值一样
复现场景:
const user = ref({ name: '小明' })
watch(user, (newVal, oldVal) => {
console.log('新值:', newVal, '旧值:', oldVal) // 打印出来的两个对象一模一样!
}, { deep: true })
user.value.name = '小红'
为什么会这样?因为Vue3的响应式系统里,deep监听的是引用类型的内部属性,而不是整个引用的替换,所以当你修改内部属性时,newVal和oldVal其实指向的是同一个内存地址,打印出来当然一样,那怎么拿到真正的旧值呢? 解决方案:
- 监听具体的属性(这时候旧值是对的,因为值类型是深拷贝的?不,值类型是按值传递的,引用不变但属性是值类型的话,会保留旧值)
const watchUserName = () => user.value.name watch(watchUserName, (newName, oldName) => { console.log('新name:', newName, '旧name:', oldName) // 新name小红,旧name小明,对的 }) - 如果必须监听整个对象,而且需要旧值,可以用computed先深拷贝一份(但要注意性能,深拷贝大对象会慢)
import { computed } from 'vue' const userCopy = computed(() => JSON.parse(JSON.stringify(user.value))) // 简单的深拷贝,复杂对象比如带Date、RegExp的要用lodash.cloneDeep watch(userCopy, (newCopy, oldCopy) => { console.log('新对象:', newCopy, '旧对象:', oldCopy) // 两个不一样的对象 })
坑3:监听路由参数时,只触发一次,切换同一个路由不同参数没反应
复现场景:比如你有一个用户详情页,路由是/user/:id,你在setup里监听了route.params.id:
import { useRoute } from 'vue-router'
const route = useRoute()
watch(route.params.id, (newId) => {
console.log('用户id变了', newId) // 第一次进入时触发,从/user/1切到/user/2没反应?
// 不对,其实有时候会有时候不会?哦看你怎么定义组件的
})
哦不对,应该先讲Vue Router的默认行为:如果组件是复用的(比如从/user/1切到/user/2,没有卸载组件,只是更新了params),那setup函数不会重新执行,但是route.params是响应式的,理论上应该能触发watch?那为什么有时候没反应?哦踩了另一个小雷:监听的是route.params.id这个值类型,但是如果你在watch里没有加deep,或者是不是写错了监听源?不对route.params是reactive的,那监听route.params.id用函数返回值会不会更稳? 等下查一下资料,哦其实Vue3的Vue Router 4.x里,useRoute返回的route对象本身是reactive的,但是直接监听route.params.id(ref吗?不,route.params是reactive的属性,是值类型的话,直接用watch(route.params.id)其实是监听的一个普通值,不是响应式的?哦对!这个是大雷!很多人都搞错了! 正确的监听路由参数的写法应该是:
// 写法1:监听整个route.params对象(默认深层监听,稳)
watch(route.params, (newParams) => {
console.log('用户id变了', newParams.id)
})
// 写法2:用函数返回值监听具体的参数(推荐,更灵活)
watch(() => route.params.id, (newId) => {
console.log('用户id变了', newId)
})
// 写法3:如果你确实想监听route.params.id,而且希望用直接监听的方式,那可以把它转成ref?不对用toRefs更稳
import { toRefs } from 'vue'
const { id } = toRefs(route.params)
watch(id, (newId) => {
console.log('用户id变了', newId)
})
刚才说的“直接监听route.params.id没反应”,是因为route.params.id是reactive对象的一个值类型属性,它本身不是响应式的ref,只有通过toRefs或者函数返回值才能正确收集到依赖。
坑4:watch/watchEffect里的异步请求没有取消,导致内存泄漏或者数据错乱
复现场景:比如搜索延迟查询,你在watch里写了fetchList,但是用户连续输入了好几次,前面的请求还没回来,后面的请求已经回来了,然后前面的请求又回来,把后面的数据覆盖了;或者组件卸载了,请求还在继续,导致内存泄漏。 解决方案:
- 加防抖(lodash.debounce,或者自己写一个简单的防抖函数),减少不必要的请求
import { debounce } from 'lodash-es' // 记得用es模块,不然打包会大 const fetchListDebounced = debounce(async (key, page) => { if (key.trim()) { const res = await fetchList(key, page) list.value = res.data } else { list.value = [] } }, 500) watch([searchKey, page], ([newKey, newPage]) => { fetchListDebounced(newKey, newPage) }, { immediate: true }) - 用AbortController取消未完成的请求(现在主流的浏览器和fetch都支持,axios也支持CancelToken或者AbortSignal)
let controller = null watch([searchKey, page], async ([newKey, newPage]) => { // 先取消上一个请求 if (controller) controller.abort() controller = new AbortController() const signal = controller.signal try { if (newKey.trim()) { const res = await fetch(`/api/list?key=${newKey}&page=${newPage}`, { signal }) const data = await res.json() // 还要判断一下当前的searchKey和page是不是和请求时的一样,防止组件复用的问题 if (searchKey.value === newKey && page.value === newPage) { list.value = data } } else { list.value = [] } } catch (err) { if (err.name !== 'AbortError') { console.error('请求失败', err) } } }, { immediate: true })
// 还要在组件卸载时取消最后一个请求 import { onUnmounted } from 'vue' onUnmounted(() => { if (controller) controller.abort() })
这里补充一点:watchEffect其实自带一个“清理函数”的参数,不用你手动写onUnmounted:
```js
watchEffect(async (onCleanup) => {
let controller = null
onCleanup(() => {
if (controller) controller.abort()
})
controller = new AbortController()
const signal = controller.signal
try {
if (searchKey.value.trim()) {
const res = await fetch(`/api/list?key=${searchKey.value}&page=${page.value}`, { signal })
const data = await res.json()
list.value = data
} else {
list.value = []
}
} catch (err) {
if (err.name !== 'AbortError') {
console.error('请求失败', err)
}
}
})
这个清理函数会在watchEffect重新执行前(比如依赖变了)或者组件卸载时自动执行,非常方便,推荐大家在处理异步请求时用watchEffect的清理函数。
坑5:watch监听了计算属性,但计算属性的依赖没变,watch却触发了
复现场景:比如你有一个计算属性fullName,依赖firstName和lastName,然后你监听了fullName,但是你只改了一个和fullName无关的响应式数据,比如age,为什么watch也触发了?哦不对,应该不会吧?哦等下可能是计算属性里写了副作用?
const firstName = ref('张')
const lastName = ref('三')
const age = ref(18)
const fullName = computed(() => {
console.log('计算fullName')
return firstName.value + lastName.value + age.value // 哦这里加了age!是我举错例子了
})
// 正确的复现场景应该是:计算属性的依赖是引用类型,而且引用没变,但内部属性变了,但计算属性本身没有用deep,哦不对computed默认也是自动收集依赖的,引用类型的内部属性变了,计算属性也会重新计算,那watch当然会触发
// 哦换一个更真实的坑:用了computed的getter里没有依赖,但返回了一个新的引用类型的对象,
const count = ref(0)
const obj = computed(() => {
return { num: count.value } // 每次count变,返回的都是一个新的对象!
})
watch(obj, (newVal) => {
console.log('obj变了', newVal) // 每次count变都会触发,没问题
})
// 那什么时候会有问题?哦如果count没变,但计算属性里手动返回了一个新对象?
const flag = ref(false)
const obj2 = computed(() => {
if (flag.value) {
return { num: 1 }
} else {
return { num: 1 } // 看起来num都是1,但每次flag变,返回的都是新对象!
}
})
watch(obj2, (newVal) => {
console.log('obj2变了', newVal) // flag变就会触发,虽然newVal.num都是1
})
哦对,这个是比较隐蔽的坑:computed返回的是引用类型,不管内部属性有没有变,只要返回了新的引用,watch默认就会触发(因为默认监听的是引用),那怎么解决?可以加一个比较函数的选项,Vue3的watch支持第三个参数里的comparator,自己定义什么时候算变化:
watch(obj2, (newVal) => {
console.log('obj2的num真的变了', newVal)
}, {
comparator: (a, b) => a.num !== b.num // 只有num变了才触发
})
不过comparator只适用于普通watch,不适用于watchEffect,而且只有当监听源是函数返回值或者ref/reactive引用类型的时候才有用(值类型的话本来就按值比较)。
最后给大家几个Vue3 watch的优化建议
- 优先用reactive包裹引用类型,避免默认监听引用的坑:值类型用ref,引用类型用reactive,既减少.value的书写,又不需要加deep:true。
- 尽量监听具体的属性,而不是整个对象:用函数返回值监听具体的属性,或者用toRefs把reactive的属性转成ref,这样既能拿到正确的旧值,又能减少不必要的监听回调(比如整个对象有10个属性,你只改了1个,监听整个对象的话会触发,但监听具体属性的话只有那个属性变才触发)。
- 合理使用防抖和节流:处理搜索、滚动、输入框实时验证等场景时,一定要加防抖或节流,减少不必要的监听回调和请求。
- 及时取消未完成的异步请求:用watchEffect的清理函数或者onUnmounted取消未完成的请求,避免内存泄漏和数据错乱。
- 不要滥用watch:很多时候,用computed代替watch会更简洁、更高效,比如根据两个值计算第三个值,用computed就行,不需要用watch监听两个值再赋值。
- 注意flush选项的使用:需要等DOM更新完再执行的逻辑,用watchPostEffect或者flush: 'post',不要随便用flush: 'sync'。
总结一下
Vue3的watch比Vue2强大很多,支持更多的监听源,有自动收集依赖的watchEffect,还有不同的刷新时机选项,但也带来了更多的坑点,只要我们搞懂了Vue3的响应式系统(Proxy和Reflect),搞懂了三种监听方式的区别,搞懂了常见的坑点和解决方案,就能正确使用watch,提高开发效率,避免踩雷,希望这篇文章能帮到正在学习Vue3或者正在用Vue3开发的你,如果还有其他关于Vue3 watch的问题,欢迎在评论区留言交流。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网

