Vue里的watch到底是啥?新手搞懂监听器的3个误区和5个高频实用场景
接触Vue开发的朋友,大概都听过data、computed、watch这三大核心响应式API吧?其中computed因为带缓存、适合多对一处理数据,经常被当成首选,导致很多刚入门的人要么把watch当“备胎”随便用,要么完全不敢碰复杂的监听配置——比如什么deep、immediate、flush这些参数,看着就头大,其实watch恰恰是解决很多“computed搞不定”场景的利器,今天咱们就通过新手最常问的几个隐形问题,彻底把这个API说透。
为什么说watch不是computed的“低配版”?
刚接触的时候我也犯过这个错:觉得不就是监听数据变化吗?computed能返回值还自动更新,watch还要手动写逻辑处理变化,太麻烦了,直到做了一个电商后台的价格批量修改功能,才发现两者的定位完全不一样,咱们可以先拿个简单的例子对比一下——比如要根据商品的“成本价”和“利润率”算出“销售价”,同时当销售价变化超过500元时,弹出一个提示框提醒运营检查。
用computed的话,算销售价没问题,但触发提示框就有点“硬塞”的感觉了:你得在computed的getter里写副作用代码(比如弹窗、接口请求、操作DOM),但官方明确说过computed的getter应该是纯函数——没有任何副作用,输入相同的data返回的结果必须一模一样,这是为了保证computed的缓存可靠性,要是你在getter里加了弹窗,那每次其他依赖computed的组件渲染,哪怕成本价和利润率没变,只要缓存被触发检查?不对,缓存检查是看依赖的响应式数据有没有变,但纯函数的原则就是不能有副作用,不管触发不触发都不行,万一哪天你不小心改了computed的逻辑,把它改成被多个地方调用的,那弹窗就要弹N次了。
这时候watch的优势就出来了:它天生就是为了处理数据变化带来的副作用而设计的——只要你监听的响应式数据(或者属性、甚至是getter函数的返回值)变了,你想执行什么操作就执行什么,弹窗、存localStorage、发接口查库存、更新图表数据,完全没问题,而且它不依赖缓存,不管你有没有其他地方用这个数据,只要变了就会触发回调。
总结一下两者的核心区别:computed是“数据的加工站”,输出新数据,纯函数优先,带缓存;watch是“数据的安全员/执行者”,只盯着指定数据的变化,负责干活,不在乎有没有新输出。
Vue 3的watch和Vue 2有啥不一样?踩过坑的人来聊聊
很多从Vue 2转Vue 3的朋友,一开始用watch都会遇到点小问题——比如监听ref的基本数据类型没问题,监听ref的对象却怎么都不触发回调?或者deep、immediate参数的写法感觉变别扭了?其实Vue 3的watch底层用的是Proxy,比Vue 2的Object.defineProperty更强大,但API确实做了一些调整,主要有3个新手容易忽略的点:
第一个点:监听单个响应式数据的写法更灵活,但要注意“监听值”和“监听引用”的区别
Vue 2里监听data里的某个属性,直接写字符串就行,比如watch: { price(newVal, oldVal) { ... } };Vue 3的组合式API里,watch是一个独立的函数,第一个参数可以是4种类型:
- 单个ref变量(包括基本类型和对象)
- 单个reactive变量(只能是对象/数组,不能解构后监听)
- 一个返回值的getter函数(这个超级实用,后面高频场景会讲)
- 由以上三种组成的数组(可以同时监听多个数据)
踩坑的地方主要在于监听ref的对象:比如你写了const goods = ref({ price: 100, stock: 50 }),直接watch(goods, (newVal, oldVal) => { ... }),当你修改goods.value.price = 200时,回调不会触发——因为你监听的是goods这个ref的引用,只有当你把整个goods.value替换成新对象(比如goods.value = { price: 200, stock: 50 })时,引用变了,回调才会触发,这时候要想监听内部属性的变化,就得加deep: true参数了,这个和Vue 2一样。
但监听reactive的对象不一样,比如const goods = reactive({ price: 100, stock: 50 }),直接watch(goods, (newVal, oldVal) => { ... }),修改内部属性会自动触发,因为reactive默认就是深层监听的——不过这里有个小遗憾:不管加不加deep,监听reactive对象时,newVal和oldVal永远是一样的,因为Proxy是直接修改原对象的,不像ref的对象替换时有新旧两个引用,要是你需要对比新旧对象的某个属性,最好用getter函数监听那个属性本身,或者加个immediate先存个初始值。
第二个点:immediate和flush参数的作用场景更明确了
Vue 2里的immediate参数大家应该有点印象:就是页面刚加载的时候,不管监听的数据有没有变化,先执行一次回调,Vue 3里这个参数保留了,但组合式API里还有个更好用的“watchEffect”函数——不过今天咱们主要讲watch,先不说这个,immediate的作用场景比如页面刚加载时,根据URL里的id参数去查商品详情,你可以监听route.params.id,加个immediate: true,就不用在onMounted里再写一遍查详情的逻辑了,代码更简洁。
flush参数是Vue 3新增的(其实Vue 2.6+也有类似的$nextTick配合,但写法麻烦),它有三个可选值:'pre'(默认)、'post'、'sync',新手刚开始可能不知道什么时候改这个,举个例子你就懂了:比如你监听了商品的“库存”,当库存小于10时,要修改页面上一个DOM元素的颜色(虽然Vue尽量不让直接操作DOM,但难免有特殊情况),默认的'pre'是在Vue的DOM更新之前执行回调的——这时候你要操作的那个元素可能还没渲染出来,或者数据还没同步到DOM上,操作就会失效;这时候改成'post',回调就会在DOM更新之后执行,没问题了。'sync'一般很少用,它是在数据变化的时候同步执行回调,不经过Vue的响应式队列,可能会影响性能,除非你有特别即时的需求。
第三个点:可以手动停止监听了
Vue 2里的watch是绑定在组件实例上的,组件销毁的时候会自动停止,但要是你想在组件销毁前就停止某个监听(比如用户取消了某个操作,不需要再监听数据变化),基本只能用一个变量标记一下,回调里判断标记再执行,有点麻烦,Vue 3的组合式API里,watch函数会返回一个“停止监听函数”,你把它存起来,想停的时候直接调用就行——
import { ref, watch } from 'vue'
const count = ref(0)
const stopWatch = watch(count, (newVal) => {
console.log('count变了:', newVal)
})
// 当count超过10时停止监听
if (count.value > 10) {
stopWatch()
}
这个功能在做一些临时的状态监听时非常有用,比如弹窗打开的时候监听表单的输入变化,弹窗关闭的时候就停止,节省性能。
新手最容易踩的3个watch误区,看看你中了几个?
刚才对比Vue 2和Vue 3的时候已经提到了一些,咱们再单独列出来3个最常见的,帮大家避避坑:
在watch的回调里直接修改监听的数据,导致无限循环
这个应该是新手最常犯的错误了——比如你写了个watch: { price(newVal) { this.price = Math.round(newVal) } },当price变化时,回调里又修改了price,触发新一轮的watch,再修改,再触发……最后浏览器就卡死了,解决办法很简单:要么别在回调里修改同一个监听数据(除非加个判断,比如只有当Math.round(newVal)和newVal不一样的时候才修改),要么用computed来做这种“数据修正”的工作——因为computed的getter里不能修改data,从根源上避免了无限循环。
监听数组时只修改内部元素的属性,不用deep或者$set(Vue 2)/直接替换新属性(Vue 3 reactive已经不需要了,这里主要说Vue 2或者Vue 3监听ref数组内部属性)
Vue 2里用Object.defineProperty监听数组,只能监听到数组的长度变化和通过push、pop、shift、unshift、splice、sort、reverse这7个变异方法修改的内容,要是你直接修改数组某个索引的元素(比如this.goodsList[0].price = 200其实在Vue 2里如果goodsList[0]是个响应式对象的话是可以的,但如果是直接this.goodsList[0] = { ... }替换索引0的元素,就不行了),或者修改非响应式对象的属性?不对,非响应式的根本不会触发,哦,准确说Vue 2里的坑是替换数组索引或者添加新属性,需要用$set或者splice;而Vue 3里不管是ref数组还是reactive数组,替换索引、添加属性都没问题,但要是监听ref数组的引用而不加deep,修改内部元素的属性还是不会触发的,刚才已经说过了。
滥用deep: true,导致性能下降
很多新手为了图省事,不管监听什么都加个deep: true——比如监听一个有几百上千个属性的大对象,每次修改任何一个小属性,Vue都要递归遍历整个对象的所有属性,检查有没有变化,这在数据量大的时候会非常影响性能,解决办法是:尽量用getter函数监听你真正需要变化的那个属性或者几个属性,比如watch(() => goods.price, (newVal) => { ... }),这样只监听price这一个属性,性能好多了;或者如果确实需要监听多个内部属性,可以把它们放在一个数组里监听,比如watch([() => goods.price, () => goods.stock], ([newPrice, newStock]) => { ... })。
5个高频实用场景,看完你就知道watch该怎么用了
说了这么多理论和误区,咱们来看看实际开发中watch到底能解决什么问题:
监听路由参数变化,重新请求数据
这个应该是最常用的了——比如做一个商品列表页,URL里有个page参数表示页码,当用户点击“下一页”或者“上一页”时,page变了,需要重新请求商品列表,Vue 3组合式API里可以这样写:
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const goodsList = ref([])
// 同时监听page和categoryId参数,任何一个变了都重新请求
watch(
() => [route.params.page, route.query.categoryId],
async ([newPage, newCategoryId]) => {
// 这里可以加个loading状态,优化用户体验
const res = await fetchGoodsList(newPage, newCategoryId)
goodsList.value = res.data
},
{ immediate: true } // 页面刚加载的时候也执行一次
)
这里用immediate: true就不用在onMounted里再写一遍fetchGoodsList了,代码更简洁。
监听表单输入变化,自动保存到localStorage
比如做一个博客编辑页,用户可能写了一半不小心关掉了页面,要是没有自动保存功能,就太惨了,这时候可以监听表单的“标题”和“内容”,每次变化延迟一小段时间(防抖)再保存到localStorage——要是每次输入一个字就保存,也会影响性能。
import { ref, watch } from 'vue'
const title = ref('')
const content = ref('')
let timer = null
// 同时监听标题和内容
watch( content],
() => {
// 防抖:每次变化清除上一次的定时器,3秒后再保存
clearTimeout(timer)
timer = setTimeout(() => {
localStorage.setItem('blogDraft', JSON.stringify({ title: title.value, content: content.value }))
}, 3000)
},
{ immediate: true } // 页面刚加载的时候先从localStorage读取草稿
)
// 页面刚加载的时候读取草稿
onMounted(() => {
const draft = localStorage.getItem('blogDraft')
if (draft) {
const { title: t, content: c } = JSON.parse(draft)value = t
content.value = c
}
})
// 组件销毁的时候清除定时器
onUnmounted(() => {
clearTimeout(timer)
})
这里要注意组件销毁的时候清除定时器,避免内存泄漏,VueUse里有个useDebounceFn和useStorage的组合,实现这个功能更简单,但新手先掌握原生的写法比较好。
监听数据变化,更新第三方库的状态
比如你在Vue里用了ECharts做图表,图表的数据是从Vue的data里获取的——当data里的数据变化时,需要手动调用ECharts的setOption方法更新图表,这时候watch就派上用场了:
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import * as echarts from 'echarts'
const chartData = ref([])
let chartInstance = null
// 初始化图表
onMounted(async () => {
await nextTick() // 确保DOM元素已经渲染出来
const chartDom = document.getElementById('myChart')
chartInstance = echarts.init(chartDom)
// 先设置一次初始数据(也可以用immediate: true)
updateChart()
// 监听窗口大小变化,调整图表大小
window.addEventListener('resize', () => {
chartInstance.resize()
})
})
// 监听chartData变化,更新图表
watch(chartData, () => {
updateChart()
}, { deep: true }) // 要是chartData是数组或者对象,记得加deep
// 更新图表的函数
const updateChart = () => {
if (!chartInstance) return
const option = {
xAxis: { type: 'category', data: chartData.value.map(item => item.date) },
yAxis: { type: 'value' },
series: [{ type: 'line', data: chartData.value.map(item => item.sales) }]
}
chartInstance.setOption(option)
}
// 组件销毁的时候销毁图表实例,清除事件监听
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
}
window.removeEventListener('resize', () => {
chartInstance.resize()
})
})
这里要注意用nextTick确保DOM元素已经渲染出来,不然echarts.init会找不到元素;还要注意组件销毁的时候销毁图表实例,避免内存泄漏。
监听用户输入,进行实时搜索(带节流/防抖)
比如做一个搜索框,用户输入的时候实时显示搜索建议——要是每次输入一个字就发一次接口,服务器压力会很大,所以需要加防抖(延迟一小段时间,等用户停止输入再发接口)或者节流(每隔一小段时间发一次接口),这里防抖更适合实时搜索:
import { ref, watch } from 'vue'
const searchKeyword = ref('')
const searchSuggestions = ref([])
let timer = null
watch(searchKeyword, (newKeyword) => {
clearTimeout(timer)
// 要是用户清空了搜索框,直接清空建议
if (!newKeyword.trim()) {
searchSuggestions.value = []
return
}
// 防抖500毫秒
timer = setTimeout(async () => {
const res = await fetchSearchSuggestions(newKeyword)
searchSuggestions.value = res.data
}, 500)
})
这里的逻辑和自动保存到localStorage差不多,只是延迟时间更短,一般300-500毫秒就够了。
监听多个数据的组合变化,执行特定操作
比如做一个打车软件的计价功能,当“里程”超过10公里或者“时长”超过30分钟时,要加收“远途费”或者“超时费”——这时候可以用getter函数返回一个组合的布尔值,监听这个布尔值的变化:
import { ref, watch, computed } from 'vue'
const mileage = ref(0)
const duration = ref(0)
// 当然也可以用computed算额外费用,但要是算额外费用的时候需要发接口查当前的远途费/超时费单价,就需要用watch了
const needExtraFee = computed(() => mileage.value > 10 || duration.value > 30)
watch(needExtraFee, async (newNeed) => {
if (newNeed) {
const res = await fetchExtraFeePrice()
// 然后更新额外费用的显示
} else {
// 清空额外费用的显示
}
})
这里要是不需要发接口,用computed直接算额外费用就行,但要是需要发接口(比如单价是动态的,从服务器获取),就必须用watch了。
最后再总结一下watch的使用原则
- 优先用computed:要是只是需要根据现有数据生成新数据,没有副作用,就用computed,别用watch;
- 只监听真正需要变化的数据:尽量用getter函数或者数组监听具体的属性,别随便加deep: true,避免性能下降;
- 别在回调里修改同一个监听数据:除非加了严格的判断,不然会导致无限循环;
- 合理使用immediate和flush参数:immediate适合页面刚加载时就需要执行一次的场景,flush: 'post'适合需要操作DOM的场景;
- 组件销毁前记得清理临时资源:比如定时器、事件监听、第三方库实例,还有手动停止不需要的watch。
好啦,今天关于Vue watch的分享就到这里,要是你还有其他问题,欢迎在评论区留言哦!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网
