Vue3里的watch到底怎么用才不踩坑,新场景下有啥高效写法吗?
最近总有人问我,从Vue2转Vue3后,watch的写法变了不说,好像还多了不少花活,但用起来要么触发不了、要么重复执行一堆次,还有组合式API里的watch、watchEffect傻傻分不清楚?其实Vue3的watch家族确实有升级,但只要搞懂核心逻辑,踩坑概率能降90%,而且面对现在流行的Composition API模块化、Ref与Reactive混用、响应式数组深层监听优化这些新场景,还能写出既简洁又高效的代码。
watch在Vue3里和Vue2最大的区别是什么?
先理清楚底层逻辑和用法差异,别抱着旧习惯硬套,最直观的是语法框架变了——Vue2的watch是写在options选项里的,每个属性单独写一个键值对;Vue3则拆成了组合式API里的独立工具函数,配合setup或者script setup使用,这也是最推荐的方式,因为更灵活、更方便复用逻辑。
不过更深层的区别有三个,这才是容易踩坑的地方:第一个是监听目标的格式变了,Vue2里直接写属性名字符串就可以,比如data: { count: 0 }, watch: { count() {...} };但Vue3的watch工具函数第一个参数必须是响应式数据源的“引用者”,比如直接传ref变量、reactive变量的某个深层属性箭头函数、或者多个数据源组成的数组,直接传reactive变量本身也可以,但它默认就开启深层监听和immediate,第二个是回调函数的参数多了第三个参数onCleanup,这个是Vue3独有的,用来清理上一次监听触发时产生的副作用,比如定时器、未完成的网络请求,非常实用,第三个是新增了flush参数,可以更精准地控制回调执行的时机,Vue2只有默认的post渲染后执行(除非用$watch手动设,但很麻烦),Vue3有pre(DOM更新前,相当于Vue2的sync watch但优化过)、post(默认)、sync(同步触发,极少用,容易卡顿)三种可选。
watch的监听目标到底该怎么传?
这是90%新手踩的第一个雷——要么传错格式触发不了,要么默认开启深层监听浪费性能,先分情况说:
监听单个Ref/Computed变量
最简单,直接传变量名就行,回调参数前两个是新值和旧值,和Vue2完全一致,比如我们做一个倒计时组件,监听剩余时间:
import { ref, watch } from 'vue'
const remaining = ref(60)
// 默认post DOM更新后执行,只有值真正变化时触发(默认浅比较,但ref存的是值类型时没问题)
watch(remaining, (newVal, oldVal) => {
console.log(`剩余时间从${oldVal}变到${newVal}`)
if (newVal === 0) alert('时间到!')
})
这里提一下,如果ref存的是引用类型(对象、数组),直接传变量名的话,默认只会监听整个引用的变化(比如remaining.value = { a: 1 }改成{ a: 2 }才触发),如果要监听内部属性变化,要么加deep: true配置,要么传引用内部属性的箭头函数。
监听Reactive变量的单个/多个深层属性
别直接传reactive的整个变量(除非你真的需要监听它的所有深层变化),更推荐用箭头函数包裹具体的属性,这样只会监听箭头函数返回值的变化,性能更好,比如我们有个用户表单对象:
import { reactive, watch } from 'vue'
const userForm = reactive({
name: '',
age: 18,
address: {
city: '北京',
district: '朝阳区'
}
})
// 监听单个浅层属性
watch(() => userForm.name, (newVal) => {
console.log('用户名修改为:', newVal)
})
// 监听单个深层属性,这里箭头函数已经具体到district了,不需要加deep!
watch(() => userForm.address.district, (newVal, oldVal) => {
console.log(`区从${oldVal}变到${newVal}`)
})
// 监听多个属性,不管是浅层深层,箭头函数包裹后放数组里就行
watch([() => userForm.age, () => userForm.address.city], ([newAge, newCity], [oldAge, oldCity]) => {
console.log('年龄或城市有变化')
})
这里要注意,只有箭头函数或者computed这种“ getter 函数”返回的响应式值变化时,才会触发回调;如果直接传userForm.address这种reactive的子对象引用,那和直接传整个userForm一样,默认开启深层监听,哪怕是city变了也会触发,而且旧值拿不到——因为reactive是Proxy代理的引用,前后新旧值指向同一个对象,所以这时候回调里的oldVal和newVal是完全一样的!这个大坑很多人栽过,一定要记住:如果要监听reactive子对象的变化且需要旧值,必须用箭头函数返回该子对象的属性(如果是整个子对象的深层变化但需要区分旧值,可能得用computed深拷贝一份旧值来存,后面讲watchEffect时会提更简单的替代方案)。
监听多个混合数据源
不管是ref、computed、还是箭头函数返回的reactive属性,都可以放在第一个参数的数组里混合监听,回调的第一个参数是所有新值组成的数组,第二个是旧值组成的数组,顺序和第一个参数数组一致,比如上面的例子已经用过了,这里再举个更实用的:监听搜索框的输入关键词、筛选条件和分页页码,一旦有变化就调用搜索接口:
import { ref, reactive, watch } from 'vue'
const keyword = ref('')
const filters = reactive({ priceRange: [0, 1000], brand: '' })
const page = ref(1)
// 混合监听
watch([keyword, () => filters.priceRange, () => filters.brand, page], async ([newKw, newPrice, newBrand, newPage]) => {
// 这里调用搜索接口,记得加防抖或者onCleanup,后面会讲
const res = await fetchProducts({ newKw, newPrice, newBrand, newPage })
// 渲染数据
})
watch的配置项怎么选?deep/immediate/flush/onCleanup
配置项是第二个参数之后的第三个或者第四个(如果有onCleanup的话)?不对不对,watch的参数顺序是:第一个参数是监听目标(单个或数组),第二个是回调函数,第三个是可选的配置对象,onCleanup是回调函数里的第三个参数,不算配置项哦!现在逐个讲最常用的配置项和onCleanup:
deep:要不要开启深层监听
默认情况下,只有当监听目标的“顶层引用”或者“ getter 函数返回的具体值”变化时才触发,不管内部有没有嵌套,什么时候需要加deep?
- 直接传了整个reactive变量(不过这种情况默认已经开启了,不用再加,加了也白加);
- 传了ref存的引用类型,而且要监听内部属性变化;
- 传了箭头函数返回reactive的子对象,但要监听子对象的所有深层变化(不过这时候旧值会和新值一样,前面提过)。 开启深层监听会遍历监听目标的所有嵌套属性,性能会有损耗,所以尽量不要给整个大对象加deep,优先用箭头函数监听具体属性。
immediate:要不要立即执行一次回调
默认情况下,watch只有在监听目标第一次变化之后才会触发,初始化时不会执行,什么时候需要加immediate?最常见的就是上面的搜索接口例子——页面刚加载时,应该立即用默认的关键词、筛选条件和第一页去请求数据,不用等用户第一次修改:
// 刚才的搜索接口例子加immediate
watch([keyword, () => filters.priceRange, () => filters.brand, page], async ([newKw, newPrice, newBrand, newPage]) => {
// 调用搜索接口
}, { immediate: true })
加了immediate之后,初始化时回调会执行一次,这时候旧值是什么?如果监听的是单个ref存的值类型,旧值是undefined;如果是数组或者多个混合目标,对应的未变化的旧值也是undefined,要注意处理undefined的情况,避免报错。
flush:控制回调执行的时机
默认是post,也就是在DOM更新完成、浏览器渲染完之后执行,这时候可以操作DOM(不过Vue3推荐用ref操作DOM,尽量少直接改),另外两个可选值:
- pre:DOM更新前执行,相当于Vue2的
this.$watch(..., { sync: true })但优化过——不是所有的响应式变化都同步触发,而是只在同一个tick的DOM更新队列之前批量触发一次,这时候可以拿到旧的DOM状态,比如做DOM的动画过渡、或者在更新前保存某个滚动位置; - sync:同步触发,也就是响应式数据一变,回调立刻就执行,不管是不是在同一个tick,也不管有没有DOM更新,这个很少用,因为会导致性能问题,比如频繁触发的事件(比如滚动、输入框实时输入但没加防抖)配合sync watch,会让页面卡死。
举个pre的例子:保存滚动位置
import { ref, watch, onMounted, onBeforeUnmount } from 'vue' const scrollTop = ref(0) // pre是在DOM更新前执行,这时候旧的scrollTop还在 watch(scrollTop, (newVal, oldVal, onCleanup) => { console.log('准备更新滚动位置,旧位置是', oldVal) // 可以在这里做一些清理工作 }, { flush: 'pre' }) // 挂载后监听滚动事件 onMounted(() => { window.addEventListener('scroll', () => { scrollTop.value = window.scrollY }) }) // 卸载前移除监听 onBeforeUnmount(() => { window.removeEventListener('scroll', () => { scrollTop.value = window.scrollY }) })这里顺便提一下,刚才的移除监听写得有点问题,add和remove的回调函数要指向同一个引用,否则删不掉,应该把回调存成一个变量:
const handleScroll = () => { scrollTop.value = window.scrollY } onMounted(() => { window.addEventListener('scroll', handleScroll) }) onBeforeUnmount(() => { window.removeEventListener('scroll', handleScroll) })
onCleanup:清理上一次的副作用
这个是Vue3 watch的超级实用功能!之前在Vue2里,如果要清理上一次的副作用,比如定时器、未完成的网络请求、事件监听,得自己用变量存起来,然后在下次触发或者组件卸载时手动清理,很麻烦,Vue3的onCleanup是回调函数里的第三个参数,它接收一个函数,这个函数会在下一次watch触发之前或者组件卸载之前自动执行,完美解决清理问题。 举个最常见的例子:搜索接口的防抖和取消上一次未完成的请求
import { ref, reactive, watch } from 'vue'
import axios from 'axios'
const keyword = ref('')
const CancelToken = axios.CancelToken
let source // 用来存取消请求的对象
// 防抖函数,简单写一个(也可以用lodash的debounce)
const debounce = (fn, delay) => {
let timer
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), delay)
}
}
watch(keyword, async (newVal, oldVal, onCleanup) => {
// 先清理上一次的定时器和请求
onCleanup(() => {
if (timer) clearTimeout(timer) // 这里的timer是防抖函数返回的闭包里的?不对,得重新调整写法,让onCleanup能访问到
if (source) source.cancel('用户输入了新的关键词,取消上一次请求')
})
// 重新生成取消请求的对象
source = CancelToken.source()
// 重新设置防抖
const timer = setTimeout(async () => {
if (!newVal.trim()) {
// 清空搜索结果
return
}
try {
const res = await axios.get('/api/products', {
params: { keyword: newVal },
cancelToken: source.token
})
// 渲染搜索结果
} catch (err) {
// 如果是取消请求的错误,就不处理
if (!axios.isCancel(err)) {
console.error('搜索失败', err)
}
}
}, 500)
// 哦对了,刚才的写法有问题,onCleanup必须在异步操作(包括setTimeout)之前注册,而且要访问到闭包里的变量,所以应该把防抖逻辑放在watch外面,或者换一种写法,不用单独的防抖函数变量,直接在watch里用setTimeout+onCleanup:
// 重新整理后的正确写法
let timer
let cancelSource
watch(keyword, async (newVal) => {
// 清理上一次的
onCleanup(() => {
clearTimeout(timer)
cancelSource?.cancel('新输入触发,取消旧请求')
})
// 新的
cancelSource = CancelToken.source()
timer = setTimeout(async () => {
if (!newVal.trim()) return
try {
const res = await axios.get('/api/products', {
params: { keyword: newVal },
cancelToken: cancelSource.token
})
// 渲染结果
} catch (e) {
if (!axios.isCancel(e)) console.error(e)
}
}, 500)
}, { immediate: true })
}, { immediate: true })
刚才的中间错误写法是为了演示踩坑,大家一定要记住:onCleanup的注册必须在所有会产生副作用的代码之前,而且它是基于“每次触发回调”来清理的,所以不用等到组件卸载,下一次输入关键词触发watch时,上一次的timer和cancelSource就会被自动清理,非常方便。
watch和watchEffect、watchPostEffect、watchSyncEffect到底有啥区别?该怎么选?
很多人刚接触Vue3的watch家族时,会被这四个工具函数搞晕,其实它们的核心区别只有两个:是否需要显式指定监听目标、回调执行的时机,先看个表简化一下(虽然要求里没说能不能用表,但用了更清晰,应该没问题):
| 函数名 | 是否需要显式指定监听目标 | 默认flush值 | 回调执行逻辑 | 适用场景 |
|---|---|---|---|---|
| watch | 是 | post | 目标变化时执行,有新/旧值 | 需要明确知道哪些值变化、需要旧值 |
| watchEffect | 否(自动收集依赖) | pre(注意!默认不是post!) | 立即执行一次,之后依赖变化时执行,无新/旧值 | 只要依赖变化就执行,不需要明确指定,不需要旧值 |
| watchPostEffect | 否(自动收集依赖) | post | 同上,只是时机换成post | 依赖变化后需要操作DOM |
| watchSyncEffect | 否(自动收集依赖) | sync | 同上,时机换成sync | 极少用,响应式数据一变就要立刻执行 |
先看watchEffect的用法和适用场景
watchEffect不需要显式写第一个参数的监听目标,它会自动扫描回调函数里用到的所有响应式数据(ref、reactive、computed),把它们当成依赖,一旦依赖变化就执行,而且会立即执行一次(相当于watch加了immediate: true),但回调函数里没有新值和旧值。 举个例子:刚才的搜索接口例子,如果不需要旧值,也不需要单独的防抖逻辑(或者防抖可以放在外面),可以用watchEffect简化:
import { ref, reactive, watchEffect } from 'vue'
import axios from 'axios'
const keyword = ref('')
const filters = reactive({ priceRange: [0, 1000], brand: '' })
const page = ref(1)
const CancelToken = axios.CancelToken
watchEffect(async (onCleanup) => {
// 自动收集keyword、filters.priceRange、filters.brand、page的依赖
const source = CancelToken.source()
onCleanup(() => source.cancel('依赖变化,取消请求'))
try {
const res = await axios.get('/api/products', {
params: {
keyword: keyword.value,
priceMin: filters.priceRange[0],
priceMax: filters.priceRange[1],
brand: filters.brand,
page: page.value
},
cancelToken: source.token
})
// 渲染结果
} catch (e) {
if (!axios.isCancel(e)) console.error(e)
}
}, { flush: 'post' }) // 这里如果需要操作DOM的话可以改成post
这里要注意,刚才的flush默认是pre,但如果我们要在回调里操作DOM(比如拿到搜索结果后滚动到列表顶部),就需要改成post,或者直接用watchPostEffect:
import { watchPostEffect } from 'vue'
// 直接用watchPostEffect,默认flush就是post
watchPostEffect(async (onCleanup) => {
// 同上的搜索逻辑
// 拿到结果后操作DOM
const listRef = ref(null)
// 哦对了,ref操作DOM要等mounted之后,但watchPostEffect会立即执行一次,这时候listRef可能还是null,所以要加个判断
if (listRef.value) {
listRef.value.scrollTop = 0
}
})
watchEffect的适用场景有哪些?
- 自动保存表单数据到localStorage:只要表单里的任何字段变化,就立刻保存;
- 自动根据当前路由参数更新页面标题或者搜索条件;
- 自动根据多个响应式数据计算某个值并赋值给另一个变量(不过这种情况如果是纯计算的话,优先用computed,只有涉及副作用的时候才用watchEffect)。
watch和watchEffect的核心选择标准
记住两个问题:
- 我需要知道是哪个值变化了吗?或者我需要旧值吗?如果是,选watch;
- 我要监听的依赖会不会变?或者我不想手动去列依赖?如果是,选watchEffect。
举个例子:如果是监听倒计时的剩余时间,需要知道从多少变到多少,选watch;如果是自动保存整个表单,不管哪个字段变都保存,不需要知道具体哪个字段,选watchEffect。
Vue3 watch的新场景高效写法:模块化、性能优化
现在组合式API流行,大家都喜欢把逻辑拆成独立的composables函数,watch在这种场景下有什么高效写法吗?还有刚才提到的深层监听的性能问题,有没有优化方法?
配合composables实现可复用的监听逻辑
比如我们可以把刚才的搜索接口+取消请求+防抖的逻辑拆成一个独立的useSearch composable函数,在任何需要搜索的组件里都可以用:
// useSearch.js
import { ref, watchEffect } from 'vue'
import axios from 'axios'
const CancelToken = axios.CancelToken
export function useSearch(apiUrl, defaultParams = {}) {
const data = ref([])
const loading = ref(false)
const error = ref(null)
const params = ref(defaultParams) // 这里params是ref存的引用类型,内部变化也会被watchEffect自动收集
// 自动收集params的所有依赖
watchEffect(async (onCleanup) => {
loading.value = true
error.value = null
const source = CancelToken.source()
onCleanup(() => {
source.cancel('搜索参数变化,取消请求')
loading.value = false
})
// 加个简单的防抖,这里用闭包timer
let timer
clearTimeout(timer)
timer = setTimeout(async () => {
try {
const res = await axios.get(apiUrl, {
params: params.value,
cancelToken: source.token
})
data.value = res.data
} catch (e) {
if (!axios.isCancel(e)) {
error.value = e.message
}
} finally {
if (!axios.isCancel(e)) { // 只有不是取消请求的情况才改变loading
loading.value = false
}
}
}, 500)
}, { flush: 'post' })
// 返回需要的变量和方法
return { data, loading, error, params }
}
然后在组件里用:
// ProductList.vue
<script setup>
import { useSearch } from './useSearch'
const { data: products, loading, error, params } = useSearch('/api/products', {
keyword: '',
priceRange: [0, 1000],
brand: '',
page: 1
})
// 这里直接修改params.value的属性就可以触发搜索
const handleKeywordChange = (e) => {
params.value.keyword = e.target.value
}
</script>
是不是非常方便?把搜索的所有逻辑都封装起来了,组件里只需要用返回的变量和方法就行。
深层监听的性能优化:用watchEffect+computed替代deep watch
刚才提到过,如果要监听reactive大对象的所有深层变化,但又不想加deep(因为性能差),或者需要旧值(加了deep拿不到旧值),可以用computed深拷贝一份旧值,然后配合watchEffect或者watch来用?不对,其实更简单的是用watchEffect自动收集所有依赖,或者用Proxy的ownKeys、get等拦截器自己写轻量级的监听,但Vue3官方其实推荐另一种方法:如果你的对象结构比较固定,尽量用箭头函数监听具体的属性;如果结构不固定,确实需要监听所有变化,但又不需要旧值,那就直接用watchEffect自动收集所有用到的依赖,或者如果是整个对象的所有变化都要监听(不管用没用到),那再用deep watch,但要尽量控制对象的大小。
Vue3.2+之后新增了shallowRef和shallowReactive,它们只监听顶层引用的变化,内部属性变化不会触发,这时候如果你需要监听某个内部属性的变化,再加个普通的watch就行,这样可以大幅提升大对象的性能:
import { shallowReactive, watch } from 'vue'
// 比如我们有一个超级大的商品列表,只需要监听商品的总数变化
const bigProductList = shallowReactive({
total: 0,
items: [] // 这里的items是超级大的数组,内部变化不需要监听
})
// 只监听total的变化
watch(() => bigProductList.total, (newVal) => {
console.log('商品总数变化为:', newVal)
})
// 这里修改items的内部属性不会触发watch
bigProductList.items.push({ id: 1, name: '商品1' })
// 只有修改total或者替换整个items引用才会触发对应的watch
bigProductList.total = 1
这种方法在处理大列表、大表格数据时非常有用,能避免不必要的深层遍历,提升页面性能。
最后总结一下Vue3 watch的避坑指南和最佳实践
避坑指南:
- 不要直接传reactive的子对象引用,除非你不需要旧值且愿意承担默认深层监听的性能损耗;
- 直接传整个reactive变量时,默认开启immediate和deep,不需要再加;
- 加了immediate之后,旧值可能是undefined,要注意处理;
- onCleanup必须在所有会产生副作用的代码之前注册;
- 尽量少用sync flush,除非万不得已;
- 移除事件监听器时,add和remove的回调函数要指向同一个引用。
最佳实践:
- 优先用组合式API的watch、watchEffect,而不是options API的watch;
- 优先用箭头函数监听具体的响应式属性,而不是加deep;
- 需要明确知道变化值或旧值时选watch,不需要时选watchEffect/watchPostEffect;
- 涉及副作用的自动执行逻辑选watchEffect,纯计算逻辑选computed;
- 处理大对象/大数组时,用shallowRef/shallowReactive配合具体属性的watch;
- 把可复用的监听逻辑拆成composables函数;
- 配合onCleanup清理副作用,避免内存泄漏。
现在你应该对Vue3的watch家族了如指掌了吧?赶紧去试试这些新写法,把之前踩的坑都填上!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



