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

Vue3 setup里的watch怎么用?和Vue2 watch/watchEffect有啥不同?踩过哪些坑要注意?

terry 2小时前 阅读数 28 #Vue

为什么Vue3要把watch放在setup里?老写法还能用吗?

刚从Vue2转过来的朋友,第一反应可能是:“好好的选项式API watch不用,非得塞到setup组合式里折腾?”其实这不是瞎折腾,是Vue3为了逻辑复用、代码可维护性、类型推导更友好做的核心调整。

先给颗定心丸:Vue3完全兼容选项式API,你在单文件组件里写<script>标签不带setup,继续用原来的watch: { 数据名(newVal, oldVal) { 逻辑 } }完全没问题,但一旦你开始用组合式API,就会发现把相关的响应式数据、操作逻辑、监听逻辑放在同一块代码里,比把它们拆在data、methods、watch三个地方舒服太多——比如你做一个搜索组件,搜索关键词的监听、搜索函数、搜索结果的展示数据,都可以挤在“搜索模块”的注释块下,下次改搜索功能不用跳来跳去。

而且组合式的watch和TypeScript简直是绝配!你不用再像选项式那样给watch里的this写as any或者复杂的接口声明,TypeScript能直接从你定义的ref、reactive里推断出监听数据的类型,连回调参数newVal、oldVal的类型都帮你标好,写代码的时候自动补全爽到飞起,后期重构也不容易出类型错误。

setup里的watch基础用法有哪些?和Vue2有啥不一样的参数?

组合式的watch和选项式的核心逻辑差不多:监听响应式数据的变化,数据变了就执行指定的回调函数,但参数写法更灵活,监听的对象也更丰富。

最基础的单数据监听

不管是ref还是reactive的属性,都可以用watch单独监听,先举个ref的例子:

import { ref, watch } from 'vue'
const count = ref(0)
// 第一个参数是要监听的数据,第二个是回调,回调参数依次是新值、旧值
watch(count, (newCount, oldCount) => {
  console.log(`count从${oldCount}变成了${newCount}`)
})

如果是reactive的属性,不能直接传reactive对象本身(后面会讲直接传的坑),得传一个返回该属性的箭头函数,或者用() => reactiveObj.prop的形式:

import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 18 })
// 监听单个reactive属性,必须用 getter 函数
watch(() => user.age, (newAge, oldAge) => {
  console.log(`user的age从${oldAge}变成了${newAge}`)
})

这里第一个不同就出来了:Vue2里监听对象属性可以直接写'user.age'字符串路径,Vue3组合式里虽然也支持字符串,但官方更推荐用getter函数,因为类型推导更好,也更符合函数式编程的风格

多数据同时监听

这个功能Vue2其实也有,但写法是把多个数据名放在一个数组里当watch的键,回调里的参数也是一个对应顺序的数组,Vue3组合式里的写法更统一,直接把要监听的多个数据源(ref、getter函数都可以混着来)放在第一个参数的数组里就行:

import { ref, reactive, watch } from 'vue'
const count = ref(0)
const user = reactive({ name: '张三' })
// 混着监听ref和reactive的getter,回调参数也是数组
watch([count, () => user.name], ([newCount, newName], [oldCount, oldName]) => {
  console.log(`count变了:${oldCount}→${newCount},name变了:${oldName}→${newName}`)
})

这个功能特别适合那种“多个条件满足一个就触发操作”或者“需要拿到多个变化后的值一起处理”的场景,比如表单的提交状态监听:只有当用户名、密码、验证码都填对了,提交按钮才变可用——不过这里可能用computed更合适,但监听也是没问题的。

新增的配置项你得知道

Vue3组合式的watch第三个参数是一个配置对象,除了保留Vue2的immediate(立即执行一次回调)、deep(深度监听对象/数组),还新增了几个好用的:

  • flush: 控制回调的执行时机,默认是'post',也就是在DOM更新之后执行;如果改成'pre',就是在DOM更新之前执行,适合那些需要在DOM重绘前做的操作(比如修改某个计算属性的依赖,避免不必要的渲染);还有一个'sync',是同步执行,一旦数据变了马上触发,慎用,因为可能会影响性能。
  • onTrack / onTrigger: 调试用的配置项。onTrack会在响应式数据被track(也就是被watch/ computed/模板使用)的时候触发,onTrigger会在数据变化触发watch回调的时候触发,里面可以打印出依赖的信息,帮助你排查为什么watch没触发或者触发了太多次。

