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

Vue3中的watch到底怎么用?它和watchEffect、computed的区别在哪?踩坑点有哪些?

terry 51分钟前 阅读数 17 #Vue

我之前帮朋友改Vue2转Vue3的电商系统代码,光watch这块就踩了三个大坑,差点把周末全搭进去,后来翻了不少官方文档和社区的实战案例,才彻底搞明白它的逻辑——其实Vue3的watch不是简单把Vue2的选项式API换成组合式就行,底层的响应式机制变化带来了完全不一样的使用细节,今天就把这些整理成问答,希望能帮到和我当初一样懵的人。

先搞懂:组合式API里的watch长什么样?

别慌,Vue3给我们留了两种使用场景的watch:选项式的老样子(不过官方更推荐用组合式),组合式的需要从vue里显式引入,先从最基础的组合式写法讲起,别嫌碎,细节都是坑。

最基础的单值监听

组合式里监听单个ref值,写法是直接把ref变量当第一个参数传,回调里能拿到新值和旧值,比如监听商品数量的变化:

import { ref, watch } from 'vue'
const productCount = ref(0)
// 监听单个ref
watch(productCount, (newVal, oldVal) => {
  console.log(`商品数量从${oldVal}变成了${newVal}`)
  // 这里可以加购物车动画、库存预警判断这些逻辑
})

这里要注意一个点:如果productCount是个对象类型的ref(比如const cart = ref({ id: 1, count: 0 })),直接传watch的话,默认只会监听cart本身的引用变化,不会监听里面id、count的属性变化,这个就是第一个容易踩的坑,后面专门讲。

进阶监听:对象属性、reactive对象

如果要监听对象的属性,有两种方法:一是用箭头函数返回该属性,二是用reactive包裹整个对象后传整个对象(但要注意是浅监听还是深监听)。

先看箭头函数监听reactive对象的单个属性:

import { reactive, watch } from 'vue'
const cart = reactive({ id: 1, count: 0, price: 99 })
// 箭头函数返回具体属性,能拿到新老值,且默认浅监听该属性的引用/值变化
watch(
  () => cart.count,
  (newVal, oldVal) => {
    console.log(`只监听购物车中该商品的数量:${oldVal} → ${newVal}`)
  }
)

这种写法的好处是精准,只有count变的时候才会触发,不会因为price或者其他属性的变化浪费性能。

再看直接传reactive对象:

watch(
  cart,
  (newVal, oldVal) => {
    console.log('整个cart的属性变了?')
  },
  { deep: true } // 默认deep是false哦
)

直接传reactive的话,第一个坑来了:不写deep: true的话,只有给cart重新赋值整个对象才会触发回调,比如cart = { id: 2, count: 1 }这种操作,改count、price这些属性完全没反应;而且写了deep: true的话,新值和旧值是同一个引用——因为Vue3的响应式是基于Proxy的,不会像Vue2那样给深拷贝一份对比后的旧值,这点太重要了,后面我朋友踩的就是这个。

多源监听

有时候我们需要同时监听多个值,只要任何一个变了就触发回调,这时候可以把第一个参数换成数组,数组里可以放ref、reactive、箭头函数,混着来也没问题:

import { ref, reactive, watch } from 'vue'
const productId = ref(1)
const cart = reactive({ count: 0 })
const userLevel = ref(3)
// 多源监听
watch(
  [productId, () => cart.count, userLevel],
  ([newId, newCount, newLevel], [oldId, oldCount, oldLevel]) => {
    // 这里的新老值也是按数组顺序对应原来的监听源
    console.log('监听的多源数据变了:', [newId, newCount, newLevel])
  }
)

多源监听的回调参数是两个数组,分别对应新值和旧值的集合,顺序和第一个参数的数组完全一致,这点很人性化,而且如果其中有ref对象本身的引用变化(比如给productId重新赋值了ref(2)),也会触发,不过这种操作我们一般很少做,除非是动态切换监听的变量。

watch的可选配置项,官方文档提了但没说透的细节

