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

Vue3 watch怎么用?有哪些高级用法和避坑点?

terry 1小时前 阅读数 29 #Vue

最近刷开发交流群,发现不少刚从Vue2转Vue3的朋友,或者用Vue3但只会写基础监听的小伙伴,都在踩watch的小坑——要么监听没触发,要么触发了但数据不对,要么不知道怎么处理深层数据、首次渲染就触发的需求,今天咱们就从基础用法聊起,把高级用法拆得明明白白,再点出最容易踩的5个坑,看完你就能把Vue3 watch用得得心应手了。

基础用法:新手入门先搞懂这3种监听对象

watch的核心作用就是“监听数据变化,触发回调函数”,但Vue3的监听对象比Vue2灵活,支持监听响应式数据(ref、reactive定义的)、getter函数、数组甚至多个数据组合,咱们逐个说。

监听单个ref定义的数据

ref定义的基本类型(比如number、string、boolean)和引用类型(比如对象、数组,但ref会把引用类型包一层.value属性),都可以直接作为监听对象。 举个例子,比如你做一个输入框实时验证功能,输入框的内容用inputValue = ref('')变了都检查长度:

import { ref, watch } from 'vue'
export default {
  setup() {
    const inputValue = ref('')
    const errorMsg = ref('')
    // 直接监听inputValue,参数是newVal(新值)和oldVal(旧值)
    watch(inputValue, (newVal, oldVal) => {
      if (newVal.length < 6) {
        errorMsg.value = '密码至少6位哦'
      } else if (newVal.length > 16) {
        errorMsg.value = '密码别超过16位'
      } else {
        errorMsg.value = ''
      }
      console.log(`密码从「${oldVal}」改成了「${newVal}」`)
    })
    return { inputValue, errorMsg }
  }
}

这里要注意,监听ref定义的基本类型时,不需要加.value,Vue会自动解包;但如果是监听ref定义的引用类型的内部属性(比如inputValue.value.password),不能直接写inputValue.password,得用getter函数或者deep选项,后面会讲。

监听reactive定义的数据

reactive定义的是响应式对象,不能直接监听整个对象的某个属性吗?当然可以,但直接监听整个reactive对象的话,newVal和oldVal会是同一个对象引用——因为Vue3对reactive做的是深层响应式,修改内部属性不会改变对象本身的地址,这时候oldVal其实没用,除非你加了deep和immediate之外的其他配置?不对,不管加不加,直接监听整个reactive对象的新旧值都是一样的。 如果要监听reactive对象的某个属性,或者整个对象但需要正确的旧值,推荐用getter函数:

import { reactive, watch } from 'vue'
export default {
  setup() {
    const user = reactive({
      name: '张三',
      age: 25,
      address: {
        city: '北京',
        district: '朝阳区'
      }
    })
    // 监听整个reactive对象(新旧值同引用)
    watch(user, (newVal, oldVal) => {
      console.log('user整个对象的新旧值同引用:', newVal === oldVal) // 永远true
    })
    // 用getter函数监听单个属性(age,基本类型,新旧值正确)
    watch(() => user.age, (newVal, oldVal) => {
      console.log(`年龄从「${oldVal}」改成了「${newVal}」`)
    })
    return { user }
  }
}

监听多个数据组合

有时候需要监听两个或以上的数据,只要其中一个变了就触发回调,这时候可以把监听对象放在一个数组里:

import { ref, reactive, watch } from 'vue'
export default {
  setup() {
    const count = ref(0)
    const user = reactive({ name: '张三' })
    // 数组里可以放ref、getter函数
    watch([count, () => user.name], ([newCount, newName], [oldCount, oldName]) => {
      console.log('count或user.name变了')
      console.log(`count新:${newCount},旧:${oldCount}`)
      console.log(`name新:${newName},旧:${oldName}`)
    })
    return { count, user }
  }
}