举个带配置项的例子,方便大家理解:

import { ref, watch } from 'vue'
const searchKeyword = ref('')
const searchResults = ref([])
// 监听搜索关键词,立即执行一次(页面加载就搜默认关键词),并且debounce一下(虽然watch本身没有debounce配置,但可以用第三方库或者自己写setTimeout实现,这里先不展开)
watch(searchKeyword, async (newKeyword) => {
  // 这里假装是调用后端的搜索接口
  const res = await fetch(`/api/search?keyword=${newKeyword}`)
  searchResults.value = await res.json()
}, {
  immediate: true, // 页面加载就执行
  flush: 'post', // 等DOM更新完再执行(虽然这里好像不影响,但习惯上写API调用用post)
  onTrack(e) {
    console.log('搜索关键词被监听了', e) // 调试用
  },
  onTrigger(e) {
    console.log('搜索关键词变了,触发回调', e) // 调试用
  }
})

setup里的watch和watchEffect到底该选哪个?很多人都搞混!

这是Vue3组合式里最容易踩的选择困难症坑了:watch和watchEffect都是监听响应式数据的,到底什么时候用哪个?别着急,先搞清楚它们的核心区别,下次就不会选错了。

核心区别一:watch是“显式监听”,watchEffect是“隐式监听”

watch需要你明确指定要监听哪些数据,只有这些数据变了才会触发回调;而watchEffect不需要你指定,它会自动收集回调函数内部用到的所有响应式数据作为依赖,只要其中任何一个依赖变了,就会触发回调。

举个简单的例子对比一下:

import { ref, watch, watchEffect } from 'vue'
const count = ref(0)
const doubleCount = ref(0)
// 显式监听count,doubleCount变了不会触发
watch(count, (newCount) => {
  doubleCount.value = newCount * 2
  console.log('watch触发,doubleCount更新')
})
// 隐式监听回调里用到的所有响应式数据——这里用到了count,所以count变了会触发;如果后面doubleCount也被用到了,doubleCount变了也会触发
watchEffect(() => {
  doubleCount.value = count.value * 2
  console.log('watchEffect触发,doubleCount更新')
})

看起来好像watchEffect更方便?不用写监听数据?但别急,先看第二个区别。

核心区别二:watch能拿到旧值,watchEffect拿不到(除非你自己存)

watch的回调函数里默认有两个参数:新值和旧值,这个在很多场景下是必须的——比如你要做一个“点赞取消就减积分,点赞成功就加积分”的功能,你需要知道点赞状态之前是true还是false,才能决定加还是减:

import { ref, watch } from 'vue'
const isLiked = ref(false)
const points = ref(0)
// 必须拿到旧值才能判断
watch(isLiked, (newIsLiked, oldIsLiked) => {
  if (newIsLiked && !oldIsLiked) {
    points.value += 10
  } else if (!newIsLiked && oldIsLiked) {
    points.value -= 10
  }
})

如果用watchEffect的话,你得自己在组件里存一个旧值的变量,每次回调执行完更新旧值,麻烦不说,还容易出错——比如immediate执行的时候旧值的初始值对不对?

核心区别三:watchEffect默认立即执行一次,watch需要手动配置immediate

刚才的例子里已经提到了,watchEffect不管你有没有配置,第一次组件渲染到setup函数的时候,就会立即执行一次回调函数,用来收集依赖;而watch默认只有数据变了才会触发,如果你想让它立即执行,得在第三个配置对象里加immediate: true

核心区别四:watch可以控制监听的粒度,watchEffect是“全量”依赖

前面说过,监听reactive对象的时候,watch可以用getter函数只监听某个属性,比如() => user.age,只有age变了才会触发;而如果用watchEffect直接访问user对象的多个属性,比如console.log(user.name, user.age),那么不管是name变了还是age变了,都会触发回调——这在某些场景下会造成不必要的性能浪费,比如你只需要监听age来做年龄验证,结果name变了也会触发验证函数,完全没必要。

什么时候选watch,什么时候选watchEffect?

