Code前端首页关于Code前端联系我们

Vue3 watch的getter参数到底怎么用?和直接传函数有啥不一样?

terry 45分钟前 阅读数 17 #Vue

最近在社群刷到不少刚转Vue3的同学提问:明明watch可以直接传ref/reactive对象或者普通回调函数的监听函数,为啥还单独多了个“getter参数”的用法?会不会是多此一举?刚开始我学Vue3的时候也有过这个疑惑,啃了不少官方文档,踩过一两次直接传对象监听的坑,现在终于摸透它的门道了,今天咱们就好好聊透这个问题,把它的用法、优势、注意事项、对比场景都说清楚,看完你肯定能在项目里用对地方。

getWatcher参数是什么?先从基础定义捋顺

可能有些刚入门的同学,连watch的完整传参结构都没记熟,先快速回忆下Vue3官方给的watch最常用的两种基础传参: 第一种是直接监听源(单个源/多个源数组),比如watch(refA, (new, old) => {})或者watch([refA, reactiveB.count], (newArr, oldArr) => {}); 第二种就是今天要重点说的getter函数作为源,结构是watch(() => { /* 在这里写依赖收集的逻辑,返回一个要监听的最终值 */ }, (newVal, oldVal) => {})

这里的getter参数,本质上就是一个“依赖收集器”——Vue3的响应式核心是Proxy,但watch监听源的时候,依赖不会自动关联到整个Proxy对象或者数组内部的嵌套值,除非你显式触发getter,这个显式触发的最佳方式,就是单独写一个返回特定值的getter函数。

举个最直观的例子你就懂:假设你有一个reactive对象user,里面有嵌套的address对象,现在只监听user.address.city这一个字段,如果直接传user.address,那监听的是整个address对象的引用,只有当整个address被重新赋值(比如user.address = { city: '上海' })才会触发回调,但如果只是user.address.city = '上海',引用没变,回调就不会动——这就是很多人常遇到的“watch监听嵌套值没反应”的坑。

这时候getter参数就派上用场了:你可以写watch(() => user.address.city, (newCity, oldCity) => { console.log(newCity) }),这时候只要city字段的值被改,不管address有没有被重新赋值,回调都会立刻触发,因为getter函数里显式访问了user.address.city,Vue3的响应式系统就能精准捕捉到这个具体的依赖。

什么时候必须用getter参数?别等踩坑才后悔

刚才的嵌套值场景是最常见的,但不是唯一的,我总结了三个必须用getter参数才能实现功能的高频场景,别想着用其他方式凑,其他方式要么有性能问题,要么写出来的代码又臭又长:

监听reactive对象的单个嵌套属性或者多个属性的组合结果

刚才已经讲了单个嵌套属性的例子,现在说组合结果的情况:比如你要监听user.name和user.age的组合——当名字是“小明”且年龄满18岁的时候触发回调,这时候用直接传源数组的方式也可以,但回调里要手动判断条件,代码会多一步;但用getter参数的话,你可以直接返回一个布尔值或者组合对象,只有当这个返回值变化的时候才触发,既精准又省事儿。

比如组合布尔值的写法:

import { reactive, watch } from 'vue'
const user = reactive({
  name: '小明',
  age: 17
})
// 直接传源数组+手动判断的写法
watch([() => user.name, () => user.age], ([newName, newAge]) => {
  if (newName === '小明' && newAge >= 18) {
    console.log('小明成年了!')
  }
})
// 用getter参数的写法,更简洁精准,只有组合结果变化才触发
watch(() => user.name === '小明' && user.age >= 18, (canVote) => {
  if (canVote) {
    console.log('小明成年了,可以投票啦!')
  }
})

你看,第二种写法是不是明显更舒服?而且第一种写法里,不管是name改成小红(但组合布尔值还是false),还是age从16改成17(组合布尔值还是false),都会触发回调,虽然里面的判断会过滤,但还是多了两次没必要的函数调用;第二种写法只有组合结果从false变成true(或者反过来)的时候才会触发,性能上也更好一点点——当然这种小性能差异在大多数项目里可以忽略,但养成写精准代码的习惯总没错。

监听reactive数组的某个具体元素的属性或者数组的计算属性值(比如长度、筛选结果)

先看具体元素属性的例子:假设你有一个todoList数组,里面每个元素是reactive对象,现在只监听第一个todo的isCompleted属性,这时候直接传todoList[0].isCompleted是不行的——因为todoList是响应式数组,但todoList[0]是Proxy对象,todoList[0].isCompleted是普通值,Vue3没法直接把这个普通值作为稳定的监听源(如果数组被重新排序或者删除了第一个元素,源就失效了);直接传todoList又会监听整个数组的所有变化(包括push、pop、排序、每个元素的所有属性修改),性能太差,回调里还要手动判断是不是第一个元素的isCompleted变了。