这个组合监听的newVal和oldVal也是数组,顺序和你写的监听对象顺序一致。

进阶配置:这4个选项让watch功能翻倍

基础用法解决了“触发”问题,但实际开发中会有“首次渲染就触发”“监听深层嵌套数据”“限制触发频率”这类需求,这时候就需要用watch的第三个参数——配置对象。

首次渲染触发:immediate

默认情况下,watch只有在监听对象变化时才会触发回调,但有时候需要页面刚加载完就执行一次(比如初始化验证、从接口拿数据后根据数据设置状态),这时候加immediate: true就行。 还是拿刚才的密码输入框举例,页面刚打开如果inputValue是空的,应该直接提示“密码至少6位”:

// 之前的代码基础上,加immediate: true
watch(inputValue, (newVal, oldVal) => {
  // 这里oldVal第一次触发时是undefined哦
  if (newVal.length < 6) {
    errorMsg.value = '密码至少6位哦'
  } else if (newVal.length > 16) {
    errorMsg.value = '密码别超过16位'
  } else {
    errorMsg.value = ''
  }
}, { immediate: true })

注意,开启immediate后,第一次触发的oldVal是undefined,这点别写错逻辑。

监听深层嵌套数据:deep

刚才说过,直接监听ref定义的引用类型(比如userRef = ref({...})),或者用getter监听reactive的整个对象?不对,reactive本身就是深层响应式,但刚才说直接监听整个reactive对象新旧值同引用,那如果监听的是ref的引用类型呢?默认情况下,ref的引用类型只会监听.value的地址变化,不会监听内部属性——比如你把userRef.value重新赋值成一个新对象,会触发;但如果只改userRef.value.name,不会触发,这时候就需要加deep: true。 用个电商购物车的例子,购物车是ref定义的数组,里面每个商品是对象,修改商品数量、选中状态都要更新总价格:

import { ref, watch, computed } from 'vue'
export default {
  setup() {
    const cart = ref([
      { id: 1, name: '手机', price: 5999, count: 1, selected: true },
      { id: 2, name: '耳机', price: 999, count: 2, selected: false }
    ])
    const totalPrice = ref(0)
    // 监听cart的深层变化,开启deep
    watch(cart, (newCart) => {
      totalPrice.value = newCart.reduce((sum, item) => {
        return item.selected ? sum + item.price * item.count : sum
      }, 0)
    }, { deep: true, immediate: true }) // 顺便加immediate初始化总价格
    return { cart, totalPrice }
  }
}

不过deep选项会递归遍历整个对象/数组,性能消耗比较大,如果只需要监听其中几个深层属性,还是建议用getter函数,性能会更好。

限制触发频率:flush

默认情况下,watch的回调会在Vue的DOM更新前执行,比如你修改了inputValue,watch先触发,然后errorMsg变,然后DOM才更新;但有时候需要等DOM更新完再执行回调(比如获取更新后的DOM元素尺寸),这时候就需要调整flush选项。 flush有三个值:

  1. pre(默认):DOM更新前执行

  2. post:DOM更新后执行

  3. sync:同步执行(数据一变立刻触发,不管Vue的更新队列,性能最差,尽量少用) 举个获取DOM元素尺寸的例子,比如有个div,内容会根据某个响应式数据变化,要获取变化后的div高度:

    import { ref, watch, nextTick } from 'vue'
    export default {
    setup() {
     const content = ref('短内容')
     const divHeight = ref(0)
     const divRef = ref(null)
     // 用flush: post,或者nextTick包裹回调,效果差不多
     watch(content, () => {
       // flush: post时不需要nextTick,但加了也没问题
       divHeight.value = divRef.value.offsetHeight
     }, { flush: 'post' })
     return { content, divHeight, divRef }
    }
    }

    这里也可以用nextTick(() => { divHeight.value = divRef.value.offsetHeight })代替flush: post,两者都是等DOM更新后执行,但flush: post更简洁,是专门为watch设计的。

