Vue3 watch怎么用才能精准监听ref对象,会不会踩坑?
为什么Vue3里watch ref对象和监听普通值会有区别?
你有没有试过刚学Vue3 Composition API时,直接用watch套住一个ref包裹的普通对象,结果修改里面的属性却没触发回调?这不是代码写错了,是ref和watch的底层机制在“各司其职”。
先说说ref的作用:它是把JavaScript基本类型(比如number、string、boolean、null、undefined)和复杂类型(数组、对象)都变成响应式的通用API,对于基本类型,ref会直接把值存到内部的.value属性上,并用Vue的响应式系统包裹这个属性的getter和setter;但复杂类型不一样,ref的.value存的其实是对象的内存地址引用,就算修改了对象里的某个键值,内存地址本身并没有变——这时候如果watch没开启深度监听,只会去盯.value这个引用地址有没有换,自然就没反应了。
Vue3 watch监听ref对象的基础写法有哪几种?
知道了底层逻辑,再来看实际能用的写法,主要有三种场景对应三种常用方式,覆盖你日常开发的90%需求:
第一种:监听整个ref对象的值(引用地址变更)
这种写法最简单,就是直接把ref变量作为watch的第一个参数,什么时候用?比如你要重置整个购物车对象,或者从接口拉了新的完整数据替换旧的,这时候.value的引用地址变了,回调就能触发。
举个例子,假设你有一个购物车的ref对象:
import { ref, watch } from 'vue'
const cart = ref({
total: 0,
items: []
})
// 直接监听cart,只在cart.value被重新赋值时触发
watch(cart, (newCart, oldCart) => {
console.log('购物车被重置了!')
console.log('新购物车:', newCart)
console.log('旧购物车:', oldCart)
})
// 测试1:修改内部属性——不会触发
cart.value.total = 100
// 测试2:重新赋值整个对象——会触发
cart.value = { total: 200, items: [{ id: 1, name: 'Vue3入门书' }] }
这里注意一个细节:当你重新赋值整个ref对象时,oldCart其实是旧的引用地址对应的对象吗?不完全是——如果开启了immediate或者深度监听之外的情况,旧值的保留有个小前提:Vue3的watch默认是浅监听且惰性的,浅监听时对于复杂类型的old值,只有当引用地址真的换了,才会拿到上一次赋值的那个对象的快照吗?不对,其实没有快照,就是上一次.value存的那个引用对象本身,所以如果你在赋值之后还不小心修改了oldCart引用的对象,那它也会跟着变——不过一般重新赋值之后,旧引用对象如果没有其他地方用,Vue会帮你垃圾回收,不用太担心。
第二种:监听ref对象的内部属性(单个或多个)
如果只需要盯紧对象里的某几个特定属性,比如购物车的total或者某个商品的quantity,直接监听整个对象再开深度监听会浪费性能——毕竟其他属性变的时候也会触发没必要的回调,这时候可以用函数返回值的方式,精准定位要监听的属性。
单个属性的写法:
import { ref, watch } from 'vue'
const cart = ref({
total: 0,
items: [{ id: 1, name: 'Vue3入门书', quantity: 1 }, { id: 2, name: '键盘', quantity: 2 }]
})
// 监听cart.value.total,这个是基本类型,不用深度,内存地址(或者说值本身)变就触发
watch(() => cart.value.total, (newTotal, oldTotal) => {
console.log('购物车总金额变了!')
console.log('从', oldTotal, '变成', newTotal)
})
// 测试:修改total——会触发
cart.value.total = 999
// 测试:修改items里的quantity——不会触发
cart.value.items[0].quantity = 3
多个属性的写法也很简单,把第一个参数换成数组,数组里放多个返回要监听属性的函数就行:
// 同时监听total和第一个商品的quantity
watch([() => cart.value.total, () => cart.value.items[0].quantity], ([newTotal, newQty], [oldTotal, oldQty]) => {
console.log('总金额或第一个商品数量变了!')
console.log('总金额变化:', oldTotal, '→', newTotal)
console.log('第一个商品数量变化:', oldQty, '→', newQty)
})
// 测试:修改第一个商品的quantity——会触发,而且只输出和它相关的旧值新值
cart.value.items[0].quantity = 5
这种写法不仅省性能,old值也能准确拿到你监听的那个属性的上一次状态,不管是基本类型还是包裹在ref数组里的对象属性。
第三种:监听ref对象的所有变化(深度监听)
如果确实需要监听对象里的任何变动,不管是新增属性、删除属性,还是修改嵌套很深的数组或子对象的属性,那就得开启深度监听了——在watch的第三个参数(配置对象)里加deep: true。
比如这个带深层嵌套的商品分类ref对象:
import { ref, watch } from 'vue'
const categories = ref({
electronics: {
phones: [{ brand: 'Apple', models: ['iPhone 15', 'iPhone 15 Pro'] }],
laptops: []
},
books: []
})
// 开启deep: true,监听categories的所有变化
watch(categories, (newCats, oldCats) => {
console.log('商品分类有变动!')
// 注意!深度监听时,旧值是不准确的!
console.log('新分类(可能包含嵌套变化):', newCats)
console.log('旧分类(其实和新分类是同一个引用,嵌套部分已经变了):', oldCats)
}, {
deep: true,
immediate: false // 默认就是false,这里写出来是为了说明可以加多个配置
})
// 测试1:新增顶级属性——会触发
categories.value.clothes = []
// 测试2:修改嵌套很深的数组元素——会触发
categories.value.electronics.phones[0].models.push('iPhone 16')
// 测试3:删除顶级属性——Vue3支持对响应式对象的delete操作,会触发
delete categories.value.books
这里一定要注意一个超级容易踩的坑:深度监听ref复杂类型时,oldValue是不准确的!因为Vue3为了性能考虑,不会在每次嵌套属性变化时,去深拷贝整个旧的ref对象存下来——新旧值都是同一个内存地址的引用,所以当你在回调里打印oldCats时,会发现它的嵌套部分已经和newCats一模一样了。
那如果确实需要拿到深度监听时的旧值怎么办?有两个办法,第一个是提前在watch外保存一份深拷贝的快照,但要注意快照的时机;第二个是用Vue3.4+新增的watchEffect结合computed的缓存,或者第三方工具库(比如lodash-es的cloneDeep,但最好用Vue官方推荐的轻量深拷贝?不对,Vue3自己好像没有内置通用深拷贝,但VueUse的useCloned或者useDebounceFn配合深拷贝倒是常用),不过一般情况下,如果不是必须对比整个旧对象,还是尽量用第二种方法(精准监听单个/多个属性)来获取准确的old值。
Vue3 watch监听ref数组和监听ref普通对象有什么不一样?
其实本质上是一样的——数组也是JavaScript的复杂类型,ref存的也是内存地址引用,所以监听ref数组的基础场景和普通对象完全对应:
- 直接监听数组变量,只在
arr.value = [...]这种重新赋值时触发; - 用函数返回单个元素、某个索引的元素、数组的
length属性,精准监听; - 开启
deep: true,监听push、pop、splice、shift、unshift这些修改原数组的方法,或者直接修改某个索引的元素,或者嵌套数组/对象的变化。
不过有个小细节是Vue3对数组的响应式处理优化得更好了——在Vue2里,直接修改数组的索引(比如arr[0] = 123)或者修改数组的length(比如arr.length = 0)是不会触发响应式的,必须用Vue.set或者splice;但在Vue3里,不管是普通对象还是数组,直接修改索引、修改length、新增/删除顶级属性,都是完全支持响应式的——前提是你用了ref或者reactive包裹。
举个监听ref数组的例子:
import { ref, watch } from 'vue'
const todoList = ref([
{ id: 1, content: '写Vue3文章', done: false },
{ id: 2, content: '遛狗', done: true }
])
// 精准监听数组的length属性
watch(() => todoList.value.length, (newLen, oldLen) => {
console.log('待办事项数量变了!')
console.log('从', oldLen, '条变成', newLen, '条')
})
// 精准监听第一个待办的done状态
watch(() => todoList.value[0].done, (newDone) => {
if (newDone) {
console.log('第一个待办完成啦!')
}
})
// 深度监听整个数组
watch(todoList, () => {
console.log('待办列表有变动(可能是内容、状态、排序、增删)')
}, { deep: true })
// 测试1:直接修改索引0的done——会触发精准监听和深度监听
todoList.value[0].done = true
// 测试2:直接修改length——会触发精准监听length和深度监听
todoList.value.length = 1
// 测试3:push新元素——会触发length和深度监听
todoList.value.push({ id: 3, content: '吃饭', done: false })
// 测试4:splice删除第一个元素——会触发length和深度监听
todoList.value.splice(0, 1)
可以看到,所有修改都能正常触发对应的watch,这比Vue2方便太多了。
Vue3 watch监听ref对象时,还有哪些实用的配置项?
除了刚才提到的deep,watch的第三个配置对象还有几个常用的,能帮你解决更多场景问题:
immediate:立即执行一次回调
默认情况下,watch是惰性的——只有当监听的源发生变化时才会触发回调,但有时候我们需要在组件初始化或者数据刚定义好的时候,就执行一次watch的逻辑,比如从localStorage里读取初始的购物车数据,或者刚从接口拉到商品列表就计算一次总金额。
这时候加immediate: true就行:
import { ref, watch } from 'vue'
// 假设localStorage里已经存了cart的JSON字符串
const savedCart = localStorage.getItem('my-cart')
const cart = ref(savedCart ? JSON.parse(savedCart) : { total: 0, items: [] })
// 加immediate: true,初始化时就执行一次,把旧值设为undefined
watch(cart, (newCart) => {
// 每次cart有变化(包括初始化),都存到localStorage里
localStorage.setItem('my-cart', JSON.stringify(newCart))
}, {
deep: true, // 存整个对象,需要深度监听所有变化
immediate: true
})
这里还要注意immediate: true时的old值:初始化第一次执行回调时,old值是undefined,因为之前还没有过任何监听源的状态。
flush:控制回调的执行时机
默认情况下,watch的回调是在Vue的DOM更新之前执行的——这时候你在回调里访问DOM元素,拿到的还是旧的DOM状态,但有时候我们需要等DOM更新完之后再执行回调,比如修改了商品列表的高度,需要拿到新的高度来做滚动定位。
这时候就可以用flush配置项,它有三个可选值:
'pre'(默认):DOM更新前执行;'post':DOM更新后执行;'sync':同步执行(一般不推荐用,会影响性能,除非是非常特殊的同步操作场景)。
举个flush: 'post'的例子:
import { ref, watch, nextTick } from 'vue'
const todoList = ref([{ id: 1, content: '写文章', done: false }])
const todoListRef = ref(null) // 绑定到ul的ref属性上
// 不用flush的话,用nextTick也能实现,但flush: 'post'更简洁
watch(todoList, () => {
// todoListRef.value是ul元素,scrollHeight是ul的总高度(包括超出可视区域的部分)
// 因为flush是post,所以这里拿到的是新增元素后的新高度
console.log('todoList的新高度:', todoListRef.value?.scrollHeight)
// 自动滚动到底部
if (todoListRef.value) {
todoListRef.value.scrollTop = todoListRef.value.scrollHeight
}
}, {
deep: true,
flush: 'post'
})
// 测试:新增一个todo,触发watch
todoList.value.push({ id: 2, content: '遛狗', done: true })
不用flush: 'post'的话,你得在回调里包一层nextTick,功能是一样的,但flush配置项看起来更统一,代码也少写一行。
onTrack和onTrigger:调试watch的触发过程
这两个配置项主要是用来调试的,当你不知道为什么watch没触发,或者触发的时机不对的时候,可以加上它们来看看监听源的getter(onTrack)和setter(onTrigger)被调用的情况。
举个简单的调试例子:
import { ref, watch } from 'vue'
const count = ref(0)
const doubleCount = ref(0)
watch(count, (newCount) => {
doubleCount.value = newCount * 2
}, {
// 调试:当count的getter被调用时(比如在模板里用了count,或者watch自己追踪的时候)
onTrack(e) {
console.log('count的getter被调用了!', e)
},
// 调试:当count的setter被调用时(修改count.value的时候)
onTrigger(e) {
console.log('count的setter被调用了!', e)
}
})
// 测试:修改count
count.value = 10
onTrack和onTrigger的回调参数e是一个调试事件对象,里面包含了很多有用的信息,比如触发的类型(get/set)、触发的组件、触发的源对象和属性名等等,能帮你快速定位问题。
Vue3 watch监听ref对象和reactive对象,该怎么选?
很多刚学Vue3的同学都会纠结:到底什么时候用ref包裹对象,什么时候用reactive?其实只要记住这几点,就能轻松做出选择:
包裹对象的话,ref和reactive底层是相通的
当你用ref包裹一个复杂类型时,Vue3内部其实会自动把这个复杂类型转成reactive对象——也就是说,cart.value其实就是一个reactive对象,所以修改cart.value的属性和修改reactive(cartObj)的属性是一样的响应式效果。
ref和reactive的区别主要在使用方式和适用场景
- 使用方式:ref不管是基本类型还是复杂类型,都要加
.value(在模板里不用加,Vue会自动解包);reactive只有复杂类型能用,不用加.value。 - 适用场景:
- 如果你要定义一个可能会被重新赋值整个对象/数组的响应式数据,用
ref——因为reactive对象如果被重新赋值,会丢失响应式(比如let state = reactive({a:1}),然后state = {b:2},这时候新的state不是响应式的)。 - 如果你要定义一个不会被重新赋值整个对象,而且属性之间有一定关联的响应式数据集合(比如表单数据、用户信息),用
reactive——不用加.value,写起来更顺手,逻辑也更清晰。
- 如果你要定义一个可能会被重新赋值整个对象/数组的响应式数据,用
那回到watch监听的问题上:不管你选ref还是reactive,watch的核心逻辑都是一样的——盯紧引用地址(浅监听)或者内部变化(深监听),或者精准监听单个/多个属性,只是写法上有点小区别:
- 监听reactive对象的内部属性,不用加
.value,直接用函数返回state.a就行; - 监听整个reactive对象,默认就是深度监听的——因为reactive内部本身就是一个Proxy,对所有属性的操作都能被捕获,但这时候同样存在
oldValue不准确的问题,而且也不能用重新赋值的方式来触发监听(除非你把整个reactive对象再包一层ref,那就又回到ref包裹对象的情况了)。
Vue3 watch监听ref对象的最佳实践
说了这么多,最后给大家整理几个日常开发中watch监听ref对象的最佳实践,能帮你少踩坑、写好代码:
- 优先用精准监听(函数返回单个/多个属性):性能最好,old值准确,避免不必要的回调。
- 只有在必须监听所有变化时才用deep: true:注意这时候old值不准确,尽量不要在回调里对比新旧对象的嵌套部分。
- 重新赋值整个对象/数组时,直接监听ref变量:不用开deep,简单高效。
- 用immediate: true处理初始化逻辑:比如从localStorage读取数据、初始化计算结果。
- 用flush: 'post'处理DOM相关的逻辑:比nextTick更简洁,代码更统一。
- 根据数据的使用场景选择ref还是reactive:可能重新赋值的用ref,固定结构不用重新赋值的用reactive。
只要掌握了这些最佳实践,Vue3 watch监听ref对象对你来说就不是难事了——剩下的就是多写代码,多踩几个小坑(踩坑印象才深嘛),慢慢就能熟练运用了。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