除了刚才提到的deep,watch还有几个常用的配置项,比如immediate、flush、onTrack、onTrigger,后面两个是调试用的,平时用得少,但前三个必须搞明白。

immediate:要不要刚挂载就执行一次?

这个配置项和Vue2的选项式API里的一样,设为true的话,组件刚挂载(或者setup刚执行到watch这里?不对,等下,setup是在beforeCreate和created之间执行的,immediate设为true的话,是在watch定义之后、组件DOM挂载之前就立即执行第一次回调),比如监听用户等级来初始化折扣逻辑:

watch(
  userLevel,
  (newVal, oldVal) => {
    // 第一次oldVal是undefined哦
    console.log(`用户等级对应的折扣:${getDiscount(newVal)}`)
  },
  { immediate: true }
)

这里要注意第二个点:immediate设为true时,第一次回调的oldVal是undefined——因为此时还没有旧的数据,这个在写条件判断的时候一定要加非空校验,不然容易报错。

flush:什么时候执行回调?

这个是Vue3新增的配置项,解决了Vue2里watch回调经常在DOM更新之前执行,拿不到最新DOM的问题,flush有三个可选值:

  1. pre(默认值):在组件DOM更新之前执行回调,如果要在回调里修改会影响DOM渲染的响应式数据,用这个比较合适,能避免不必要的重渲染;但如果要操作DOM(比如给新增的购物车项加滚动高亮),就拿不到最新的DOM节点了。
  2. post:在组件DOM更新之后执行回调,刚好和pre相反,适合操作DOM的场景,刚才提到的购物车高亮就可以用这个。
  3. sync:同步执行回调,也就是监听的响应式数据一变化就立即触发,不管什么时机,这个要慎用,因为会打乱Vue的响应式更新队列,可能导致性能问题或者其他逻辑bug,除非是需要极低延迟的场景(比如实时监听输入框的高频变化并进行本地存储?不过watchPostEffect有时候更合适)。

我朋友当初踩的第二个坑就是这个:他用默认的pre去获取刚添加商品后的购物车列表DOM高度,结果每次都拿到旧的高度,折腾了好久才想到加flush: 'post'。

deep:深监听到底要不要开?