清理副作用:onCleanup

这个选项可能新手用得少,但在处理异步操作(比如接口请求、定时器)时特别有用——如果watch在触发回调后,还没等异步操作完成,监听对象又变了,这时候上一次的异步操作可能会产生副作用(比如多次请求接口、旧数据覆盖新数据),这时候就需要用onCleanup清理上一次的副作用。 举个搜索建议的例子,用户输入关键词,过300ms(防抖,虽然有防抖插件,但这里先自己写个简单的理解onCleanup)才请求接口,如果用户输入得快,上一次的请求还没完成就应该取消:

import { ref, watch } from 'vue'
export default {
  setup() {
    const keyword = ref('')
    const suggestions = ref([])
    const isLoading = ref(false)
    watch(keyword, (newKeyword, oldKeyword, onCleanup) => {
      // 定义一个定时器,300ms后请求
      let timer = setTimeout(async () => {
        if (!newKeyword.trim()) {
          suggestions.value = []
          isLoading.value = false
          return
        }
        isLoading.value = true
        try {
          // 模拟接口请求
          const res = await new Promise(resolve => {
            setTimeout(() => {
              resolve([`${newKeyword}1`, `${newKeyword}2`, `${newKeyword}3`])
            }, 500)
          })
          suggestions.value = res
        } catch (error) {
          console.error('获取搜索建议失败', error)
        } finally {
          isLoading.value = false
        }
      }, 300)
      // 这里是重点:清理上一次的定时器
      onCleanup(() => {
        clearTimeout(timer)
        // 如果有真实的接口请求,可以用AbortController取消
        // abortController.abort()
      })
    })
    return { keyword, suggestions, isLoading }
  }
}

onCleanup的回调会在下一次watch触发前或者组件卸载时执行,完美解决了异步操作的副作用问题。

避坑指南:这5个坑你肯定踩过或者即将踩

聊完了用法和配置,再说说最容易踩的坑,这些都是群里小伙伴踩过无数次总结出来的,一定要记住。

坑1:直接监听ref的引用类型内部属性,没加deep或getter

刚才说过,ref的引用类型默认只监听.value的地址变化,内部属性变了不会触发,

import { ref, watch } from 'vue'
export default {
  setup() {
    const userRef = ref({ name: '张三' })
    // 直接监听userRef,修改name不会触发!
    watch(userRef, (newVal) => {
      console.log('userRef变了', newVal)
    })
    // 只有这样才会触发:
    // userRef.value = { name: '李四' }
    return { userRef }
  }
}

解决方法要么加deep: true,要么用getter函数监听内部属性() => userRef.value.name,后者性能更好。

坑2:直接监听reactive的单个属性,没加getter函数

import { reactive, watch } from 'vue'
export default {
  setup() {
    const user = reactive({ name: '张三' })
    // 这样写不会报错,但也不会触发!因为user.name是一个普通的字符串,不是响应式对象
    watch(user.name, (newVal) => {
      console.log('user.name变了', newVal)
    })
    return { user }
  }
}

解决方法必须用getter函数() => user.name,因为getter函数返回的值会被Vue追踪依赖。

坑3:直接监听整个reactive对象,以为oldVal有用

刚才反复提过,直接监听整个reactive对象,newVal和oldVal是同一个对象引用,因为修改内部属性不会改变对象地址,

import { reactive, watch } from 'vue'
export default {
  setup() {
    const user = reactive({ name: '张三', age: 25 })
    watch(user, (newVal, oldVal) => {
      console.log(newVal === oldVal) // 永远true
      console.log(oldVal.name) // 已经变成新的name了!
    })
    return { user }
  }
}

解决方法是用getter函数监听整个对象的深拷贝?不对,深拷贝性能差,除非你真的需要旧值,否则还是监听单个属性,或者用computed先返回一个新的对象引用?