现在给大家一个简单的判断标准,记住这几点就行:

  1. 需要拿到旧值选watch,不用犹豫。
  2. 只需要监听特定的一个或几个数据选watch,性能更好。
  3. 不需要指定依赖,依赖是动态变化的选watchEffect——比如你做一个数据预览组件,预览的内容是根据用户选择的不同数据项生成的,用户选A就用A的数据,选B就用B的数据,这时候用watchEffect自动收集选中数据项的依赖最合适,不用每次用户换选项都手动改watch的监听数据。
  4. 只是简单的响应式数据联动,不需要旧值,也不关心性能(或者性能影响很小)选watchEffect,写法更简洁。

setup里用watch的常见踩坑指南,90%的人都中过!

坑一:直接监听reactive对象,导致深度监听失效或者旧值拿不到

这个是最常见的坑了!很多刚转过来的朋友会想:“Vue2里直接监听整个对象,加个deep: true就能深度监听,Vue3应该也一样吧?”然后就写成这样:

import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', info: { age: 18 } })
// 错误写法:直接监听reactive对象
watch(user, (newUser, oldUser) => {
  console.log('user变了', newUser, oldUser)
}, { deep: true })

这个写法有两个问题:

  1. 旧值拿不到:不管你改user的哪个属性,newUser和oldUser都是同一个引用,因为reactive对象是响应式的引用类型,Vue3不会为了监听它而做深拷贝(太消耗性能了),所以打印出来的oldUser其实也是修改后的对象。
  2. 加不加deep: true都会深度监听:哦?还有这种操作?对,Vue3里直接监听reactive对象的话,默认就是深度监听的,不管你有没有加deep配置——这其实是官方的一个设计,因为reactive对象本身就是深层响应式的,但结合第一个问题,这个设计反而容易让人踩坑。

正确的写法

  • 如果你只需要监听reactive对象的某个属性:用getter函数,比如() => user.name或者() => ({ ...user })(如果需要监听整个对象的浅变化,比如替换整个info对象)。
  • 如果你需要监听整个对象的深变化,并且需要拿到旧值:那你得自己在组件里存一个旧值的深拷贝,每次watch触发的时候先把旧值存下来,再更新——不过这种场景很少见,一般建议尽量拆分监听的属性,或者用computed计算出需要的属性再监听。

举个监听整个对象浅变化的例子:

import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', info: { age: 18 } })
// 正确写法:用getter函数返回一个浅拷贝的对象,这样只有user的顶层属性变化的时候才会触发,并且能拿到旧值
watch(() => ({ ...user }), (newUser, oldUser) => {
  console.log('user的顶层属性变了', newUser, oldUser)
})
// 测试一下
user.name = '李四' // 会触发,顶层属性变了
user.info.age = 20 // 不会触发,因为是info的属性,浅拷贝的user里info还是原来的引用

坑二:监听ref包裹的对象,deep配置没生效

刚才说的是直接监听reactive对象,那如果是ref包裹的普通对象呢?

import { ref, watch } from 'vue'
const user = ref({ name: '张三', info: { age: 18 } })
// 错误写法:没加deep: true,只监听ref的value引用变化
watch(user, (newUser, oldUser) => {
  console.log('user变了', newUser, oldUser)
})
// 测试一下
user.value.name = '李四' // 不会触发,因为value的引用没变,只是里面的属性变了
user.value = { name: '王五', info: { age: 20 } } // 会触发,因为value的引用变了

哦,这个和reactive对象正好相反!ref包裹的普通对象(不是reactive对象),默认只监听value的引用变化,如果你要深度监听里面的属性,必须手动加deep: true——而且加了之后,也能拿到旧值吗? 等一下,我们测试一下:

import { ref, watch } from 'vue'
const user = ref({ name: '张三', info: { age: 18 } })
watch(user, (newUser, oldUser) => {
  console.log('user的value引用变了?', newUser === oldUser)
  console.log('newUser.name:', newUser.name)
  console.log('oldUser.name:', oldUser.name)
}, { deep: true })
// 测试修改属性
user.value.name = '李四' // 会触发
// 打印结果:
// user的value引用变了? false(因为value还是同一个引用)
// newUser.name: 李四
// oldUser.name: 李四(因为oldUser也是同一个引用)