刚才已经提到过一点,这里再详细说,ref的基本类型(数字、字符串、布尔值等)不需要开deep,因为它们是值类型,直接比较值就行;ref的对象类型(如果不拆包的话)默认只监听引用变化,要监听属性的话,要么用箭头函数指向具体属性(精准且性能好,推荐优先用),要么开deep;reactive的对象类型默认是浅监听引用变化(其实Proxy本身是可以监听到属性变化的,但组合式API里的watch默认对reactive对象开了浅比较?不对,等下,我再理清楚:组合式API里的watch,当第一个参数是reactive对象本身时,会自动开启深层响应式追踪,但回调触发的条件默认是比较对象的引用是否变化,除非加deep: true才会比较深层属性的变化——这点很绕,我当初看官方文档也看了两遍才明白。

而且开了deep: true还有个问题:如果监听的是多层嵌套的大对象(比如电商的商品详情页数据,有十几个属性,每个属性下面又有嵌套),会消耗大量的性能,因为Vue要递归遍历整个对象的所有属性去做依赖追踪和变化比较,所以优先用箭头函数精准监听需要的属性,实在没办法再开deep: true,并且要控制监听的对象层级不要太深

还有刚才提到的第三个坑:reactive对象开deep: true的话,回调里的新值和旧值是同一个引用!因为Proxy代理的是原对象,Vue3不会像Vue2那样在每次变化时先深拷贝一份作为旧值,再更新原对象。

const cart = reactive({ count: 0 })
watch(
  cart,
  (newVal, oldVal) => {
    console.log(newVal === oldVal) // 输出true!
  },
  { deep: true }
)
cart.count++

那如果我需要旧值怎么办?这时候可以用箭头函数结合JSON.parse(JSON.stringify())来手动深拷贝一份监听的对象,但要注意JSON.parse(JSON.stringify())有局限性(不能处理函数、正则、Date对象等),如果有这些类型,可以用lodash的cloneDeep(不过要注意lodash的版本,vue-cli5+或者vite默认安装的lodash-es是按需引入的,比较轻量)。

watch vs watchEffect vs computed,别再傻傻分不清楚

这三个是Vue3响应式API里最容易混淆的,很多时候明明用computed更合适,却写成了watch,导致代码冗余或者性能差,我们可以从三个维度来对比:依赖来源、触发时机、返回值、使用场景

核心对比表(方便快速查找)

对比维度 watch watchEffect computed
依赖来源 显式指定(第一个参数) 自动收集(回调里用到的所有响应式数据) 自动收集(getter里用到的所有响应式数据)
触发时机 显式指定的依赖变化时触发 定义时立即执行一次+依赖变化时触发 getter被访问时执行+依赖变化时缓存失效
返回值 返回计算后的结果
新旧值获取 可以拿到(箭头函数/reactive浅引用除外) 拿不到 拿不到(除非结合get/set和ref手动存)
性能消耗 依赖追踪精准,默认低;开deep高 自动收集所有依赖,可能有冗余;但整体灵活 有缓存,只有依赖变化且被访问时才重新计算,性能最高
推荐使用场景 需要旧值、异步操作、精准控制触发条件 需要自动收集依赖、不需要旧值、简单的副作用 需要计算后的值给模板/其他响应式数据用、有缓存需求

分场景举例子,一看就懂

场景1:计算商品总价

这个肯定用computed!因为总价是依赖商品数量和单价的,而且模板里会频繁访问,用computed的缓存机制能避免重复计算:

import { reactive, computed } from 'vue'
const cart = reactive({ id: 1, count: 0, price: 99 })
// 计算总价,有缓存
const totalPrice = computed(() => {
  console.log('计算总价了!') // 只有count或price变的时候才会打印
  return cart.count * cart.price
})

如果写成watch的话,得手动定义一个totalPrice的ref,然后在count或price变化时更新它,不仅代码多,还没有缓存(不过可以手动加,但没必要):

// 不推荐的写法
import { reactive, ref, watch } from 'vue'
const cart = reactive({ id: 1, count: 0, price: 99 })
const totalPrice = ref(0)
// 多源监听
watch([() => cart.count, () => cart.price], () => {
  console.log('计算总价了!')
  totalPrice.value = cart.count * cart.price
}, { immediate: true })

场景2:监听搜索关键词,延迟调用接口

这个用watch最合适!因为需要精准控制只监听关键词的变化(比如忽略空格、防抖节流等),还需要异步操作(调用接口):

import { ref, watch } from 'vue'
import { searchProducts } from '@/api/product'
const searchKeyword = ref('')
const productList = ref([])
// 可以用lodash的debounce,这里手动写个简单的
let timer = null
watch(
  searchKeyword,
  async (newVal) => {
    // 先清空之前的定时器
    if (timer) clearTimeout(timer)
    // 如果关键词为空,清空列表
    if (!newVal.trim()) {
      productList.value = []
      return
    }
    // 延迟500ms调用接口,避免高频触发
    timer = setTimeout(async () => {
      const res = await searchProducts(newVal.trim())
      productList.value = res.data
    }, 500)
  }
)

如果写成watchEffect的话,也能实现,但自动收集依赖可能会不小心收集到其他不需要的变量,而且防抖节流的逻辑得放在回调里,和watch的写法差不多,但依赖来源不够清晰。

场景3:自动保存表单数据到本地存储

这个用watchEffect更灵活!因为不需要显式指定要监听表单的哪些字段,只要表单里的任何响应式字段变了,就自动保存:

import { reactive, watchEffect, onBeforeUnmount } from 'vue'
const formData = reactive({
  username: '',
  password: '',
  email: '',
  phone: ''
})
// 自动收集formData里的所有依赖
const stopWatch = watchEffect(() => {
  localStorage.setItem('userFormData', JSON.stringify(formData))
})
// 组件卸载前要停止监听,避免内存泄漏
onBeforeUnmount(() => {
  stopWatch()
})

这里还要注意一个点:无论是watch还是watchEffect,都会返回一个停止监听的函数,如果监听的逻辑是在组件内定义的,组件卸载时会自动停止,但如果是在全局或者动态组件里定义的,最好手动调用停止函数,避免内存泄漏。

除了刚才说的,还有哪些容易被忽略的踩坑点?

监听ref的基本类型数组

刚才讲过ref的对象类型默认只监听引用变化,数组也是对象类型的一种,所以监听ref的数组时,如果只是push、pop、shift、unshift这些操作(也就是改变数组的元素但不改变引用),直接传watch的话不会触发回调,除非加deep: true,或者用箭头函数指向数组本身(这点和reactive对象不一样,箭头函数指向ref的数组本身时,Vue会自动追踪数组的元素变化?不对,等下,试一下:

const list = ref([])
// 写法1:直接传ref数组,默认只监听引用变化
watch(list, () => {
  console.log('写法1触发了')
})
// 写法2:箭头函数指向ref数组的值(.value)
watch(() => list.value, () => {
  console.log('写法2触发了')
})
// 写法3:直接传ref数组,加deep: true
watch(list, () => {
  console.log('写法3触发了')
}, { deep: true })
list.value.push(1) // 写法2和3会触发,写法1不会
list.value = [2, 3] // 三个都会触发

哦对,刚才的表述有点问题,修正一下:ref的基本类型数组,如果直接传watch的话,默认只监听数组的引用变化;如果用箭头函数返回list.value的话,会自动开启深层追踪(也就是监听数组的元素变化);如果用箭头函数返回list.value.map(item => item)这种新数组的话,和直接传ref数组默认行为一样,只监听引用变化

监听props

props是只读的,组合式API里监听props的时候,最好用箭头函数返回具体的props属性,因为如果直接传整个props对象的话,虽然会自动追踪,但可能会有不必要的依赖,而且写法不够清晰:

// 父组件传过来的props
const props = defineProps(['productId', 'productCount'])
// 推荐的写法:箭头函数返回具体属性
watch(
  () => props.productId,
  (newVal) => {
    // 父组件传的productId变了,重新获取商品详情
    getProductDetail(newVal)
  }
)

这里还要注意一个点:如果props是个对象类型,父组件没有改变对象的引用,只是改变了里面的属性,直接用箭头函数返回props.productObj的话,不会自动开启深层追踪,需要加deep: true

回调里不要直接修改监听的响应式数据

除非你加了flush: 'sync'(但不推荐),不然在pre或者post的回调里直接修改监听的响应式数据,会导致无限循环触发watch,

const count = ref(0)
// 错误的写法:无限循环
watch(count, (newVal) => {
  count.value = newVal + 1
})
count.value++

这个逻辑明显有问题,count每次加1都会触发watch,watch里又加1,无限循环下去,浏览器会卡死,如果真的需要在监听的回调里修改数据,一定要加条件判断,或者监听其他数据。

Vue3的watch到底该怎么用?

最后给大家整理一个简单的使用流程,方便大家快速上手:

  1. 确定使用场景:先想清楚是需要计算值(用computed),还是需要做副作用(用watch或watchEffect);
  2. 选择合适的API:副作用场景下,如果需要旧值、异步操作、精准控制触发条件(比如防抖节流、只监听某个属性),用watch;如果需要自动收集依赖、不需要旧值、简单的副作用(比如自动保存、自动加载),用watchEffect;
  3. 写watch的时候:优先用箭头函数精准监听需要的属性,避免开deep: true;如果开了deep: true,要注意新值和旧值是同一个引用的问题;如果需要操作DOM,加flush: 'post';如果需要刚挂载就执行,加immediate: true;
  4. 记得停止监听:如果监听的逻辑是在全局或者动态组件里定义的,记得调用返回的停止函数,避免内存泄漏。

其实Vue3的响应式API比Vue2的更灵活,但也多了很多细节,只有多写多练多踩坑,才能真正掌握,希望今天的内容能帮到大家,如果还有其他问题,欢迎在评论区留言交流。

版权声明

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

热门