这时候必须用getter参数:watch(() => todoList[0]?.isCompleted, (newVal, oldVal) => {}),这里加个可选链是防止第一个元素不存在时报错。

再看数组计算属性值的例子:比如监听todoList中未完成任务的数量,用getter参数的话,直接返回todoList.filter(todo => !todo.isCompleted).length就可以了,只有未完成数量变化才触发,非常精准。

监听非响应式变量通过某种方式转换后的响应式结果

这个场景可能稍微进阶一点,但也挺有用的:比如你有一个从localStorage里读取的非响应式初始theme值,然后把它赋值给一个响应式的ref,但你想要同时监听localStorage里theme的变化?不对不对,localStorage不是响应式的,Vue3的watch没法直接监听它,哦,换个更准确的例子:比如你有一个非响应式的配置对象config,里面有个url字段,现在要把这个url和响应式的refId组合成一个请求接口,然后监听这个组合接口的变化,触发数据请求,这时候直接传refId可以,但如果config.url是动态修改的(虽然config不是响应式的,但比如通过setInterval或者其他事件修改了),那接口变了也不会触发请求——不过这种情况下最好还是把config改成响应式的,但如果不能改(比如config是第三方库传过来的),那getter参数也可以解决吗?不对不对,如果config不是响应式的,即使写在getter里,Vue3也没法捕捉到它的变化,哦,那我换个更靠谱的进阶场景:比如你有一个响应式的refDate,现在要监听它格式化后的字符串(比如YYYY-MM-DD)的变化,然后更新某个DOM或者做其他操作,这时候用getter参数直接返回格式化后的字符串就可以了,只有字符串变化才触发,不用管refDate的具体值是加了一天还是减了一个小时。

比如这个进阶场景的代码:

import { ref, watch } from 'vue'
import dayjs from 'dayjs'
const refDate = ref(new Date())
// 只有格式化后的YYYY-MM-DD变化才触发
watch(() => dayjs(refDate.value).format('YYYY-MM-DD'), (newDateStr, oldDateStr) => {
  console.log(`日期字符串从${oldDateStr}变成了${newDateStr}`)
  // 这里可以做更新日历组件、请求当天数据等操作
})
// 测试:修改refDate到当天的下午,格式化后的字符串不变,不触发回调
setTimeout(() => {
  refDate.value = new Date(refDate.value.getTime() + 3600000)
}, 1000)
// 测试:修改refDate到明天,格式化后的字符串变了,触发回调
setTimeout(() => {
  refDate.value = new Date(refDate.value.getTime() + 86400000)
}, 2000)

getter参数和直接传ref/reactive对象有啥本质区别?别搞混了

很多同学会觉得,直接传ref对象的时候,不是也自动解包.value了吗?那和写() => refA.value的getter参数有啥不一样?其实从功能上来说,大多数情况下是一样的,但从响应式原理极端场景还是有细微差别的:

从响应式原理来看,直接传ref对象的时候,Vue3内部其实帮你封装了一个getter函数——没错,就是() => refA.value!所以这时候的依赖收集逻辑是完全一样的,都是显式访问ref的.value属性,触发Proxy的get拦截器。

那极端场景有啥不一样呢?比如你有一个refA,它的.value是一个普通的非响应式对象,现在你在回调里修改了这个普通对象的某个属性:

import { ref, watch } from 'vue'
const refA = ref({ count: 0 }) // 这里refA.value是普通对象,不是reactive!
// 直接传refA
watch(refA, (newVal, oldVal) => {
  console.log('直接传refA触发了')
})
// 用getter参数传refA.value
watch(() => refA.value, (newVal, oldVal) => {
  console.log('getter传refA.value触发了')
})
// 测试:修改普通对象的count属性
refA.value.count++ // 这时候两个回调都不会触发!因为refA.value的引用没变,而且它是普通对象,没有响应式
// 测试:重新赋值refA.value
refA.value = { count: 1 } // 这时候两个回调都会触发!因为refA.value的引用变了

哦,这时候好像还是一样的?那再换一个极端场景:比如你有一个refB,它的.value是一个Proxy对象(比如通过reactive创建的),现在你修改了这个Proxy对象的某个属性:

import { ref, reactive, watch } from 'vue'
const reactiveObj = reactive({ count: 0 })
const refB = ref(reactiveObj)
// 直接传refB
watch(refB, (newVal, oldVal) => {
  console.log('直接传refB触发了')
})
// 用getter参数传refB.value
watch(() => refB.value, (newVal, oldVal) => {
  console.log('getter传refB.value触发了')
})
// 测试:修改Proxy对象的count属性
refB.value.count++ // 这时候两个回调都不会触发!因为refB.value的引用没变,而且直接传源的时候,如果源是ref包裹的reactive对象,Vue3默认不会深度监听

哦,对了,这里要提一下深度监听!直接传reactive对象的时候,Vue3默认是深度监听的(比如直接传reactiveObj,不管修改它的哪个嵌套属性都会触发回调),但直接传ref包裹的reactive对象(比如refB)的时候,默认是浅度监听的——只有当refB.value的引用变了才会触发,那如果要深度监听ref包裹的reactive对象的嵌套属性呢?可以加{ deep: true }配置,但这时候和直接传reactiveObj加{ deep: true }是一样的吗?其实一样的,但加了{ deep: true }之后性能会变差,因为Vue3要递归遍历整个对象的所有属性,建立依赖关系。

那如果用getter参数显式访问refB.value.count呢?不用加{ deep: true }就可以精准监听,这就是getter参数的优势之一——不用开全局深度监听,就能精准监听单个嵌套属性,性能更好。

哦,刚才那个极端场景补充一下:

import { ref, reactive, watch } from 'vue'
const reactiveObj = reactive({ count: 0 })
const refB = ref(reactiveObj)
// 不用加deep,直接显式访问嵌套属性
watch(() => refB.value.count, (newVal, oldVal) => {
  console.log('精准监听count触发了')
})
// 测试:修改count
refB.value.count++ // 立刻触发,完美!

用getter参数的时候要注意什么?别踩这些小坑

虽然getter参数很好用,但也有一些小细节要注意,否则还是会踩坑:

getter函数必须是纯函数,不要在里面修改任何响应式数据

纯函数的定义是:输入相同,输出一定相同,而且没有任何副作用(比如修改响应式数据、修改DOM、发送请求、打印日志这些都是副作用),如果在getter函数里修改响应式数据,会导致无限循环——因为修改响应式数据会触发依赖更新,依赖更新会重新调用getter函数,getter函数又修改响应式数据,无限循环下去。

比如这个错误的例子:

import { ref, watch } from 'vue'
const count = ref(0)
// 错误!在getter里修改了count
watch(() => {
  count.value++ // 这里有副作用
  return count.value
}, (newVal) => {
  console.log(newVal)
})

运行这段代码的话,控制台会一直打印数字,直到浏览器崩溃。

getter函数的返回值必须是可比较的,或者显式开启{ deep: true }

刚才已经讲过,如果getter函数返回的是一个普通对象(不是reactive),那只有当对象的引用变了的时候才会触发回调;如果想要监听对象内部属性的变化,必须显式开启{ deep: true },但这时候性能会变差,所以最好还是像之前说的那样,显式返回你要监听的具体属性,不用开deep。

getter函数如果返回的是多个源的组合数组,那数组的引用每次都会变吗?

比如你写watch(() => [refA.value, refB.value], (newArr, oldArr) => {}),这时候即使refA和refB的值都没变,每次Vue3的响应式系统检查依赖的时候,会不会重新创建一个数组?其实不会,Vue3内部做了优化——只有当数组里的元素变化的时候,getter函数的返回值才会被认为是“变化了”;如果数组里的元素都没变,即使数组的引用变了(因为每次调用getter都会创建新数组),Vue3也会跳过回调。

不过为了保险起见,如果你要监听多个源的组合,最好还是用Vue3官方推荐的直接传源数组的方式:watch([refA, refB], (newArr, oldArr) => {}),这时候Vue3内部的优化更成熟,而且代码更简洁。

getter参数到底是“神器”还是“鸡肋”?

现在你应该不会觉得getter参数是多此一举了吧?它确实是Vue3 watch里的一个“小神器”,专门用来解决精准监听嵌套值、组合值、非直接引用值的问题,不用开全局深度监听,就能获得更好的性能和更简洁的代码。

它也不是万能的,大多数简单场景(比如监听单个ref对象、单个reactive对象的引用),直接传源就可以了,不用特意写getter函数。

最后给你一个快速判断要不要用getter参数的小口诀: 嵌套属性怎么办?getter显式来访问; 组合结果要监听?返回布尔或对象; 精准触发不浪费,性能优化有一套; 直接传源搞不定?就把getter请上场!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门