Vue3 watch怎么用?有哪些高级用法和避坑点?
最近刷开发交流群,发现不少刚从Vue2转Vue3的朋友,或者用Vue3但只会写基础监听的小伙伴,都在踩watch的小坑——要么监听没触发,要么触发了但数据不对,要么不知道怎么处理深层数据、首次渲染就触发的需求,今天咱们就从基础用法聊起,把高级用法拆得明明白白,再点出最容易踩的5个坑,看完你就能把Vue3 watch用得得心应手了。
基础用法:新手入门先搞懂这3种监听对象
watch的核心作用就是“监听数据变化,触发回调函数”,但Vue3的监听对象比Vue2灵活,支持监听响应式数据(ref、reactive定义的)、getter函数、数组甚至多个数据组合,咱们逐个说。
监听单个ref定义的数据
ref定义的基本类型(比如number、string、boolean)和引用类型(比如对象、数组,但ref会把引用类型包一层.value属性),都可以直接作为监听对象。
举个例子,比如你做一个输入框实时验证功能,输入框的内容用inputValue = ref('')变了都检查长度:
import { ref, watch } from 'vue'
export default {
setup() {
const inputValue = ref('')
const errorMsg = ref('')
// 直接监听inputValue,参数是newVal(新值)和oldVal(旧值)
watch(inputValue, (newVal, oldVal) => {
if (newVal.length < 6) {
errorMsg.value = '密码至少6位哦'
} else if (newVal.length > 16) {
errorMsg.value = '密码别超过16位'
} else {
errorMsg.value = ''
}
console.log(`密码从「${oldVal}」改成了「${newVal}」`)
})
return { inputValue, errorMsg }
}
}
这里要注意,监听ref定义的基本类型时,不需要加.value,Vue会自动解包;但如果是监听ref定义的引用类型的内部属性(比如inputValue.value.password),不能直接写inputValue.password,得用getter函数或者deep选项,后面会讲。
监听reactive定义的数据
reactive定义的是响应式对象,不能直接监听整个对象的某个属性吗?当然可以,但直接监听整个reactive对象的话,newVal和oldVal会是同一个对象引用——因为Vue3对reactive做的是深层响应式,修改内部属性不会改变对象本身的地址,这时候oldVal其实没用,除非你加了deep和immediate之外的其他配置?不对,不管加不加,直接监听整个reactive对象的新旧值都是一样的。 如果要监听reactive对象的某个属性,或者整个对象但需要正确的旧值,推荐用getter函数:
import { reactive, watch } from 'vue'
export default {
setup() {
const user = reactive({
name: '张三',
age: 25,
address: {
city: '北京',
district: '朝阳区'
}
})
// 监听整个reactive对象(新旧值同引用)
watch(user, (newVal, oldVal) => {
console.log('user整个对象的新旧值同引用:', newVal === oldVal) // 永远true
})
// 用getter函数监听单个属性(age,基本类型,新旧值正确)
watch(() => user.age, (newVal, oldVal) => {
console.log(`年龄从「${oldVal}」改成了「${newVal}」`)
})
return { user }
}
}
监听多个数据组合
有时候需要监听两个或以上的数据,只要其中一个变了就触发回调,这时候可以把监听对象放在一个数组里:
import { ref, reactive, watch } from 'vue'
export default {
setup() {
const count = ref(0)
const user = reactive({ name: '张三' })
// 数组里可以放ref、getter函数
watch([count, () => user.name], ([newCount, newName], [oldCount, oldName]) => {
console.log('count或user.name变了')
console.log(`count新:${newCount},旧:${oldCount}`)
console.log(`name新:${newName},旧:${oldName}`)
})
return { count, user }
}
}
这个组合监听的newVal和oldVal也是数组,顺序和你写的监听对象顺序一致。
进阶配置:这4个选项让watch功能翻倍
基础用法解决了“触发”问题,但实际开发中会有“首次渲染就触发”“监听深层嵌套数据”“限制触发频率”这类需求,这时候就需要用watch的第三个参数——配置对象。
首次渲染触发:immediate
默认情况下,watch只有在监听对象变化时才会触发回调,但有时候需要页面刚加载完就执行一次(比如初始化验证、从接口拿数据后根据数据设置状态),这时候加immediate: true就行。
还是拿刚才的密码输入框举例,页面刚打开如果inputValue是空的,应该直接提示“密码至少6位”:
// 之前的代码基础上,加immediate: true
watch(inputValue, (newVal, oldVal) => {
// 这里oldVal第一次触发时是undefined哦
if (newVal.length < 6) {
errorMsg.value = '密码至少6位哦'
} else if (newVal.length > 16) {
errorMsg.value = '密码别超过16位'
} else {
errorMsg.value = ''
}
}, { immediate: true })
注意,开启immediate后,第一次触发的oldVal是undefined,这点别写错逻辑。
监听深层嵌套数据:deep
刚才说过,直接监听ref定义的引用类型(比如userRef = ref({...})),或者用getter监听reactive的整个对象?不对,reactive本身就是深层响应式,但刚才说直接监听整个reactive对象新旧值同引用,那如果监听的是ref的引用类型呢?默认情况下,ref的引用类型只会监听.value的地址变化,不会监听内部属性——比如你把userRef.value重新赋值成一个新对象,会触发;但如果只改userRef.value.name,不会触发,这时候就需要加deep: true。
用个电商购物车的例子,购物车是ref定义的数组,里面每个商品是对象,修改商品数量、选中状态都要更新总价格:
import { ref, watch, computed } from 'vue'
export default {
setup() {
const cart = ref([
{ id: 1, name: '手机', price: 5999, count: 1, selected: true },
{ id: 2, name: '耳机', price: 999, count: 2, selected: false }
])
const totalPrice = ref(0)
// 监听cart的深层变化,开启deep
watch(cart, (newCart) => {
totalPrice.value = newCart.reduce((sum, item) => {
return item.selected ? sum + item.price * item.count : sum
}, 0)
}, { deep: true, immediate: true }) // 顺便加immediate初始化总价格
return { cart, totalPrice }
}
}
不过deep选项会递归遍历整个对象/数组,性能消耗比较大,如果只需要监听其中几个深层属性,还是建议用getter函数,性能会更好。
限制触发频率:flush
默认情况下,watch的回调会在Vue的DOM更新前执行,比如你修改了inputValue,watch先触发,然后errorMsg变,然后DOM才更新;但有时候需要等DOM更新完再执行回调(比如获取更新后的DOM元素尺寸),这时候就需要调整flush选项。
flush有三个值:
-
pre(默认):DOM更新前执行
-
post:DOM更新后执行
-
sync:同步执行(数据一变立刻触发,不管Vue的更新队列,性能最差,尽量少用) 举个获取DOM元素尺寸的例子,比如有个div,内容会根据某个响应式数据变化,要获取变化后的div高度:
import { ref, watch, nextTick } from 'vue' export default { setup() { const content = ref('短内容') const divHeight = ref(0) const divRef = ref(null) // 用flush: post,或者nextTick包裹回调,效果差不多 watch(content, () => { // flush: post时不需要nextTick,但加了也没问题 divHeight.value = divRef.value.offsetHeight }, { flush: 'post' }) return { content, divHeight, divRef } } }这里也可以用
nextTick(() => { divHeight.value = divRef.value.offsetHeight })代替flush: post,两者都是等DOM更新后执行,但flush: post更简洁,是专门为watch设计的。
清理副作用:onCleanup
这个选项可能新手用得少,但在处理异步操作(比如接口请求、定时器)时特别有用——如果watch在触发回调后,还没等异步操作完成,监听对象又变了,这时候上一次的异步操作可能会产生副作用(比如多次请求接口、旧数据覆盖新数据),这时候就需要用onCleanup清理上一次的副作用。
举个搜索建议的例子,用户输入关键词,过300ms(防抖,虽然有防抖插件,但这里先自己写个简单的理解onCleanup)才请求接口,如果用户输入得快,上一次的请求还没完成就应该取消:
import { ref, watch } from 'vue'
export default {
setup() {
const keyword = ref('')
const suggestions = ref([])
const isLoading = ref(false)
watch(keyword, (newKeyword, oldKeyword, onCleanup) => {
// 定义一个定时器,300ms后请求
let timer = setTimeout(async () => {
if (!newKeyword.trim()) {
suggestions.value = []
isLoading.value = false
return
}
isLoading.value = true
try {
// 模拟接口请求
const res = await new Promise(resolve => {
setTimeout(() => {
resolve([`${newKeyword}1`, `${newKeyword}2`, `${newKeyword}3`])
}, 500)
})
suggestions.value = res
} catch (error) {
console.error('获取搜索建议失败', error)
} finally {
isLoading.value = false
}
}, 300)
// 这里是重点:清理上一次的定时器
onCleanup(() => {
clearTimeout(timer)
// 如果有真实的接口请求,可以用AbortController取消
// abortController.abort()
})
})
return { keyword, suggestions, isLoading }
}
}
onCleanup的回调会在下一次watch触发前或者组件卸载时执行,完美解决了异步操作的副作用问题。
避坑指南:这5个坑你肯定踩过或者即将踩
聊完了用法和配置,再说说最容易踩的坑,这些都是群里小伙伴踩过无数次总结出来的,一定要记住。
坑1:直接监听ref的引用类型内部属性,没加deep或getter
刚才说过,ref的引用类型默认只监听.value的地址变化,内部属性变了不会触发,
import { ref, watch } from 'vue'
export default {
setup() {
const userRef = ref({ name: '张三' })
// 直接监听userRef,修改name不会触发!
watch(userRef, (newVal) => {
console.log('userRef变了', newVal)
})
// 只有这样才会触发:
// userRef.value = { name: '李四' }
return { userRef }
}
}
解决方法要么加deep: true,要么用getter函数监听内部属性() => userRef.value.name,后者性能更好。
坑2:直接监听reactive的单个属性,没加getter函数
import { reactive, watch } from 'vue'
export default {
setup() {
const user = reactive({ name: '张三' })
// 这样写不会报错,但也不会触发!因为user.name是一个普通的字符串,不是响应式对象
watch(user.name, (newVal) => {
console.log('user.name变了', newVal)
})
return { user }
}
}
解决方法必须用getter函数() => user.name,因为getter函数返回的值会被Vue追踪依赖。
坑3:直接监听整个reactive对象,以为oldVal有用
刚才反复提过,直接监听整个reactive对象,newVal和oldVal是同一个对象引用,因为修改内部属性不会改变对象地址,
import { reactive, watch } from 'vue'
export default {
setup() {
const user = reactive({ name: '张三', age: 25 })
watch(user, (newVal, oldVal) => {
console.log(newVal === oldVal) // 永远true
console.log(oldVal.name) // 已经变成新的name了!
})
return { user }
}
}
解决方法是用getter函数监听整个对象的深拷贝?不对,深拷贝性能差,除非你真的需要旧值,否则还是监听单个属性,或者用computed先返回一个新的对象引用?
import { reactive, watch, computed } from 'vue'
export default {
setup() {
const user = reactive({ name: '张三', age: 25 })
// computed每次依赖变化都会返回一个新的对象引用(这里简单的展开,深层的话需要深拷贝)
const userCopy = computed(() => ({ ...user }))
watch(userCopy, (newVal, oldVal) => {
console.log(newVal === oldVal) // false
console.log(oldVal.name) // 旧的name
})
return { user }
}
}
但如果是深层嵌套的对象,展开不够,需要用JSON.parse(JSON.stringify())或者lodash的cloneDeep,但性能消耗大,尽量避免这种需求。
坑4:开启immediate后,忘记oldVal是undefined
刚才基础用法里提过,开启immediate后第一次触发的oldVal是undefined,如果你在回调里用到了oldVal,比如比较新旧值的差异,一定要加判断,
import { ref, watch } from 'vue'
export default {
setup() {
const count = ref(0)
watch(count, (newVal, oldVal) => {
// 一定要加oldVal !== undefined的判断!
if (oldVal !== undefined && newVal > oldVal + 5) {
console.log('count增长太快了')
}
}, { immediate: true })
return { count }
}
}
不加的话第一次触发会报错或者逻辑错误。
坑5:频繁使用deep选项,导致性能问题
deep选项会递归遍历整个对象/数组,监听所有属性的变化,比如一个有1000个商品的购物车数组,每个商品有10个属性,加deep的话每次修改任何一个属性,Vue都会遍历一遍,性能消耗特别大。 解决方法就是“按需监听”:只监听你需要的属性,用getter函数;如果需要监听多个深层属性,可以把它们放在一个数组里监听;如果必须监听整个深层对象/数组,尽量让数据结构简单一点。
watch vs watchEffect:什么时候用哪个?
聊完了watch,很多人肯定会问watchEffect,毕竟都是Vue3的监听API,这里简单对比一下,帮你选对合适的API:
- watch:
- 需要明确指定监听对象
- 可以获取新值和旧值
- 默认不立即触发,需要手动加immediate
- 适合有明确触发条件、需要旧值的场景
- watchEffect:
- 不需要明确指定监听对象,会自动追踪回调里用到的所有响应式数据
- 不能获取旧值,只能获取新值
- 默认立即触发(相当于watch加了immediate)
- 适合“只要用到的响应式数据变了就执行,不需要旧值”的场景,比如根据搜索关键词请求接口(不需要防抖的话)
举个watchEffect的例子,还是刚才的购物车总价格:
import { ref, reactive, watchEffect } from 'vue'
export default {
setup() {
const cart = reactive([...])
const totalPrice = ref(0)
// 自动追踪cart里的selected、price、count
watchEffect(() => {
totalPrice.value = cart.reduce((sum, item) => {
return item.selected ? sum + item.price * item.count : sum
}, 0)
})
return { cart, totalPrice }
}
}
这个写法比watch更简洁,因为不需要指定监听对象,也不需要加immediate。
今天咱们从Vue3 watch的基础用法(监听ref、reactive、多个数据)聊起,讲了4个进阶配置(immediate、deep、flush、onCleanup),点出了5个最容易踩的坑,还对比了watch和watchEffect的区别。 总结一下核心要点:
- 监听基本类型用ref直接传,监听引用类型内部属性用getter函数或deep
- 按需使用配置选项,immediate解决首次触发,deep解决深层监听(但注意性能),flush解决执行时机,onCleanup解决异步副作用
- 避开5个常见坑,尤其是直接监听reactive单个属性、忘记oldVal是undefined、频繁用deep
- watch和watchEffect选对:需要旧值、明确触发条件用watch;不需要旧值、自动追踪用watchEffect
好了,今天的内容就到这里,如果你还有其他Vue3的问题,欢迎在评论区留言讨论。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


