Vue3 怎么正确监听 ref 数组的变化?会不会漏监听或者触发太频繁?
最近有不少刚从 Vue2 转 Vue3 的同学,在群里问起监听 ref 数组的坑——有时候明明 push/splice 改了数组,watch 没反应;有时候加了 deep: true 又疯狂触发回调,连数组里某个对象的属性改了都弹一堆冗余日志;还有时候想用 computed 做个依赖 ref 数组的派生值,结果也卡壳,这些问题其实都绕不开 Vue3 对响应式数据的底层更新逻辑,以及 ref 和 reactive 处理数组的区别,今天咱们就把这块聊透,从基础原理到具体场景的解决方案,连踩过的坑都帮你提前踩一遍,看完肯定能顺顺当当用 watch 搞定 ref 数组的监听。
为什么有时候直接 push/splice ref 数组,watch 没反应?
先讲第一个大家遇到最多的问题:明明是 ref 包装的数组,用原生方法改了,watch 为什么不触发?
首先要回忆一下 Vue3 ref 的本质,ref 是把基本类型和引用类型都包装成一个有.value 属性的对象,对吧?对于基本类型(string、number、boolean),Vue3 是通过 Object.defineProperty 或者更底层的 Proxy?不对不对,等下,别搞混,reactive 才是全靠 Proxy,ref 对基本类型和引用类型的处理是不一样的:基本类型的 ref.value 是个普通值,Vue3 内部用了一个 Dep 对象来做依赖收集和更新通知;引用类型(比如数组、对象)的 ref.value,Vue3 会自动把它转成 reactive 代理对象,然后再存到 Dep 里。
那这和监听没反应有什么关系?
关键来了:你写 watch 监听的到底是「ref 这个对象本身的变化」,还是「ref.value 这个 reactive 代理对象内部的变化」?
如果只写了 watch(arrRef, () => {}),没有加任何配置,那默认 Vue3 只会监听 arrRef.value 的引用地址变化——也就是只有你给 arrRef 重新赋值(arrRef.value = [1,2,3])的时候,watch 才会触发;用 push/splice/shift/unshift/pop/reverse/sort 这些直接修改原数组的原生方法(这些方法会让 reactive 代理对象内部的数组发生变化,但引用地址没变),或者直接改 arrRef.value[0] 这种通过下标修改数组元素,默认的 watch 都不会触发。
哦对了,这里提一嘴 Vue2 的对比?可能转场的同学更有感觉:Vue2 里用 $set 或者直接替换下标对应的元素(比如用 splice 改单个元素),加上 immediate/deep 就能监听数组;但 Vue3 这里有个小误区,默认的 deep 其实对通过下标直接修改元素有没有用?等下咱们后面单独讲这个配置。
想监听 ref 数组的 push/splice 这类修改,最简单的方法是什么?
刚才说了默认情况的问题,那解决起来其实有好几种思路,咱们先说最常用、性能也还算ok的两种:
第一种:监听 ref.value 的 getter,加上 deep 或者直接监听 reactive 代理的内部变化?不对,直接给 watch 加上 deep: true 配置就行? 等下等下,先别急,先验证一下:比如咱们写个简单的例子:
import { ref, watch } from 'vue'
const listRef = ref([1, 2, 3])
// 第一种写法:默认只监听引用
watch(listRef, (newVal, oldVal) => {
console.log('默认监听:', newVal, oldVal)
})
// 第二种写法:加 deep: true
watch(listRef, (newVal, oldVal) => {
console.log('加 deep 监听:', newVal, oldVal)
}, { deep: true })
// 测试1:push
listRef.value.push(4)
// 测试2:直接替换引用
listRef.value = [5,6,7]
这时候你在浏览器控制台运行,会发现测试1的时候,只有「加 deep 监听」会打印;测试2的时候,两个都会打印。
那这里的 deep: true 到底做了什么?其实对于引用类型的 ref,加了 deep 之后,Vue3 会遍历整个 arrRef.value 的 reactive 代理对象,给每一层属性(如果是嵌套数组或对象的话)都做依赖收集——所以不管是直接修改数组的引用地址,还是用原生方法改数组内部,还是改数组里嵌套对象的属性,加了 deep: true 的 watch 都会触发。
但是问题来了:如果你的 ref 数组特别大,或者嵌套特别深(比如三层以上的对象数组),加 deep: true 会不会影响性能? 会的,因为每次遍历整个大数组做依赖收集和更新通知,都会消耗一定的内存和CPU资源,所以这时候咱们可以用第二种更精准的方法,避免遍历全数组。
第二种更精准的方法:监听 ref 数组的「响应式元素集合」,也就是 watch 的第一个参数传一个 getter 函数,返回 arrRef.value 的长度,或者返回 arrRef.value 本身的某个遍历结果?不对不对,有没有更直接的?哦对了!直接传 () => [...arrRef.value]?不对不对,等下,其实对于直接修改原数组的原生方法(push/splice/shift/unshift/pop/reverse/sort),这些方法在 Vue3 的 reactive 代理里是被重写过的——重写之后的方法,不仅会执行原有的数组操作,还会触发代理对象的内部更新通知,那有没有办法直接监听这种重写方法触发的更新,而不需要遍历全数组?
哦对了!直接给 watch 的第一个参数传 arrRef.value 就行?不对不对,刚才的例子里默认传 listRef 没加 deep 不行,但如果传的是 listRef.value 呢?
等下等下,这里我之前也踩过坑,再写个更细致的例子:
import { ref, watch, reactive } from 'vue'
const listRef = ref([1, 2, 3])
const listReactive = reactive([1,2,3])
// 写法1:默认传 ref 对象
watch(listRef, () => console.log('传ref对象'))
// 写法2:传ref.value的getter(直接写 arrRef.value 不行?哦对,要写函数返回)
watch(() => arrRef.value, () => console.log('传ref.value的getter'))
// 写法3:传ref.value的getter + deep
watch(() => arrRef.value, () => console.log('传ref.value的getter+deep'), { deep: true })
// 写法4:直接传 reactive 数组
watch(listReactive, () => console.log('传reactive数组'))
// 测试1:push
listRef.value.push(4)
listReactive.push(4)
// 测试2:直接改下标
listRef.value[0] = 99
listReactive[0] = 99
// 测试3:改嵌套对象(先改一下数组)
listRef.value = [{a:1}, {b:2}]
listReactive.push({c:3})
listRef.value[0].a = 999
listReactive[2].c = 999
现在你再运行这个例子,会发现很有意思的结果: 测试1的时候:写法2(传getter返回ref.value)、写法3、写法4(传reactive数组)都打印了;写法1(默认传ref对象)没打印。 测试2的时候:只有写法3和写法4打印了;写法1、2没打印。 测试3的时候:只有写法3和写法4打印了;写法1、2没打印。
哦!原来如此!这里的核心区别在于:
- 传 ref 对象本身:默认只监听 ref.value 的引用地址,引用变才触发;
- 传 getter 函数返回 arrRef.value:这时候 Vue3 会去追踪 arrRef.value 这个 reactive 代理对象的「数组长度变化」和「直接修改原数组的重写原生方法调用」——因为重写之后的方法,push,会同时修改数组的 length 属性,对吧?所以这种写法其实本质上是在监听「数组的 length 和内部元素的索引依赖(通过重写方法修改元素索引时触发的那种)」,但不会监听直接通过下标修改单个元素,也不会监听嵌套对象的属性变化;
- 不管是传 ref 对象加 deep,还是传 getter 加 deep,还是传 reactive 数组(因为 reactive 数组本身默认就是被深度追踪的),都会监听所有内部变化:引用地址、重写方法、直接下标修改、嵌套对象属性。
那这样的话,咱们针对不同的场景,就可以选不同的写法,来平衡精准度和性能:
针对不同场景,应该选哪种监听 ref 数组的方案?
刚才的三种核心写法(传ref加deep、传getter返回ref.value、传getter加deep),加上一种直接用 reactive 代替 ref 存数组的方案,咱们来逐个对应场景:
场景1:只需要监听「数组的长度变化」或者「有没有用 push/splice 这类方法增删改元素」——不需要监听直接下标修改单个元素,也不需要监听嵌套对象属性
这种场景其实最常见,比如做购物车的时候,只需要知道商品有没有被加购/删除,来更新总数量或者总价格的显示;再比如做分页列表的时候,只需要知道当前页的列表有没有重置,来滚动到顶部。
这时候最佳方案是传 getter 函数返回 arrRef.value,不加 deep:
import { ref, watch, computed } from 'vue'
const cartListRef = ref([])
// 用watch监听增删
watch(() => cartListRef.value, (newVal) => {
console.log('购物车商品数量或顺序变了!当前总数:', newVal.length)
// 这里可以调用同步到后端的接口,或者更新本地存储
})
// 用computed做派生总数量,其实computed本质也是一种监听依赖的响应式机制,这里刚好可以用
const totalCount = computed(() => cartListRef.value.length)
这种写法的性能是最好的,因为它不需要遍历整个数组的嵌套结构,只需要追踪数组的 length 和重写原生方法触发的更新通知,刚才的例子里 computed 其实可以直接写,不需要先 watch,但如果需要做一些副作用操作(比如调接口、存本地、弹提示),就必须用 watch。
哦对了,如果你的场景是「只需要监听数组的长度变化」,那更极致的写法是直接传 getter 函数返回 arrRef.value.length:
watch(() => cartListRef.value.length, (newLen, oldLen) => {
console.log(`购物车数量从 ${oldLen} 变成了 ${newLen}`)
if (newLen > oldLen) {
// 弹个加购成功的提示
}
})
这种写法连数组的顺序变化(reverse/sort)都不会触发,性能更优。
场景2:需要监听「直接通过下标修改单个元素」,但不需要监听嵌套对象属性
刚才的例子里,传 getter 返回 arrRef.value 不加 deep,或者传 ref 对象不加 deep,都不会触发直接下标修改的监听,对吧?那这时候怎么办?
首先要想清楚,直接通过下标修改数组元素(arrRef.value[2] = 'newVal'),在 Vue3 里会不会让 reactive 代理对象做依赖收集?答案是:不会——除非这个下标对应的元素之前已经被访问过了(比如在模板里渲染过,或者在 computed/watch 里遍历过),哦对,这里又有一个小细节!
比如咱们写个模板的例子:
<template>
<div v-for="(item, index) in listRef.value" :key="index">
{{ item }}
</div>
<button @click="changeByIndex">直接改下标2</button>
</template>
<script setup>
import { ref, watch } from 'vue'
const listRef = ref([1,2,3])
watch(() => listRef.value, () => console.log('触发了!'))
const changeByIndex = () => {
listRef.value[2] = 99
}
</script>
这时候你点击按钮,会发现 watch 居然打印了!为什么?因为模板里的 v-for 遍历了 listRef.value,并且用 index 作为 key(虽然不建议用 index 做 key,但这里只是演示依赖收集)——遍历的时候,Vue3 会访问 listRef.value[0]、listRef.value[1]、listRef.value[2],这时候这些下标对应的索引就被加入到 reactive 代理的依赖收集里了,所以直接修改已经访问过的下标对应的元素,就会触发依赖该代理对象的 watch(只要传了 getter 返回 arrRef.value)。
但如果是访问过的下标之外的呢?listRef.value 只有 [1,2,3],你直接改 listRef.value[5] = 999,这时候模板里没有渲染过 index 5,所以依赖收集里没有这个索引,watch 就不会触发。
那如果不管下标有没有被访问过,都要监听直接下标修改的元素呢?这时候最佳方案是用 reactive 代替 ref 存数组,或者传 getter 返回 arrRef.value 加 deep: true——不过用 reactive 代替的话,性能和写法更自然一点,因为 reactive 本来就是设计用来存引用类型的:
import { reactive, watch } from 'vue'
const listReactive = reactive([1,2,3])
watch(listReactive, (newVal) => {
console.log('不管怎么改(引用、增删、下标、嵌套)都触发!')
})
// 或者用 ref 加 deep
const listRef = ref([1,2,3])
watch(() => listRef.value, (newVal) => {
console.log('用ref加deep也触发!')
}, { deep: true })
不过这里要注意,用 reactive 存数组有个小坑:如果直接给 reactive 数组重新赋值(listReactive = [4,5,6]),那这个数组就会失去响应式——因为你把原来的 reactive 代理对象给替换成了普通数组!所以如果要替换整个 reactive 数组,应该用 Object.assign(listReactive, [4,5,6]) 或者 listReactive.splice(0, listReactive.length, ...[4,5,6]);而用 ref 存数组的话,直接替换 arrRef.value = [4,5,6] 就没问题,因为替换的是 ref 对象的 value 属性,而 ref 对象本身没有变,依赖收集还是指向 ref 内部的 Dep。
场景3:需要监听「数组里嵌套对象的属性变化」
这种场景也很常见,比如做表格编辑的时候,用户直接修改表格里某一行的某个字段(比如姓名、年龄),这时候需要实时更新总人数里的平均年龄,或者把修改后的行数据同步到后端。
这时候必须用 deep: true——不管是传 ref 对象加 deep,还是传 getter 返回 arrRef.value 加 deep,还是传 reactive 数组(因为 reactive 数组默认深度追踪):
import { ref, watch, computed } from 'vue'
const userListRef = ref([
{ id: 1, name: '张三', age: 25 },
{ id: 2, name: '李四', age: 30 }
])
// 传 ref 对象加 deep
watch(userListRef, (newVal, oldVal) => {
console.log('用户列表发生了任何变化(引用、增删、下标、嵌套属性)!')
// 这里可以遍历修改后的行,把变化同步到后端
// 不过这里有个坑:newVal 和 oldVal 的引用地址是一样的!因为 deep 监听的是内部变化,没有替换整个数组
// 所以如果你需要对比变化前后的嵌套对象属性,不能直接用 JSON.parse(JSON.stringify()) 深拷贝 oldVal 来存,因为在 watch 的回调里,oldVal 已经是新的值了!
// 那怎么存变化前的快照?后面咱们单独讲这个问题
}, { deep: true })
// 用 computed 做平均年龄的派生值,刚好可以自动追踪嵌套属性的变化
const averageAge = computed(() => {
const total = userListRef.value.reduce((sum, user) => sum + user.age, 0)
return userListRef.value.length ? (total / userListRef.value.length).toFixed(1) : 0
})
刚才提到了一个 deep 监听的大问题:newVal 和 oldVal 的引用地址是一样的——不管是直接修改原数组,还是修改嵌套对象的属性,在 watch 的回调里,newVal 和 oldVal 都是指向同一个 reactive 代理对象(或者同一个普通引用类型对象,如果你之前替换过的话),所以你没法直接对比变化前后的内容,甚至连 console.log(oldVal) 都会显示新的内容(因为 console.log 是异步打印引用的,等打印的时候数组已经变了)。
那怎么解决这个问题?怎么获取 deep 监听前的数组快照?
怎么获取 deep 监听 ref 数组时的「变化前快照」?
解决这个问题有两种常用的方法,一种是用 immediate: true 配合一个变量手动存快照,另一种是用 watchEffect 配合 onTrack 和 onTrigger?不对不对,watchEffect 不太适合存快照,还是第一种和第三种(用 lodash 的 cloneDeep 配合手动存?哦对,第一种就是手动存,第三种是用 VueUse 库的 usePrevious 钩子——不过可能很多新手不想装第三方库,所以咱们先讲原生方法,再提一下 VueUse 的便捷方案)。
原生方法:用 immediate: true 配合一个响应式/非响应式变量存快照
核心思路是:在 watch 初始化的时候(加 immediate: true),把当前的 arrRef.value 深拷贝一份存到一个变量里;每次 watch 回调触发的时候,先把这个变量作为 oldVal 使用,然后再把当前的 arrRef.value 深拷贝一份更新到这个变量里。
这里要注意深拷贝的选择:
- 如果数组里的嵌套对象没有循环引用,也没有函数、Date、RegExp 等特殊类型,那可以用
JSON.parse(JSON.stringify())做深拷贝,性能还不错; - 如果有特殊类型或者循环引用,那最好自己写个简单的深拷贝函数,或者用 lodash 的 cloneDeep。
先看一个用 JSON.parse(JSON.stringify()) 的例子:
import { ref, watch } from 'vue'
const userListRef = ref([
{ id: 1, name: '张三', age: 25 },
{ id: 2, name: '李四', age: 30 }
])
// 用非响应式变量存快照,因为不需要把快照渲染到模板里
let oldUserListSnapshot = null
// 加 immediate: true,初始化的时候先存一份
watch(userListRef, (newVal) => {
// 先打印 oldUserListSnapshot,这时候还是上一次的
console.log('变化前:', oldUserListSnapshot)
console.log('变化后:', newVal)
// 然后更新快照
oldUserListSnapshot = JSON.parse(JSON.stringify(newVal))
}, { deep: true, immediate: true })
// 测试一下:修改张三的年龄
userListRef.value[0].age = 26
这时候你运行,会发现控制台打印的「变化前」是 age=25 的,「变化后」是 age=26 的——完美解决了引用地址一样的问题!
如果有特殊类型,Date:
const userListRef = ref([
{ id: 1, name: '张三', birthday: new Date('1998-01-01') }
])
let oldUserListSnapshot = null
// 自己写个简单的深拷贝函数,支持 Date、普通对象、普通数组
function simpleDeepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj
if (obj instanceof Date) return new Date(obj.getTime())
if (obj instanceof Array) return obj.map(item => simpleDeepClone(item))
if (obj instanceof Object) {
const clonedObj = {}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = simpleDeepClone(obj[key])
}
}
return clonedObj
}
}
watch(userListRef, (newVal) => {
console.log('变化前生日:', oldUserListSnapshot[0].birthday)
console.log('变化后生日:', newVal[0].birthday)
oldUserListSnapshot = simpleDeepClone(newVal)
}, { deep: true, immediate: true })
// 测试修改生日
userListRef.value[0].birthday = new Date('1999-02-02')
这样也没问题!
第三方库方法:用 VueUse 的 usePrevious 钩子
如果你项目里已经装了 VueUse(这是一个非常好用的 Vue3 工具库,强烈推荐),那可以直接用 usePrevious 钩子,一行代码就能解决问题:
import { ref, watch } from 'vue'
import { usePrevious } from '@vueuse/core'
const userListRef = ref([
{ id: 1, name: '张三', age: 25 }
])
// 直接用 usePrevious 获取变化前的快照
const previousUserList = usePrevious(userListRef, { deep: true })
watch(userListRef, (newVal) => {
console.log('变化前:', previousUserList.value)
console.log('变化后:', newVal)
}, { deep: true })
// 测试修改
userListRef.value[0].age = 26
这里要注意给 usePrevious 也加上 deep: true 配置,否则它只会监听引用地址变化,不会存内部变化的快照。
还有没有其他监听 ref 数组的方法?watchEffect?
刚才提到了 watchEffect,那 watchEffect 能不能用来监听 ref 数组?答案是可以,但不太适合所有场景。
watchEffect 的核心特点是:自动追踪回调函数里用到的所有响应式数据,不需要手动指定监听目标,也不需要加 deep(只要在回调里遍历了嵌套结构,就会自动深度追踪),也没有 oldVal。
比如咱们用 watchEffect 来做购物车的总数量同步到本地存储:
import { ref, watchEffect } from 'vue'
const cartListRef = ref([])
// 自动追踪 cartListRef.value 的所有变化(引用、增删、下标、嵌套属性)
watchEffect(() => {
// 只要在回调里用到了 cartListRef.value,不管怎么改,都会触发
localStorage.setItem('cartList', JSON.stringify(cartListRef.value))
})
这种写法比 watch 简洁,对吧?但它的缺点也很明显:没有 oldVal,没法对比变化前后的内容;而且初始化的时候会自动执行一次(相当于 watch 加了 immediate: true)——如果你的副作用操作不需要初始化执行,或者需要对比 oldVal,那还是用 watch 更合适。
如果你只想在 watchEffect 里追踪 cartListRef.value 的长度或者增删,不想追踪嵌套对象的属性,那可以在回调里只访问 cartListRef.value.length,或者只遍历 cartListRef.value 的 id:
// 只追踪长度变化
watchEffect(() => {
const len = cartListRef.value.length
console.log('购物车数量变了:', len)
})
// 只追踪增删(通过访问所有元素的 id,但不访问其他嵌套属性)
watchEffect(() => {
cartListRef.value.forEach(item => console.log(item.id))
console.log('购物车增删了商品或者商品的 id 变了')
})
这样也能实现精准监听,性能也不错。
Vue3 监听 ref 数组的最佳实践
说了这么多,咱们来整理一下一个清晰的「最佳实践流程图」,方便你以后直接套用:
- 先想清楚你要监听什么:
a. 只监听引用地址变化:直接传 ref 对象,不加任何配置;
b. 只监听长度变化:传 getter 函数返回
arrRef.value.length; c. 只监听增删改元素的重写原生方法(push/splice/shift/unshift/pop/reverse/sort):传 getter 函数返回arrRef.value,不加 deep; d. 要监听直接下标修改单个元素或者嵌套对象的属性变化: i. 如果不需要替换整个数组的引用:用 reactive 代替 ref 存数组,直接传 reactive 数组; ii. 如果需要替换整个数组的引用:传 ref 对象加 deep: true,或者传 getter 返回 arrRef.value 加 deep: true; - 再想清楚你要不要对比变化前后的内容: a. 不需要:直接用对应的 watch 写法,或者 watchEffect; b. 需要: i. 用原生方法:加 immediate: true,配合手动深拷贝存快照; ii. 用 VueUse:直接用 usePrevious 钩子加 deep: true;
- 最后再想清楚你要不要初始化执行副作用: a. 不需要:watch 不加 immediate: true; b. 需要:watch 加 immediate: true,或者直接用 watchEffect。
还有几个小细节要注意:
- 尽量不要用 index 作为 v-for 的 key,因为如果数组顺序变化,会导致 Vue3 重新渲染整个列表,不仅影响性能,还可能导致表单输入框的内容错位;
- ref 数组特别大,嵌套特别深,尽量避免用 deep: true,而是用更精准的监听方法(比如监听长度、监听某个特定的属性变化、或者用 computed 先过滤出需要监听的部分再 watch);
- 深拷贝的时候要注意特殊类型和循环引用,不要随便用
JSON.parse(JSON.stringify()),以免出现数据丢失的问题。
好了,今天关于 Vue3 监听 ref 数组的所有内容就聊到这里了——从底层原理到具体场景的解决方案,从踩过的坑到最佳实践,应该都覆盖到了,如果你还有其他问题,欢迎在评论区留言讨论!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