import { reactive, watch, computed } from 'vue'
export default {
  setup() {
    const user = reactive({ name: '张三', age: 25 })
    // computed每次依赖变化都会返回一个新的对象引用(这里简单的展开,深层的话需要深拷贝)
    const userCopy = computed(() => ({ ...user }))
    watch(userCopy, (newVal, oldVal) => {
      console.log(newVal === oldVal) // false
      console.log(oldVal.name) // 旧的name
    })
    return { user }
  }
}

但如果是深层嵌套的对象,展开不够,需要用JSON.parse(JSON.stringify())或者lodash的cloneDeep,但性能消耗大,尽量避免这种需求。

坑4:开启immediate后,忘记oldVal是undefined

刚才基础用法里提过,开启immediate后第一次触发的oldVal是undefined,如果你在回调里用到了oldVal,比如比较新旧值的差异,一定要加判断,

import { ref, watch } from 'vue'
export default {
  setup() {
    const count = ref(0)
    watch(count, (newVal, oldVal) => {
      // 一定要加oldVal !== undefined的判断!
      if (oldVal !== undefined && newVal > oldVal + 5) {
        console.log('count增长太快了')
      }
    }, { immediate: true })
    return { count }
  }
}

不加的话第一次触发会报错或者逻辑错误。

坑5:频繁使用deep选项,导致性能问题

deep选项会递归遍历整个对象/数组,监听所有属性的变化,比如一个有1000个商品的购物车数组,每个商品有10个属性,加deep的话每次修改任何一个属性,Vue都会遍历一遍,性能消耗特别大。 解决方法就是“按需监听”:只监听你需要的属性,用getter函数;如果需要监听多个深层属性,可以把它们放在一个数组里监听;如果必须监听整个深层对象/数组,尽量让数据结构简单一点。

watch vs watchEffect:什么时候用哪个?

聊完了watch,很多人肯定会问watchEffect,毕竟都是Vue3的监听API,这里简单对比一下,帮你选对合适的API:

  1. watch
    • 需要明确指定监听对象
    • 可以获取新值和旧值
    • 默认不立即触发,需要手动加immediate
    • 适合有明确触发条件、需要旧值的场景
  2. watchEffect
    • 不需要明确指定监听对象,会自动追踪回调里用到的所有响应式数据
    • 不能获取旧值,只能获取新值
    • 默认立即触发(相当于watch加了immediate)
    • 适合“只要用到的响应式数据变了就执行,不需要旧值”的场景,比如根据搜索关键词请求接口(不需要防抖的话)

举个watchEffect的例子,还是刚才的购物车总价格:

import { ref, reactive, watchEffect } from 'vue'
export default {
  setup() {
    const cart = reactive([...])
    const totalPrice = ref(0)
    // 自动追踪cart里的selected、price、count
    watchEffect(() => {
      totalPrice.value = cart.reduce((sum, item) => {
        return item.selected ? sum + item.price * item.count : sum
      }, 0)
    })
    return { cart, totalPrice }
  }
}

这个写法比watch更简洁,因为不需要指定监听对象,也不需要加immediate。

今天咱们从Vue3 watch的基础用法(监听ref、reactive、多个数据)聊起,讲了4个进阶配置(immediate、deep、flush、onCleanup),点出了5个最容易踩的坑,还对比了watch和watchEffect的区别。 总结一下核心要点:

  • 监听基本类型用ref直接传,监听引用类型内部属性用getter函数或deep
  • 按需使用配置选项,immediate解决首次触发,deep解决深层监听(但注意性能),flush解决执行时机,onCleanup解决异步副作用
  • 避开5个常见坑,尤其是直接监听reactive单个属性、忘记oldVal是undefined、频繁用deep
  • watch和watchEffect选对:需要旧值、明确触发条件用watch;不需要旧值、自动追踪用watchEffect

好了,今天的内容就到这里,如果你还有其他Vue3的问题,欢迎在评论区留言讨论。

版权声明

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

热门