Vue3的watch handler有哪些实用写法?不同场景该怎么选?还藏着多少容易踩的小坑?
现在打开社区论坛或者技术群,总能看到转Vue3的朋友在问watch相关的问题:明明watch写得和Vue2差不多,怎么要么不触发要么数据乱了?getter到底该怎么返回才算对?还有那个immediate和deep,怎么感觉比以前“敏感”又“迟钝”?其实这些混乱,很大一部分是没搞清楚Vue3对watch handler的重构逻辑——它不再像Vue2那样,依赖选项式API里的watch对象就行,组合式API带来了更灵活但也更需要精细化使用的写法,今天咱们就把这些问题掰开揉碎讲清楚,从基础到进阶,连踩坑都提前踩一遍,看完就能顺手用。
组合式API下的4种核心写法
Vue3的watch核心是从vue里直接导出的watch()函数,这个函数接受的第一个参数是“侦听源”,第二个才是我们常说的“handler处理函数”,不过侦听源的类型不一样,handler的表现也会跟着变,甚至连参数的写法都有简化版,咱们先从最常用的开始说。
直接侦听ref变量
这是新手最容易上手的方式,不用写getter,直接把ref变量名丢给第一个参数就行,比如我们做一个todo应用,需要监听用户输入的关键词变化,去过滤待办事项,这时候直接写关键词的ref变量就行。
假设我们有:
import { ref, watch } from 'vue'
const keyword = ref('')
const filteredTodos = ref([])
那侦听逻辑就可以写成:
watch(keyword, (newVal, oldVal) => {
// 这里的newVal是keyword.value更新后的值,oldVal是更新前的原始值
console.log(`关键词从「${oldVal}」变成了「${newVal}」`)
// 过滤逻辑
filteredTodos.value = originalTodos.value.filter(todo =>
todo.title.includes(newVal)
)
})
这里有个小细节:如果keyword是reactive解构出来的ref(比如const { count } = toRefs(state)),也可以直接这么用,本质上和普通ref是一样的。
侦听reactive对象的某个属性
直接侦听整个reactive对象当然也可以,但很多时候我们只关心其中一两个属性,比如用户的登录表单,只想监听手机号格式变对不对,没必要监听整个form对象,这时候第一个参数就不能直接写form.phone,因为reactive的属性本身不是响应式引用(除非用toRef单独转),这时候就得用getter函数包裹住属性。
getter函数的写法很简单,就是返回你要监听的那个值:
import { reactive, watch } from 'vue'
const loginForm = reactive({
phone: '',
password: ''
})
只监听手机号的话:
watch(
() => loginForm.phone,
(newVal) => {
// 这里只做手机号格式校验就行,不用管密码
if (!/^1[3-9]\d{9}$/.test(newVal) && newVal) {
console.log('手机号格式不对哦')
}
}
)
同时侦听多个侦听源
有时候我们需要两个值都变了才触发处理?或者其中一个变了就触发?Vue3的watch支持把第一个参数写成数组,里面可以放ref变量、reactive属性的getter、甚至是普通值?不对不对,普通值不行,得是响应式的,数组里的元素每一个都得是有效的侦听源。
同时监听后,handler的参数也会变成数组:newVal是所有源更新后的值的数组,oldVal是更新前的数组,顺序和第一个参数的数组一致,比如我们要同时监听登录表单的手机号和密码,两个都填了才激活登录按钮:
const isLoginBtnDisabled = ref(true)
watch(
[() => loginForm.phone, () => loginForm.password],
([newPhone, newPwd]) => {
isLoginBtnDisabled.value = !(/^1[3-9]\d{9}$/.test(newPhone) && newPwd.length >= 6)
}
)
这个场景里,只要其中一个源变了,handler就会触发,数组里的顺序不会影响,只会影响newVal和oldVal里的对应位置。
监听整个reactive对象(含嵌套对象/数组)
如果直接把整个reactive对象作为侦听源,那默认情况下,handler只会在对象的顶层属性被替换的时候触发——注意哦,是替换,不是修改属性值!比如你把loginForm = reactive({ phone: '' })之后直接给loginForm赋值一个新对象(当然在组合式API里,reactive返回的是响应式代理,不能直接替换整个变量,得用Object.assign或者toRaw替换),这时候才会触发,那如果我们要监听对象内部所有属性的修改,包括嵌套对象的属性、数组的push/splice/修改索引值呢?这时候就得加第三个配置参数{ deep: true }。
举个嵌套对象的例子:
const user = reactive({
name: '张三',
address: {
city: '北京',
district: '朝阳区'
},
tags: ['前端', 'Vue3']
})
如果直接watch(user, () => { console.log('user变了') }),那只有你把user的name改成非字符串或者替换整个address、tags数组的时候才触发;改成watch(user, () => { console.log('user深层变了') }, { deep: true })之后,修改address.city、tags.push('React')甚至tags[0] = '全栈'都会触发。
不过这里要提前预警一个性能问题:deep:true会递归遍历整个对象,给所有属性(包括嵌套的、未来添加的)都加上侦听器,对象层级越深、属性越多,性能消耗就越大,所以尽量不要随便给大对象加deep:true,能拆成单个属性监听就拆成单个。
组合式API里的简化版:watchEffect
很多刚转Vue3的朋友会把watch和watchEffect搞混,其实watchEffect也是一种“侦听+处理”的写法,但它和watch的最大区别是:不需要显式指定侦听源,它会自动追踪函数内部用到的所有响应式数据,而且默认是immediate: true(第一次执行代码的时候就会先运行一次处理函数)。
比如刚才的过滤todo事项的例子,用watchEffect可以写成这样:
import { ref, watchEffect } from 'vue'
const keyword = ref('')
const filteredTodos = ref([])
const originalTodos = ref([/* 初始数据 */])
// 第一次组件挂载到DOM前就会执行一次filteredTodos的赋值
watchEffect(() => {
filteredTodos.value = originalTodos.value.filter(todo =>
todo.title.includes(keyword.value)
)
})
这种写法适合处理函数内部只用到响应式数据,而且不需要oldVal,第一次执行也没关系的场景,比watch简洁很多。
那什么时候用watch什么时候用watchEffect呢?可以记一个小口诀:“要旧值、指定源、不立即,找watch;只新值、自动找、默认立即,用Effect”,比如刚才的登录表单激活按钮的场景,也可以用watchEffect吗?当然可以,而且更短:
watchEffect(() => {
isLoginBtnDisabled.value = !(/^1[3-9]\d{9}$/.test(loginForm.phone) && loginForm.password.length >= 6)
})
它会自动追踪loginForm.phone和loginForm.password,这两个变了它就触发,第一次也会执行,刚好符合我们的需求。
选项式API下的保留写法
虽然Vue3主推组合式API,但选项式API还是完全保留的,老项目迁移过来完全不用担心,选项式API里的watch对象写法和Vue2几乎一样,但有两个小细节要注意:
第一个是deep:true的表现,和组合式API的一样——Vue3把选项式API的watch内部实现也改成了和组合式API一致的逻辑,所以现在选项式API里直接监听嵌套对象,不加deep:true也不会触发内部属性的修改了,这点和Vue2不一样哦,很多老Vue2用户转过来第一个踩的就是这个坑。
第二个是如果在选项式API里想访问组合式API里定义的ref变量,不需要加.value,Vue3会自动帮你解包。
export default {
setup() {
const count = ref(0)
return { count }
},
watch: {
count(newVal, oldVal) {
// 这里直接写count,不用count.value
console.log(`count从${oldVal}变到${newVal}`)
}
}
}
那些90%的人都踩过的watch handler小坑
讲完了写法和场景,咱们来说说避坑,这些坑都是社区里高频出现的,提前知道了能省不少调试时间。
坑一:直接修改侦听源的newVal/oldVal导致数据混乱
很多新手刚用watch的时候,可能会顺手修改newVal,
watch(count, (newVal) => {
newVal++ // 错误!newVal只是一个原始值的副本,不是响应式引用
// 要修改count的话,直接写count.value++或者在setup里修改state里的属性
})
如果侦听源是对象(不管是reactive直接监听还是getter返回的对象),那newVal和oldVal是同一个引用哦!这时候修改newVal的属性,oldVal也会跟着变,更重要的是,对象本身也会变,可能会导致无限循环或者数据不一致。
const user = reactive({ age: 20 })
watch(user, (newVal) => {
newVal.age++ // 这里会触发user的更新,然后又触发watch,形成无限循环
}, { deep: true })
所以千万不要在handler里直接修改侦听源(尤其是对象的属性),如果需要根据新值修改其他数据,修改和侦听源无关的响应式数据就行。
坑二:immediate:true时oldVal是undefined
不管是组合式API还是选项式API,加了immediate:true之后,第一次执行handler的时候,oldVal都会是undefined,这点在需要对比新旧值做逻辑的时候要特别注意,比如刚才的todo过滤,如果加了immediate:true,第一次就不会打印“关键词从xxx变成了xxx”,因为oldVal是空的,需要加个判断:
watch(keyword, (newVal, oldVal) => {
if (oldVal !== undefined) {
console.log(`关键词从「${oldVal}」变成了「${newVal}」`)
}
filteredTodos.value = originalTodos.value.filter(todo =>
todo.title.includes(newVal)
)
}, { immediate: true })
坑三:侦听函数返回的非响应式原始值不会触发更新
刚才说过,第一个参数的getter函数要返回响应式依赖,如果返回的是经过计算的非响应式原始值,而且这个值只计算一次,那不管依赖的响应式数据怎么变,handler都不会触发。
const count = ref(0)
// 错误写法!getter返回的是count.value * 2,但没有直接依赖count.value?不对不对,其实是依赖的,但如果写成这样:
const doubleCount = count.value * 2 // 这里只计算了一次,doubleCount是普通数字
watch(() => doubleCount, (newVal) => {
console.log(newVal) // 永远不会触发,因为doubleCount没变
})
正确的写法是把计算逻辑放在getter里:
watch(() => count.value * 2, (newVal) => {
console.log(newVal) // count.value变了,这里就会触发
})
或者直接用computed计算doubleCount,然后侦听computed变量:
const doubleCount = computed(() => count.value * 2)
watch(doubleCount, (newVal) => {
console.log(newVal) // 也可以
})
坑四:在async函数里写watchEffect不会自动追踪
watchEffect只会自动追踪同步执行时用到的响应式数据,如果在async函数里(或者在setTimeout、Promise.then等异步回调里)用了响应式数据,那这些数据不会被追踪到。
const userId = ref(1)
watchEffect(async () => {
// 同步部分用到的userId会被追踪
const res = await fetchUser(userId.value)
// 异步回调里的userId(如果res里没用)不会被追踪,但这里res是根据userId来的,不过如果有其他异步数据就不行
})
如果异步回调里也需要追踪响应式数据,可以把回调里的逻辑抽出来,单独再写一个watchEffect,或者用watch显式指定所有需要的侦听源。
坑五:忘记清理watch/watchEffect导致内存泄漏
组合式API里的watch/watchEffect如果是在setup函数或者组件的生命周期钩子(比如onMounted)里定义的,那组件卸载的时候会自动清理,不会有内存泄漏;但如果是在定时器、事件监听器、或者全局作用域里定义的,那必须手动清理,否则会一直占用内存,甚至在组件卸载后还会触发处理函数。
手动清理的方法很简单:watch/watchEffect会返回一个清理函数,我们只需要把这个函数存起来,在需要清理的时候调用就行。
import { ref, watch, onUnmounted } from 'vue'
const count = ref(0)
// 存起来清理函数
const stopWatch = watch(count, (newVal) => {
console.log(newVal)
})
// 组件卸载的时候调用
onUnmounted(() => {
stopWatch()
})
或者如果你想在组件里某个时候手动停止,比如用户点击了“暂停监控”按钮,也可以直接调用stopWatch。
最后再总结一下
Vue3的watch handler灵活性确实比Vue2强了很多,组合式API下的4种核心写法(直接ref、getter单属性、数组多源、deep深层)覆盖了几乎所有场景,还有简化版的watchEffect可以用;选项式API的写法保留了,但有两个小细节要注意;避坑方面主要是不要修改侦听源、注意immediate的oldVal、getter要返回依赖响应式的表达式、异步里的追踪问题,还有手动清理非组件生命周期里的侦听器。
其实只要记住:watch是“明确告诉它要听啥,等啥时候变了再干啥”,watchEffect是“自动找它用了啥,一用就先干,用的东西变了再干”,然后根据场景选就行,不用纠结哪种写法更“高级”,适合自己的业务需求才是最好的。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


