Vue3 watch到底怎么用?深层监听、立即执行、多源监听这些坑你踩过几个?
刚开始学Vue3 Composition API的时候,很多人会把watch和watchEffect搞混,或者踩深层监听没生效、立即执行出问题、监听多源拿到的旧值不对这类坑,今天就用最直白的方式,把watch的用法、特性、避坑点全讲透,看完不管是基础开发还是复杂逻辑,都能顺手拈来。
watch的核心用法:监听单个响应式数据
先从最基础的单个监听说起,这个是所有人上手的第一步,但哪怕是基础,也有需要注意的细节。 watch接收三个参数——第一个是监听源,第二个是回调函数,第三个是可选配置对象,单个监听的时候,监听源有几种情况: 第一种是ref包装的基本数据类型,比如数字、字符串、布尔值,这种直接传ref变量就行,回调里会自动拿到新值和旧值,举个例子,做个计数器,每次点击数值超过5的时候提示用户:
import { ref, watch } from 'vue'
const count = ref(0)
// 监听单个ref基本类型
watch(count, (newVal, oldVal) => {
if (newVal > 5) {
console.log('超过5啦!新值是' + newVal + ',旧值是' + oldVal)
}
})
这里的count是直接传的,没问题,但如果是ref包装的引用类型(比如对象、数组),直接传的话只能监听到引用地址的变化——比如整个对象被重新赋值,而对象内部的属性变化、数组的push/splice这种修改是不会触发的,这就是第一个常见坑点。 那如果要监听ref对象的某个属性呢?可以用getter函数返回这个属性,比如监听user的age:
const user = ref({
name: '张三',
age: 18
})
// 监听ref对象的单个属性,用getter
watch(() => user.value.age, (newVal, oldVal) => {
console.log('年龄变了:' + newVal)
})
// 现在修改user.value.age = 20,就会触发啦
用getter函数的时候,要注意返回的是ref的value属性哦,不然会报错。 第二种单个监听源是reactive对象/数组,直接传reactive本身的话,默认就是深层监听的!这个和ref引用类型不一样,要区分开,举个例子:
import { reactive, watch } from 'vue'
const user = reactive({
name: '张三',
age: 18,
hobbies: ['游泳', '看书']
})
// 直接传reactive,默认深层监听
watch(user, (newVal, oldVal) => {
console.log('user有变化')
// 注意!这里的newVal和oldVal是同一个对象!
console.log('新值:', newVal, '旧值:', oldVal)
})
// 修改任何属性、push hobbies都会触发,但旧值拿不到真实的变化前状态
哦对了,这里还有个隐藏的旧值问题——直接传reactive或者ref引用类型开启深层监听后,回调里的oldVal和newVal指向的是同一个引用地址,所以根本没法对比之前的具体状态,这个坑后面讲可选配置的时候会说怎么部分解决。
高频特性详解:配置对象到底能改啥
watch的第三个参数配置对象,才是解决复杂场景和避坑的关键,常用的配置有三个:deep、immediate、flush。
第一个配置:deep(深层监听开关)
刚才说了,ref引用类型直接传默认不深层,reactive直接传默认深层,但有时候我们只需要监听reactive的第一层属性变化,不需要管内部深层嵌套的,这时候就可以把deep设为false;或者只监听ref对象的某个嵌套属性,但那个属性本身也是对象,这时候可以对getter函数的结果开启deep。 举个监听reactive第一层的例子:
const user = reactive({
name: '张三',
address: {
city: '北京',
district: '朝阳区'
}
})
// 只监听user的第一层,address内部变化不触发
watch(user, () => {
console.log('user第一层变化了')
}, {
deep: false
})
// 修改name会触发,修改address.city不会
再举个监听ref嵌套属性的例子:
const user = ref({
address: {
city: '北京'
}
})
// 监听address.city,这个属性本身是值类型,不用deep;但如果要监听整个address的变化,就需要
watch(() => user.value.address, () => {
console.log('address变了')
}, {
deep: true // 加了之后,修改address.city也会触发
})
那刚才说的直接传reactive或者ref引用类型加deep,oldVal拿不到的问题怎么办?其实没办法完全解决——因为引用类型的地址没变,Vue3只能记录新的状态,如果非要拿到变化前的具体某个属性的旧值,可以把监听源改成getter函数返回那个属性的深拷贝,但深拷贝会消耗性能,不建议在频繁触发的场景用,深拷贝的简单写法可以用JSON.parse(JSON.stringify()),但要注意不能处理undefined、Symbol、函数这些特殊类型。
第二个配置:immediate(立即执行开关)
默认情况下,watch只有在监听源第一次变化的时候才会触发回调,但有时候我们需要页面刚加载就执行一次回调,比如获取用户初始数据、展示默认筛选结果,这时候就可以把immediate设为true。 举个展示默认筛选结果的例子:
import { ref, watch } from 'vue'
const searchType = ref('all')
const searchResults = ref([])
// 模拟获取数据的函数
const fetchData = async (type) => {
// 这里替换成真实的接口请求
const mockData = type === 'all' ? ['全部文章1', '全部文章2'] : ['技术文章1', '技术文章2']
searchResults.value = mockData
}
// 页面加载时先执行一次fetchData('all'),之后searchType变化也会执行
watch(searchType, (newVal) => {
fetchData(newVal)
}, {
immediate: true
})
这里要注意的是,开启immediate后,第一次执行的回调里,oldVal是undefined,因为还没有发生过变化,这个细节在做逻辑判断的时候要记得处理。
第三个配置:flush(回调执行时机)
这个配置可能新手用得少,但在做DOM操作的时候非常重要,它能控制回调在什么时候执行,有三个可选值:'pre'(默认)、'sync'、'post'。
- pre(默认):在Vue3的DOM更新之前执行回调,这时候你获取到的DOM还是旧的。
- sync:同步执行回调,监听源一变化就立刻执行,不管DOM有没有更新或者其他微任务队列里的东西,性能会稍微差一点,但适合需要立刻响应的简单逻辑。
- post:在Vue3的DOM更新之后执行回调,这时候可以拿到最新的DOM,适合做DOM相关的操作,比如修改某个元素的样式、滚动到指定位置。
举个需要操作最新DOM的例子:
import { ref, watch, nextTick } from 'vue'
const showModal = ref(false) const modalContent = ref('')
// 模拟点击按钮显示弹窗并设置内容后,滚动到弹窗底部 watch(showModal, async (newVal) => { if (newVal) { // 先设置内容 modalContent.value = '很长的文章内容...' // 这时候用默认pre的话,DOM还没更新,拿不到modal的高度,所以可以用flush: 'post' // 或者用nextTick,但flush: 'post'更简洁 } }, { flush: 'post' })
// 补充用flush: 'post'后直接操作DOM的写法 watch(showModal, (newVal) => { if (newVal) { modalContent.value = '很长的文章内容...' // 这里直接写DOM操作就行,因为flush是post,DOM已经更新了 const modal = document.querySelector('.modal') if (modal) { modal.scrollTop = modal.scrollHeight } } }, { flush: 'post', immediate: false })
有人可能会问,nextTick和flush: 'post'有什么区别?其实nextTick是单独的异步处理,不管watch的时机;而flush: 'post'是把watch的回调直接放到DOM更新后的微任务队列里,逻辑更紧凑一点,性能差不多,但如果是在watch回调里,用flush: 'post'会更符合场景,不需要再包一层nextTick。
## 多源监听怎么用?有什么注意事项?
watch还支持监听多个响应式数据,这时候第一个参数要改成**数组**,数组里的元素可以是ref、reactive、getter函数的任意组合,回调里的新值和旧值也会变成**对应的数组**。
举个搜索场景的例子:同时监听搜索关键词和搜索类型,只要其中一个变了就重新请求数据:
```javascript
import { ref, watch } from 'vue'
const keyword = ref('')
const searchType = ref('all')
const searchResults = ref([])
const fetchData = async (kw, type) => {
// 真实接口请求
const mockData = kw === '' ? (type === 'all' ? ['全部文章1', '全部文章2'] : ['技术文章1']) : (type === 'all' ? [`搜索${kw}的全部文章1`] : [`搜索${kw}的技术文章1`])
searchResults.value = mockData
}
// 多源监听,数组里的顺序对应回调里新值旧值的顺序
watch([keyword, searchType], ([newKw, newType], [oldKw, oldType]) => {
console.log('搜索条件变化:新关键词', newKw, '旧关键词', oldKw, '新类型', newType, '旧类型', oldType)
fetchData(newKw, newType)
}, {
immediate: true, // 页面加载时先查默认
deep: false // 这里的deep对数组里的每个元素生效吗?后面讲
})
这里要注意几个多源监听的坑: 第一个坑是deep配置的作用范围——刚才说的配置对象是全局的,对数组里的所有监听源都生效,比如数组里有一个ref包装的引用类型,这时候如果开启了deep,那个ref的内部变化也会触发;如果不想让某个源深层监听,就要把那个源改成getter函数返回需要的具体值,而不是整个引用类型。 第二个坑是旧值数组的对应问题——如果数组里有引用类型的监听源(不管是直接传的ref引用类型加了deep,还是直接传的reactive),那对应的旧值位置要么是undefined(第一次immediate执行),要么是和新值同一个引用地址,没法用。 第三个坑是触发时机——只要数组里的任意一个监听源变化,就会触发一次回调,不管其他源有没有变,如果需要多个源同时满足某个条件才触发,可以在回调里加判断,或者用计算属性先包装一下,监听计算属性的结果。
watch和watchEffect到底选哪个?别再纠结了
很多人刚开始学Composition API,会觉得watchEffect比watch简单,因为不用写监听源,会自动追踪回调里用到的响应式数据,但其实两者的适用场景完全不一样,别乱用。 先回忆下watchEffect的核心特点:
- 自动追踪回调里的所有响应式数据,不需要手动指定监听源;
- 默认immediate: true,页面加载时立刻执行一次;
- 没有旧值,只能拿到新值;
- 默认flush: 'pre',和watch一样,但也有watchEffect的专属版本watchPostEffect、watchSyncEffect,和watch的flush配置是一一对应的。 那什么时候用watch,什么时候用watchEffect? 选watch的场景:
- 需要旧值做逻辑判断(比如只有当newVal > oldVal的时候才触发,或者对比用户之前的操作);
- 需要明确控制监听的源(比如只想监听某个对象的单个属性,不想监听其他无关属性,避免不必要的性能消耗);
- 不需要立即执行回调(immediate设为false)。 选watchEffect的场景:
- 不需要旧值;
- 用到的响应式数据比较多,手动指定监听源太麻烦;
- 页面加载时就需要执行一次逻辑,而且之后用到的响应式数据变化时也要执行。 举个对比的例子: 如果是做“只有当年龄增长时才提示用户”,必须用watch,因为要对比oldVal和newVal; 如果是做“自动保存用户输入的表单数据”,可以用watchEffect,因为要监听所有表单字段的变化,而且不需要旧值,页面加载时也不需要立刻保存(当然如果需要也可以,但默认自动保存一般是用户修改后)——哦不对,自动保存用户修改后的可以加防抖,watchEffect加防抖的话,第一次执行可能会被取消,所以还是用watch手动指定表单字段的getter数组,再在回调里加防抖更稳妥。 所以总结下来,watch的适用场景更广,更可控,新手建议先把watch学透,再用watchEffect处理简单的场景。
额外的避坑点总结
除了上面讲的各个特性里的坑,还有几个容易被忽略的:
- 不要在watch回调里修改监听源本身——比如监听count,回调里又修改count.value,这样会造成无限循环,除非加了条件判断(比如count.value > 10的时候设为0,这个没问题,因为只循环一次);
- 监听getter函数的时候,要确保返回的是响应式数据的路径或者值——不能返回一个普通变量,不然不会触发;
- 用ref包装的函数作为监听源是没用的——因为函数的引用地址一般不会变,除非整个函数被重新赋值;
- 在Vue3的setup语法糖里,watch的导入要和ref、reactive一起从vue里导入,不要记错。
给大家留一个小练习:做一个购物车的小功能,用watch监听购物车数组的变化,计算总价格(用计算属性也可以,但练习watch的话可以手动计算),当总价格超过1000的时候,自动打9折,同时提示用户“满1000减100”,还要注意旧值的处理(避免无限循环打折)。 做完这个练习,相信你对Vue3 watch的理解会更深入!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



