Vue3里的watch到底怎么用?和computed、watchEffect有啥不一样?避坑点全整理
Vue3 watch的核心定位:精准追踪数据变化并触发自定义逻辑
先把最基础的搞明白——Vue3的watch不是凭空来的,它是Vue2 watch的升级优化版,但内核逻辑没变:专门盯着一个或一组“响应式数据的某个变化维度”,当这个维度符合预期时,执行一段你提前写好的回调。
这里的“维度”是Vue3新增的关键概念,Vue2只能默认看“值变化”,甚至深层嵌套对象只能看引用是否改变,除非加deep:true,Vue3不一样,能灵活调整追踪的颗粒度:比如只看对象的某个属性、只追踪数组的push/pop/shift/unshift/splice/sort/reverse这些原生变异方法(非整组替换)、或者不管深层浅层只看数据本身的变化触发次数?先别急,颗粒度的问题后面单独用选项来说。
先举个最简单的例子:做个实时计数保存到localStorage的功能,用setup语法糖写,首先定义一个响应式的count,然后写watch:
import { ref, watch } from 'vue'
export default {
setup() {
const count = ref(0)
// 第一个参数是“被监听的源”,第二个是回调(新值、旧值)
watch(count, (newVal, oldVal) => {
localStorage.setItem('saved-count', newVal)
})
return { count }
}
}
这就是watch最基础的用法——监听ref定义的基本数据类型,值变了就存,如果count变成对象呢?比如ref包裹一个{ value: 0 }的对象,或者用reactive?
import { ref, reactive, watch } from 'vue'
export default {
setup() {
const refObj = ref({ value: 0 })
const reactiveObj = reactive({ value: 0 })
// 监听ref包裹的对象,默认只看引用是否变(比如refObj.value = { value: 1 })
// 要监听内部属性,有两种方式
// 方式1:直接写要监听的属性的getter函数
watch(() => refObj.value.value, (newVal, oldVal) => {
console.log('refObj的value变了', newVal, oldVal)
})
// 方式2:监听整个refObj,加deep:true
watch(refObj, (newVal, oldVal) => {
console.log('refObj整个对象有变动(不管引用还是属性)', newVal, oldVal)
}, { deep: true })
// 监听reactive对象,默认就是deep:true,不用加
watch(reactiveObj, (newVal, oldVal) => {
console.log('reactiveObj整个对象有变动', newVal, oldVal)
})
// 监听reactive的单个属性,也要用getter函数,不然旧值拿不到(因为reactive是代理,新旧对象引用始终是同一个)
watch(() => reactiveObj.value, (newVal, oldVal) => {
console.log('reactiveObj的value变了', newVal, oldVal)
})
return { refObj, reactiveObj }
}
}
这里其实已经带出了第一个避坑点和第二个知识点:知识点是watch的第一个参数“被监听的源”支持两种类型:单个响应式数据(ref/reactive/computed/甚至其他watch的返回值?不过很少用),或者返回任意值的getter函数;避坑点是监听reactive定义的对象/数组时,默认deep:true,但回调里的旧值永远和新值引用一致,拿不到历史状态——如果一定要拿到reactive的旧值,只能在回调前手动存一份快照,或者用getter函数监听它的序列化字符串?不过后者开销大,适合小数据量。
继续说颗粒度的另一个调整:immediate选项,这个Vue2也有,但新手容易忘——默认watch是“懒加载”的,只有被监听的源第一次变化之后才会执行回调,但有时候需要组件刚挂载(或者watch刚被创建)就先跑一次,比如刚才的localStorage例子,组件刷新后得先从localStorage读一次count对吧?用immediate就行:
import { ref, watch } from 'vue'
export default {
setup() {
const count = ref(0)
watch(count, (newVal, oldVal) => {
// immediate为true时,第一次执行oldVal是undefined,注意判断
if (oldVal !== undefined) {
localStorage.setItem('saved-count', newVal)
} else {
// 组件刚挂载,先读
const saved = localStorage.getItem('saved-count')
if (saved) count.value = parseInt(saved)
}
}, { immediate: true })
return { count }
}
}
对,这里又带出一个小细节:immediate第一次触发,oldVal是undefined,别直接拿来用,不然容易出NaN或者其他逻辑错误。
再进阶一点:监听多个源,这个Vue2没有,Vue3新增的,非常实用——比如你要监听count和username两个值,只要其中一个变了,就保存整个用户数据到localStorage,那可以把两个源放在数组里传给watch:
import { ref, watch } from 'vue'
export default {
setup() {
const count = ref(0)
const username = ref('')
watch(
[count, username],
// 新值和旧值也是数组,顺序和源数组一致
([newCount, newName], [oldCount, oldName]) => {
localStorage.setItem('user-data', JSON.stringify({ count: newCount, name: newName }))
},
{ immediate: true }
)
return { count, username }
}
}
数组里可以混着放ref、reactive的getter、computed这些,没问题。
还有一个Vue3.2+才加的flush选项,这个稍微深一点,但遇到DOM操作的时候必须用,先讲原理:Vue有个“响应式更新队列”,当你修改多个数据时,不会立即触发DOM更新,而是等同步代码执行完,在下一个微任务里批量更新,这样能避免重复渲染,提升性能,默认watch的flush是'pre',意思是“在DOM更新之前执行回调”——这时候你在回调里拿DOM元素(比如document.querySelector),拿到的还是更新前的DOM,比如做个功能:count变了之后,DOM里的数字颜色变成红色,过1秒再变回黑色,用flush:'pre'的话:
import { ref, watch, nextTick } from 'vue'
export default {
setup() {
const count = ref(0)
watch(count, (newVal) => {
// 这里拿到的span还是旧的,或者可能还没渲染?
const span = document.querySelector('#count-span')
if (span) span.style.color = 'red'
nextTick(() => {
// nextTick是等DOM更新完,微任务里的回调
const span = document.querySelector('#count-span')
if (span) {
setTimeout(() => span.style.color = 'black', 1000)
}
})
}, { immediate: true })
return { count }
}
}
虽然可以用nextTick解决,但flush选项有个'post',可以直接让回调在DOM更新之后执行,更简洁:
import { ref, watch } from 'vue'
export default {
setup() {
const count = ref(0)
watch(count, (newVal) => {
const span = document.querySelector('#count-span')
if (span) {
span.style.color = 'red'
setTimeout(() => span.style.color = 'black', 1000)
}
}, { immediate: true, flush: 'post' })
return { count }
}
}
flush还有个'sync'选项,慎用!意思是“数据一变立即执行回调,不管队列不队列”,这样如果修改多个关联数据,会触发多次watch,性能很差,除非非常特殊的场景(比如要在事件冒泡的某个阶段同步处理数据),不然别用。
和computed、watchEffect的区别:三个工具各管一摊,别乱用
新手最容易犯的错误就是把这三个混着用,一会儿用watch做计算属性,一会儿用computed做监听逻辑,结果代码又乱又难维护,其实它们的分工非常明确:
watch vs computed
核心区别是有没有返回值,以及是否是“纯函数”。
- computed:是“计算属性”,必须有返回值,而且这个返回值也是响应式的;computed的回调必须是纯函数——也就是说,同样的输入永远产生同样的输出,而且不能修改外部的响应式数据(不是不能改,改了会出警告,而且逻辑会绕),它的定位是“基于现有响应式数据,派生出来一个新的响应式数据”,比如把firstName和lastName拼成fullName:
import { ref, computed } from 'vue' export default { setup() { const firstName = ref('张') const lastName = ref('三') const fullName = computed(() => { return firstName.value + lastName.value }) return { fullName } } } - watch:是“监听器”,可以没有返回值(大部分时候都没有),回调里可以修改外部数据、调用接口、操作DOM、存localStorage……想干啥干啥,它的定位是“当数据变化时,执行一段副作用逻辑”——刚才的localStorage、变色、调用接口都是副作用。
举个反例:用watch做fullName,虽然能做,但非常傻:
import { ref, watch } from 'vue'
export default {
setup() {
const firstName = ref('张')
const lastName = ref('三')
const fullName = ref('张三') // 还要手动初始化
watch([firstName, lastName], ([newFirst, newLast]) => {
fullName.value = newFirst + newLast // 手动更新
}, { immediate: true })
return { fullName }
}
}
对比一下computed的版本,多了两个步骤:手动初始化fullName,手动在watch里更新fullName,而且computed还有缓存——只要firstName和lastName不变,不管你在模板里用多少次fullName,都只会重新计算一次;但用watch的话,虽然fullName是ref,也不会有这个缓存吗?不,ref本身不会,除非你再加逻辑,但完全没必要,computed已经帮你做好了。
watch vs watchEffect
这两个都是处理副作用的,核心区别是是否需要手动指定被监听的源,以及是否能拿到旧值。
- watch:需要你显式告诉它要监听谁,而且能拿到新值和旧值;默认是“懒加载”的(除非加immediate),它的定位是“精准监听某几个数据的变化,只在这几个数据变的时候执行,而且需要知道变化前后的值做对比”,比如刚才的变色例子,需要知道旧值吗?不需要,但如果要做“当count从10变成20的时候弹个窗”,就必须用watch:
import { ref, watch } from 'vue' export default { setup() { const count = ref(0) watch(count, (newVal, oldVal) => { if (oldVal === 10 && newVal === 20) { alert('恭喜你,从10到20了!') } }) return { count } } } - watchEffect:不需要显式指定源,它会自动追踪回调里用到的所有响应式数据,只要其中一个变了,就会执行;它没有懒加载——创建后立即执行第一次;它拿不到旧值,它的定位是“快速处理一些依赖多个响应式数据的简单副作用,不需要对比新旧值”,比如刚才的localStorage例子,如果是存多个数据的组合,用watchEffect比用watch的数组写法更简单:
import { ref, watchEffect } from 'vue' export default { setup() { const count = ref(0) const username = ref('') // 自动追踪count和username,创建后立即执行 watchEffect(() => { localStorage.setItem('user-data', JSON.stringify({ count: count.value, name: username.value })) }) // 组件刚挂载时会自动读吗?不会,得自己在watchEffect里写 // 哦不对,watchEffect第一次执行就会触发,那可以把读的逻辑也放进去? // 等一下,放进去的话,count.value和username.value一变,又会读,覆盖掉新的?不行 // 所以读的逻辑还是单独放setup里 const saved = localStorage.getItem('user-data') if (saved) { const data = JSON.parse(saved) count.value = data.count || 0 username.value = data.name || '' } return { count, username } } }哦对,这里要注意watchEffect的顺序——刚才的代码里,先定义count和username,然后写watchEffect存,然后再读并赋值——没问题,因为赋值会触发count和username的变化,watchEffect会自动再存一次,覆盖掉第一次存的初始值(0和空字符串),但其实反过来也可以:先读并赋值,再写watchEffect,这样第一次存的就是正确的,不会有多余的一次存储,更优一点。
再举个反例:用watchEffect做刚才的弹10→20的窗,做不到!因为拿不到旧值。
Vue3 watch的避坑点全整理:新手90%的问题都在这里
刚才已经零零散散提了几个,现在系统整理一下,都是踩过坑的经验:
监听reactive定义的对象/数组时,旧值拿不到
刚才说过了,解决方案有两个:
- 如果只需要监听单个属性,用getter函数返回这个属性:
watch(() => reactiveObj.value, (newVal, oldVal) => { // 这里oldVal是正确的历史值 }) - 如果需要监听整个对象/数组的所有属性变化,而且必须拿到旧值,手动存一份快照:
import { ref, reactive, watch } from 'vue' export default { setup() { const reactiveObj = reactive({ value: 0 }) // 初始化快照 let oldObjSnapshot = JSON.parse(JSON.stringify(reactiveObj)) watch(reactiveObj, (newVal) => { // 用快照对比 if (oldObjSnapshot.value !== newVal.value) { console.log('value变了', newVal.value, oldObjSnapshot.value) } // 更新快照 oldObjSnapshot = JSON.parse(JSON.stringify(newVal)) }, { deep: true }) // 虽然reactive默认deep,但写出来更清晰 return { reactiveObj } } }注意:JSON.parse(JSON.stringify())有局限性,不能序列化函数、正则、Date(会变成字符串)、Symbol、循环引用的对象……如果你的数据有这些,得用lodash的cloneDeep,或者自己写深拷贝函数。
监听ref包裹的对象时,不加deep:true,只有引用变了才触发
刚才的例子里也有,解决方案有两个:
- 加deep:true
- 用getter函数返回要监听的属性或整个对象的某个部分
immediate第一次触发时,oldVal是undefined
这个一定要注意判断,不然容易出问题,比如parseInt(undefined)是NaN,或者拿undefined的属性报错。
监听数组时,用索引赋值或者修改length,Vue3不会触发(除非加deep:true)
哦,这个很重要!Vue3的响应式系统是基于Proxy的,和Vue2的Object.defineProperty不一样,但对数组的处理有个共同点:用索引直接赋值arr[0] = 1或者修改arr.length = 0,不会自动触发响应式更新——不过Vue3可以用deep:true解决,或者用数组的原生变异方法(push/pop/shift/unshift/splice/sort/reverse),或者用Vue3提供的set方法?不对,Vue3没有全局的Vue.set了,因为Proxy可以直接处理,但索引赋值和length修改确实不会触发,除非加deep:true监听整个数组。
import { ref, watch } from 'vue'
export default {
setup() {
const arr = ref([1,2,3])
// 默认不加deep:true
watch(arr, (newVal, oldVal) => {
console.log('arr变了', newVal, oldVal)
})
// 测试1:push,会触发
arr.value.push(4)
// 测试2:索引赋值,不会触发
arr.value[0] = 0
// 测试3:修改length,不会触发
arr.value.length = 2
// 测试4:整组替换,会触发
arr.value = [5,6,7]
return { arr }
}
}
如果要让索引赋值和修改length也触发,加deep:true就行:
watch(arr, (newVal, oldVal) => {
console.log('arr变了', newVal, oldVal)
}, { deep: true })
监听计算属性时,只有计算属性的依赖变了才会触发,不是每次访问
这个其实是computed的缓存机制带来的,但新手容易误解——比如你有个computed依赖count,你在模板里点击100次fullName的按钮,但count没变,那fullName不会重新计算,监听fullName的watch也不会触发,这是正常的,别以为是bug。
不要在watch的回调里修改被监听的源本身,会造成无限循环
import { ref, watch } from 'vue'
export default {
setup() {
const count = ref(0)
watch(count, (newVal) => {
count.value = newVal + 1 // 这里count变了,又触发watch,又加1,无限循环
})
return { count }
}
}
除非你有特殊的逻辑控制,比如加个判断:
watch(count, (newVal) => {
if (newVal < 10) {
count.value = newVal + 1
}
})
这样只会循环到10,没问题,但一般还是尽量避免在回调里修改被监听的源,逻辑会绕。
什么时候用watch?什么时候不用?
最后再总结一下,帮你快速决策:
- 用watch的场景:
- 需要知道数据变化前后的旧值做对比
- 需要精准监听某几个数据的变化,不想因为其他无关数据的变化触发逻辑
- 需要执行的副作用逻辑比较复杂,或者依赖的源变化频率低,不需要自动追踪
- 不用watch的场景:
- 只是想基于现有数据派生新的响应式数据——用computed
- 依赖的源多且杂,不需要对比新旧值,副作用逻辑简单——用watchEffect
- 只是想操作DOM——尽量用生命周期钩子(比如onMounted、onUpdated)或者flush: 'post'的watch/watchEffect,但优先用模板绑定class/style或者v-if/v-show,除非必须直接操作DOM
Vue3的watch其实非常灵活,只要搞懂了它的核心定位、参数选项、和其他两个工具的区别,以及避坑点,用起来就会非常顺手,能帮你解决很多响应式数据变化带来的问题。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



