Vue3里watch直接写ref变量名为什么没效果?watch ref的value到底要不要加?
身边最近有好几个刚从Vue2转过来的前端小伙伴问我类似的问题:“明明我把ref定义的响应式变量直接丢进watch里了,怎么改数据的时候一点反应都没有?是不是得加个.value才行?但有时候好像又不用加?”说实话,这两个问题确实是新手踩坑率最高的Vue3 watch知识点了,连我自己刚学的时候也绕了好几天才理清楚,今天就结合实际开发场景,把这事儿彻底掰扯明白,不仅会讲结论,还会挖挖背后Vue3响应式系统的小原理,让你不管以后遇到什么ref和watch的组合,都能稳准狠地写对。
先给你一个最干脆的结论:要不要加.value分两种核心情况
其实不用记一堆复杂的规则,就两种场景——你想监听的是“ref这个容器本身的替换”,还是“ref容器里存的具体值的变化”?把这两个核心需求分清楚,加不加.value的问题自然迎刃而解,举个最简单的例子:你有一个装苹果的塑料袋(ref容器),你是想看看“什么时候这个塑料袋被换成了装香蕉的”,还是“什么时候袋子里的苹果被吃掉或者换成橘子了”?对,就是这个意思。
只想看ref里具体值的变化?绝大多数新手的需求,watch第一个参数可以直接写变量名,不用加.value
这个场景是开发中占比90%以上的情况,比如你监听用户输入框的内容(用ref绑的v-model)、监听页面滚动的高度(用ref存的refTop值)、监听购物车数量的变化(用ref存的cartCount)——这些都是“想知道容器里的内容变了没”,不是“想换个容器”。
那为什么Vue2的时候不能这么写?Vue2里如果用data定义的变量,直接写进去watch就会监听;但Vue3里ref本质是一个只有一个value属性的普通对象啊!如果直接把这个对象传给watch,按道理说,Vue3的响应式应该只监听这个对象本身的引用变化才对——但为什么偏偏ref就可以直接监听value的变化呢?
这里就要提一下Vue3 watch的“小智能”了:当watch的第一个参数是ref对象本身(不是.value)的时候,Vue3的内部逻辑会自动把这个ref“解包”,去监听它内部的.value属性的变化,这是尤雨溪和Vue团队为了方便开发者做的一个语法糖(Syntactic Sugar),毕竟大部分时候大家都是关心值,不是关心容器本身。
那我们可以写一段代码测试一下这个场景是不是真的成立:
import { ref, watch } from 'vue'
// 定义一个装购物车数量的ref容器
const cartCount = ref(0)
// 直接把cartCount这个对象丢进watch,不加.value
watch(cartCount, (newVal, oldVal) => {
console.log('购物车数量变了!新数量是', newVal, '旧数量是', oldVal)
})
// 模拟用户点击“加入购物车”按钮,修改.value
cartCount.value++
// 这时候控制台应该会打印:购物车数量变了!新数量是 1 旧数量是 0
你看,是不是完全没问题?完全没有出现大家担心的“没效果”的情况,而且这里还有个小细节:watch回调函数里的newVal和oldVal,直接就是ref容器里的具体值,不是ref对象本身——这也是解包带来的好处,不用你再在回调里写newVal.value了,省了一步。
不过这里要注意一个例外:如果你的ref容器里存的是另一个对象或者数组,那直接写ref对象本身的话,默认只能监听“这个对象/数组的引用被完全替换”,不能监听“对象里的某个属性变了”或者“数组里的元素被添加/删除了”——这个时候就要用到watch的第三个参数options里的deep: true了,但这不是今天的重点,今天主要讲加不加.value的问题,这个deep的细节我们后面可以单独聊。
想看ref容器本身的替换?这个需求比较少见,但非常重要,必须加.value(哦不对,等一下,反过来?直接写ref对象本身会不会?)
等等等,这里刚才差点说反了!刚才场景一我们说“直接写ref对象本身是解包监听.value”,那如果我们想监听“这个ref对象被完全替换成另一个ref对象”,应该怎么做?
这个场景确实很少见,但也不是没有,比如你写了一个组件,有一个“数据源切换”的功能:初始用的是本地mock数据的ref,后来用户点击“切换到实时数据”,你就把这个ref直接替换成了从接口请求回来的新ref——这个时候你可能就想监听这个“容器被替换”的事件,做一些初始化的操作。
那这个时候,直接写ref对象本身肯定不行啊,因为Vue3会自动解包去监听里面的.value,那怎么办?这里有两个方法,但其中一个方法里会涉及到“主动解包然后监听?不对不对,是监听ref本身的引用,不希望它被自动解包”——哦对了!用watch的第一个参数传一个箭头函数**,返回这个ref对象本身,这样Vue3的自动解包机制就不会生效了,会直接监听你返回的这个值的引用变化!
等一下,刚才差点混淆了“加不加.value在第一个参数里”和“箭头函数返回什么”——我们还是用代码把这两种情况(想监听value变化和想监听ref本身变化)都对比写一遍,这样更清楚:
import { ref, watch } from 'vue'
// 初始用的本地mock数据ref
const mockDataSource = ref([
{ id: 1, name: '苹果', price: 5 },
{ id: 2, name: '香蕉', price: 3 }
])
// 第一个情况:直接写dataSource(这里dataSource初始是mockDataSource的引用)——自动解包监听.value
// 注意:这里我们先把dataSource赋值为mockDataSource
let dataSource = mockDataSource
// 监听具体值的变化(比如mock数据里的苹果价格变了)
watch(dataSource, (newVal, oldVal) => {
console.log('数据源的具体内容变了!')
console.log('新内容前2个:', newVal.slice(0, 2))
console.log('旧内容前2个:', oldVal.slice(0, 2))
})
// 第二个情况:用箭头函数返回dataSource这个变量本身——不自动解包,监听引用变化
watch(() => dataSource, (newVal, oldVal) => {
console.log('数据源的容器(引用)被完全替换了!')
console.log('新容器的id:', newVal.value[0]?.id) // 这里newVal和oldVal都是ref对象,所以要加.value
console.log('旧容器的id:', oldVal.value[0]?.id)
})
// 现在模拟两个操作,分别触发两个watch
// 操作一:修改mockDataSource的具体值——只会触发第一个watch
mockDataSource.value[0].price = 6
// 控制台应该先打印:数据源的具体内容变了!然后打印具体的新旧价格
// 操作二:切换到实时数据——把dataSource替换成新的ref对象——只会触发第二个watch
const realTimeDataSource = ref([
{ id: 101, name: '实时苹果', price: 5.8 },
{ id: 102, name: '实时香蕉', price: 3.2 }
])
dataSource = realTimeDataSource
// 控制台应该接着打印:数据源的容器(引用)被完全替换了!然后打印新的id
哦,对了!刚才差点忘了一个点:如果你的ref是直接在组件的setup顶层或者
code前端网