哦,原来如此!不管是reactive对象还是ref包裹的普通对象,只要是引用类型,加了deep配置之后,修改里面的属性,newVal和oldVal都是同一个引用,拿不到真正的旧值——只有当引用本身变化的时候(比如ref.value重新赋值,reactive对象被整体替换,但reactive对象不能整体替换,因为它是const的),才能拿到旧值。

正确的写法

  • 如果是ref包裹的普通对象,只需要监听引用变化:直接传ref,不用加deep。
  • 如果需要深度监听,但不需要旧值:传ref,加deep: true。
  • 如果需要深度监听,并且需要旧值:和reactive对象一样,自己存旧值的深拷贝,或者用getter函数返回浅拷贝/深拷贝的对象。

坑三:监听computed属性的时候,computed本身是惰性的,会不会有问题?

很多朋友会担心:“computed是惰性的,只有被访问的时候才会计算,那我用watch监听它,会不会因为没访问过而不触发?”别担心,watch监听computed属性的时候,会自动触发computed的计算,并且收集computed的依赖作为自己的间接依赖——也就是说,只要computed的依赖变了,computed的返回值变了,watch就会触发,不管你之前有没有访问过computed。

举个例子验证一下:

import { ref, computed, watch } from 'vue'
const count = ref(0)
const doubleCount = computed(() => {
  console.log('doubleCount被计算了')
  return count.value * 2
})
// 注意:这里没有在模板或者其他地方访问doubleCount
watch(doubleCount, (newVal, oldVal) => {
  console.log('doubleCount变了', newVal, oldVal)
})
// 测试修改count
count.value = 1 // 会先打印“doubleCount被计算了”,然后打印“doubleCount变了 2 0”

你看,没问题吧?watch自动触发了doubleCount的计算,并且count变了之后,doubleCount也变了,watch就触发了。

不过这里有个小细节:如果computed的依赖变了,但computed的返回值没变(比如count.value是1,computed返回的是count.value > 0 ? true : true),那watch会不会触发?答案是不会——因为computed会做缓存,只有当依赖变了并且返回值和上次不一样的时候,才会更新缓存,watch才会触发,这个特性很好,避免了不必要的回调执行。

坑四:watch回调里的this指向不对

哦,不对,组合式的setup函数里没有this!很多刚转过来的朋友会习惯性地在watch回调里写this.$emit或者this.$refs,结果报错“this is undefined”。

正确的写法

  • 如果要在setup里用$emit:从vue里导入defineEmits,先定义emits,然后调用定义好的emits函数。
  • 如果要在setup里用$refs:用ref(null)给DOM元素或者子组件绑定ref,然后直接访问ref的value(注意要在onMounted之后访问,因为DOM元素或者子组件只有挂载之后才会有值)。

举个例子:

import { ref, watch, defineEmits, onMounted } from 'vue'
const count = ref(0)
const emits = defineEmits(['count-changed']) // 定义emits
const countInputRef = ref(null) // 绑定DOM元素的ref
onMounted(() => {
  console.log('countInputRef的value:', countInputRef.value) // 这里才能访问到DOM元素
})
watch(count, (newCount) => {
  emits('count-changed', newCount) // 正确的emit写法
  countInputRef.value.focus() // 假设count变了之后要让输入框获取焦点
})

setup里的watch你掌握了吗?

今天我们从为什么用setup里的watch,到基础用法、配置项,再到和watchEffect的对比,最后讲了四个常见的踩坑指南,相信大家对Vue3组合式的watch已经有了比较全面的了解。

最后再给大家总结几个关键点,方便大家记忆:

  1. 组合式的watch更适合逻辑复用、类型推导,选项式的watch依然兼容,按需选择。
  2. 监听单个reactive属性要用getter函数,直接监听reactive对象默认深度监听但拿不到旧值。
  3. 监听ref包裹的普通对象,默认只监听引用变化,深度监听要加deep: true。
  4. watch能拿到旧值、显式监听、性能更好,适合大多数场景;watchEffect隐式监听、默认立即执行,适合动态依赖的场景。
  5. setup里没有this,要用defineEmits、ref来代替选项式的this.$emit、this.$refs。

如果大家还有其他关于Vue3 watch setup的问题,欢迎在评论区留言讨论,我们一起学习进步!

版权声明

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

热门