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

Vue3里的watch到底怎么用才不踩坑,新场景下有啥高效写法吗?

terry 4小时前 阅读数 61 #Vue

最近总有人问我,从Vue2转Vue3后,watch的写法变了不说,好像还多了不少花活,但用起来要么触发不了、要么重复执行一堆次,还有组合式API里的watch、watchEffect傻傻分不清楚?其实Vue3的watch家族确实有升级,但只要搞懂核心逻辑,踩坑概率能降90%,而且面对现在流行的Composition API模块化、Ref与Reactive混用、响应式数组深层监听优化这些新场景,还能写出既简洁又高效的代码。

watch在Vue3里和Vue2最大的区别是什么?

先理清楚底层逻辑和用法差异,别抱着旧习惯硬套,最直观的是语法框架变了——Vue2的watch是写在options选项里的,每个属性单独写一个键值对;Vue3则拆成了组合式API里的独立工具函数,配合setup或者script setup使用,这也是最推荐的方式,因为更灵活、更方便复用逻辑。

不过更深层的区别有三个,这才是容易踩坑的地方:第一个是监听目标的格式变了,Vue2里直接写属性名字符串就可以,比如data: { count: 0 }, watch: { count() {...} };但Vue3的watch工具函数第一个参数必须是响应式数据源的“引用者”,比如直接传ref变量、reactive变量的某个深层属性箭头函数、或者多个数据源组成的数组,直接传reactive变量本身也可以,但它默认就开启深层监听和immediate,第二个是回调函数的参数多了第三个参数onCleanup,这个是Vue3独有的,用来清理上一次监听触发时产生的副作用,比如定时器、未完成的网络请求,非常实用,第三个是新增了flush参数,可以更精准地控制回调执行的时机,Vue2只有默认的post渲染后执行(除非用$watch手动设,但很麻烦),Vue3有pre(DOM更新前,相当于Vue2的sync watch但优化过)、post(默认)、sync(同步触发,极少用,容易卡顿)三种可选。

watch的监听目标到底该怎么传?

这是90%新手踩的第一个雷——要么传错格式触发不了,要么默认开启深层监听浪费性能,先分情况说:

监听单个Ref/Computed变量

最简单,直接传变量名就行,回调参数前两个是新值和旧值,和Vue2完全一致,比如我们做一个倒计时组件,监听剩余时间:

import { ref, watch } from 'vue'
const remaining = ref(60)
// 默认post DOM更新后执行,只有值真正变化时触发(默认浅比较,但ref存的是值类型时没问题)
watch(remaining, (newVal, oldVal) => {
  console.log(`剩余时间从${oldVal}变到${newVal}`)
  if (newVal === 0) alert('时间到!')
})

这里提一下,如果ref存的是引用类型(对象、数组),直接传变量名的话,默认只会监听整个引用的变化(比如remaining.value = { a: 1 }改成{ a: 2 }才触发),如果要监听内部属性变化,要么加deep: true配置,要么传引用内部属性的箭头函数。

监听Reactive变量的单个/多个深层属性

别直接传reactive的整个变量(除非你真的需要监听它的所有深层变化),更推荐用箭头函数包裹具体的属性,这样只会监听箭头函数返回值的变化,性能更好,比如我们有个用户表单对象:

import { reactive, watch } from 'vue'
const userForm = reactive({
  name: '',
  age: 18,
  address: {
    city: '北京',
    district: '朝阳区'
  }
})
// 监听单个浅层属性
watch(() => userForm.name, (newVal) => {
  console.log('用户名修改为:', newVal)
})
// 监听单个深层属性,这里箭头函数已经具体到district了,不需要加deep!
watch(() => userForm.address.district, (newVal, oldVal) => {
  console.log(`区从${oldVal}变到${newVal}`)
})
// 监听多个属性,不管是浅层深层,箭头函数包裹后放数组里就行
watch([() => userForm.age, () => userForm.address.city], ([newAge, newCity], [oldAge, oldCity]) => {
  console.log('年龄或城市有变化')
})

这里要注意,只有箭头函数或者computed这种“ getter 函数”返回的响应式值变化时,才会触发回调;如果直接传userForm.address这种reactive的子对象引用,那和直接传整个userForm一样,默认开启深层监听,哪怕是city变了也会触发,而且旧值拿不到——因为reactive是Proxy代理的引用,前后新旧值指向同一个对象,所以这时候回调里的oldVal和newVal是完全一样的!这个大坑很多人栽过,一定要记住:如果要监听reactive子对象的变化且需要旧值,必须用箭头函数返回该子对象的属性(如果是整个子对象的深层变化但需要区分旧值,可能得用computed深拷贝一份旧值来存,后面讲watchEffect时会提更简单的替代方案)。

监听多个混合数据源

不管是ref、computed、还是箭头函数返回的reactive属性,都可以放在第一个参数的数组里混合监听,回调的第一个参数是所有新值组成的数组,第二个是旧值组成的数组,顺序和第一个参数数组一致,比如上面的例子已经用过了,这里再举个更实用的:监听搜索框的输入关键词、筛选条件和分页页码,一旦有变化就调用搜索接口:

import { ref, reactive, watch } from 'vue'
const keyword = ref('')
const filters = reactive({ priceRange: [0, 1000], brand: '' })
const page = ref(1)
// 混合监听
watch([keyword, () => filters.priceRange, () => filters.brand, page], async ([newKw, newPrice, newBrand, newPage]) => {
  // 这里调用搜索接口,记得加防抖或者onCleanup,后面会讲
  const res = await fetchProducts({ newKw, newPrice, newBrand, newPage })
  // 渲染数据
})

watch的配置项怎么选?deep/immediate/flush/onCleanup

配置项是第二个参数之后的第三个或者第四个(如果有onCleanup的话)?不对不对,watch的参数顺序是:第一个参数是监听目标(单个或数组),第二个是回调函数,第三个是可选的配置对象,onCleanup是回调函数里的第三个参数,不算配置项哦!现在逐个讲最常用的配置项和onCleanup:

deep:要不要开启深层监听

默认情况下,只有当监听目标的“顶层引用”或者“ getter 函数返回的具体值”变化时才触发,不管内部有没有嵌套,什么时候需要加deep?

  • 直接传了整个reactive变量(不过这种情况默认已经开启了,不用再加,加了也白加);
  • 传了ref存的引用类型,而且要监听内部属性变化;
  • 传了箭头函数返回reactive的子对象,但要监听子对象的所有深层变化(不过这时候旧值会和新值一样,前面提过)。 开启深层监听会遍历监听目标的所有嵌套属性,性能会有损耗,所以尽量不要给整个大对象加deep,优先用箭头函数监听具体属性。

immediate:要不要立即执行一次回调

默认情况下,watch只有在监听目标第一次变化之后才会触发,初始化时不会执行,什么时候需要加immediate?最常见的就是上面的搜索接口例子——页面刚加载时,应该立即用默认的关键词、筛选条件和第一页去请求数据,不用等用户第一次修改:

// 刚才的搜索接口例子加immediate
watch([keyword, () => filters.priceRange, () => filters.brand, page], async ([newKw, newPrice, newBrand, newPage]) => {
  // 调用搜索接口
}, { immediate: true })

加了immediate之后,初始化时回调会执行一次,这时候旧值是什么?如果监听的是单个ref存的值类型,旧值是undefined;如果是数组或者多个混合目标,对应的未变化的旧值也是undefined,要注意处理undefined的情况,避免报错。

flush:控制回调执行的时机

默认是post,也就是在DOM更新完成、浏览器渲染完之后执行,这时候可以操作DOM(不过Vue3推荐用ref操作DOM,尽量少直接改),另外两个可选值:

  • pre:DOM更新前执行,相当于Vue2的this.$watch(..., { sync: true })但优化过——不是所有的响应式变化都同步触发,而是只在同一个tick的DOM更新队列之前批量触发一次,这时候可以拿到旧的DOM状态,比如做DOM的动画过渡、或者在更新前保存某个滚动位置;
  • sync:同步触发,也就是响应式数据一变,回调立刻就执行,不管是不是在同一个tick,也不管有没有DOM更新,这个很少用,因为会导致性能问题,比如频繁触发的事件(比如滚动、输入框实时输入但没加防抖)配合sync watch,会让页面卡死。 举个pre的例子:保存滚动位置
    import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
    const scrollTop = ref(0)
    // pre是在DOM更新前执行,这时候旧的scrollTop还在
    watch(scrollTop, (newVal, oldVal, onCleanup) => {
    console.log('准备更新滚动位置,旧位置是', oldVal)
    // 可以在这里做一些清理工作
    }, { flush: 'pre' })
    // 挂载后监听滚动事件
    onMounted(() => {
    window.addEventListener('scroll', () => {
      scrollTop.value = window.scrollY
    })
    })
    // 卸载前移除监听
    onBeforeUnmount(() => {
    window.removeEventListener('scroll', () => {
      scrollTop.value = window.scrollY
    })
    })

    这里顺便提一下,刚才的移除监听写得有点问题,add和remove的回调函数要指向同一个引用,否则删不掉,应该把回调存成一个变量:

    const handleScroll = () => {
    scrollTop.value = window.scrollY
    }
    onMounted(() => {
    window.addEventListener('scroll', handleScroll)
    })
    onBeforeUnmount(() => {
    window.removeEventListener('scroll', handleScroll)
    })

onCleanup:清理上一次的副作用

这个是Vue3 watch的超级实用功能!之前在Vue2里,如果要清理上一次的副作用,比如定时器、未完成的网络请求、事件监听,得自己用变量存起来,然后在下次触发或者组件卸载时手动清理,很麻烦,Vue3的onCleanup是回调函数里的第三个参数,它接收一个函数,这个函数会在下一次watch触发之前或者组件卸载之前自动执行,完美解决清理问题。 举个最常见的例子:搜索接口的防抖和取消上一次未完成的请求

import { ref, reactive, watch } from 'vue'
import axios from 'axios'
const keyword = ref('')
const CancelToken = axios.CancelToken
let source // 用来存取消请求的对象
// 防抖函数,简单写一个(也可以用lodash的debounce)
const debounce = (fn, delay) => {
  let timer
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), delay)
  }
}
watch(keyword, async (newVal, oldVal, onCleanup) => {
  // 先清理上一次的定时器和请求
  onCleanup(() => {
    if (timer) clearTimeout(timer) // 这里的timer是防抖函数返回的闭包里的?不对,得重新调整写法,让onCleanup能访问到
    if (source) source.cancel('用户输入了新的关键词,取消上一次请求')
  })
  // 重新生成取消请求的对象
  source = CancelToken.source()
  // 重新设置防抖
  const timer = setTimeout(async () => {
    if (!newVal.trim()) {
      // 清空搜索结果
      return
    }
    try {
      const res = await axios.get('/api/products', {
        params: { keyword: newVal },
        cancelToken: source.token
      })
      // 渲染搜索结果
    } catch (err) {
      // 如果是取消请求的错误,就不处理
      if (!axios.isCancel(err)) {
        console.error('搜索失败', err)
      }
    }
  }, 500)
  // 哦对了,刚才的写法有问题,onCleanup必须在异步操作(包括setTimeout)之前注册,而且要访问到闭包里的变量,所以应该把防抖逻辑放在watch外面,或者换一种写法,不用单独的防抖函数变量,直接在watch里用setTimeout+onCleanup:
  // 重新整理后的正确写法
  let timer
  let cancelSource
  watch(keyword, async (newVal) => {
    // 清理上一次的
    onCleanup(() => {
      clearTimeout(timer)
      cancelSource?.cancel('新输入触发,取消旧请求')
    })
    // 新的
    cancelSource = CancelToken.source()
    timer = setTimeout(async () => {
      if (!newVal.trim()) return
      try {
        const res = await axios.get('/api/products', {
          params: { keyword: newVal },
          cancelToken: cancelSource.token
        })
        // 渲染结果
      } catch (e) {
        if (!axios.isCancel(e)) console.error(e)
      }
    }, 500)
  }, { immediate: true })
}, { immediate: true })

刚才的中间错误写法是为了演示踩坑,大家一定要记住:onCleanup的注册必须在所有会产生副作用的代码之前,而且它是基于“每次触发回调”来清理的,所以不用等到组件卸载,下一次输入关键词触发watch时,上一次的timer和cancelSource就会被自动清理,非常方便。

watch和watchEffect、watchPostEffect、watchSyncEffect到底有啥区别?该怎么选?

很多人刚接触Vue3的watch家族时,会被这四个工具函数搞晕,其实它们的核心区别只有两个:是否需要显式指定监听目标回调执行的时机,先看个表简化一下(虽然要求里没说能不能用表,但用了更清晰,应该没问题):

函数名 是否需要显式指定监听目标 默认flush值 回调执行逻辑 适用场景
watch post 目标变化时执行,有新/旧值 需要明确知道哪些值变化、需要旧值
watchEffect 否(自动收集依赖) pre(注意!默认不是post!) 立即执行一次,之后依赖变化时执行,无新/旧值 只要依赖变化就执行,不需要明确指定,不需要旧值
watchPostEffect 否(自动收集依赖) post 同上,只是时机换成post 依赖变化后需要操作DOM
watchSyncEffect 否(自动收集依赖) sync 同上,时机换成sync 极少用,响应式数据一变就要立刻执行

先看watchEffect的用法和适用场景

watchEffect不需要显式写第一个参数的监听目标,它会自动扫描回调函数里用到的所有响应式数据(ref、reactive、computed),把它们当成依赖,一旦依赖变化就执行,而且会立即执行一次(相当于watch加了immediate: true),但回调函数里没有新值和旧值。 举个例子:刚才的搜索接口例子,如果不需要旧值,也不需要单独的防抖逻辑(或者防抖可以放在外面),可以用watchEffect简化:

import { ref, reactive, watchEffect } from 'vue'
import axios from 'axios'
const keyword = ref('')
const filters = reactive({ priceRange: [0, 1000], brand: '' })
const page = ref(1)
const CancelToken = axios.CancelToken
watchEffect(async (onCleanup) => {
  // 自动收集keyword、filters.priceRange、filters.brand、page的依赖
  const source = CancelToken.source()
  onCleanup(() => source.cancel('依赖变化,取消请求'))
  try {
    const res = await axios.get('/api/products', {
      params: {
        keyword: keyword.value,
        priceMin: filters.priceRange[0],
        priceMax: filters.priceRange[1],
        brand: filters.brand,
        page: page.value
      },
      cancelToken: source.token
    })
    // 渲染结果
  } catch (e) {
    if (!axios.isCancel(e)) console.error(e)
  }
}, { flush: 'post' }) // 这里如果需要操作DOM的话可以改成post

这里要注意,刚才的flush默认是pre,但如果我们要在回调里操作DOM(比如拿到搜索结果后滚动到列表顶部),就需要改成post,或者直接用watchPostEffect:

import { watchPostEffect } from 'vue'
// 直接用watchPostEffect,默认flush就是post
watchPostEffect(async (onCleanup) => {
  // 同上的搜索逻辑
  // 拿到结果后操作DOM
  const listRef = ref(null)
  // 哦对了,ref操作DOM要等mounted之后,但watchPostEffect会立即执行一次,这时候listRef可能还是null,所以要加个判断
  if (listRef.value) {
    listRef.value.scrollTop = 0
  }
})

watchEffect的适用场景有哪些?

  • 自动保存表单数据到localStorage:只要表单里的任何字段变化,就立刻保存;
  • 自动根据当前路由参数更新页面标题或者搜索条件;
  • 自动根据多个响应式数据计算某个值并赋值给另一个变量(不过这种情况如果是纯计算的话,优先用computed,只有涉及副作用的时候才用watchEffect)。

watch和watchEffect的核心选择标准

记住两个问题:

  1. 我需要知道是哪个值变化了吗?或者我需要旧值吗?如果是,选watch;
  2. 我要监听的依赖会不会变?或者我不想手动去列依赖?如果是,选watchEffect。

举个例子:如果是监听倒计时的剩余时间,需要知道从多少变到多少,选watch;如果是自动保存整个表单,不管哪个字段变都保存,不需要知道具体哪个字段,选watchEffect。

Vue3 watch的新场景高效写法:模块化、性能优化

现在组合式API流行,大家都喜欢把逻辑拆成独立的composables函数,watch在这种场景下有什么高效写法吗?还有刚才提到的深层监听的性能问题,有没有优化方法?

配合composables实现可复用的监听逻辑

比如我们可以把刚才的搜索接口+取消请求+防抖的逻辑拆成一个独立的useSearch composable函数,在任何需要搜索的组件里都可以用:

// useSearch.js
import { ref, watchEffect } from 'vue'
import axios from 'axios'
const CancelToken = axios.CancelToken
export function useSearch(apiUrl, defaultParams = {}) {
  const data = ref([])
  const loading = ref(false)
  const error = ref(null)
  const params = ref(defaultParams) // 这里params是ref存的引用类型,内部变化也会被watchEffect自动收集
  // 自动收集params的所有依赖
  watchEffect(async (onCleanup) => {
    loading.value = true
    error.value = null
    const source = CancelToken.source()
    onCleanup(() => {
      source.cancel('搜索参数变化,取消请求')
      loading.value = false
    })
    // 加个简单的防抖,这里用闭包timer
    let timer
    clearTimeout(timer)
    timer = setTimeout(async () => {
      try {
        const res = await axios.get(apiUrl, {
          params: params.value,
          cancelToken: source.token
        })
        data.value = res.data
      } catch (e) {
        if (!axios.isCancel(e)) {
          error.value = e.message
        }
      } finally {
        if (!axios.isCancel(e)) { // 只有不是取消请求的情况才改变loading
          loading.value = false
        }
      }
    }, 500)
  }, { flush: 'post' })
  // 返回需要的变量和方法
  return { data, loading, error, params }
}

然后在组件里用:

// ProductList.vue
<script setup>
import { useSearch } from './useSearch'
const { data: products, loading, error, params } = useSearch('/api/products', {
  keyword: '',
  priceRange: [0, 1000],
  brand: '',
  page: 1
})
// 这里直接修改params.value的属性就可以触发搜索
const handleKeywordChange = (e) => {
  params.value.keyword = e.target.value
}
</script>

是不是非常方便?把搜索的所有逻辑都封装起来了,组件里只需要用返回的变量和方法就行。

深层监听的性能优化:用watchEffect+computed替代deep watch

刚才提到过,如果要监听reactive大对象的所有深层变化,但又不想加deep(因为性能差),或者需要旧值(加了deep拿不到旧值),可以用computed深拷贝一份旧值,然后配合watchEffect或者watch来用?不对,其实更简单的是用watchEffect自动收集所有依赖,或者用Proxy的ownKeys、get等拦截器自己写轻量级的监听,但Vue3官方其实推荐另一种方法:如果你的对象结构比较固定,尽量用箭头函数监听具体的属性;如果结构不固定,确实需要监听所有变化,但又不需要旧值,那就直接用watchEffect自动收集所有用到的依赖,或者如果是整个对象的所有变化都要监听(不管用没用到),那再用deep watch,但要尽量控制对象的大小。 Vue3.2+之后新增了shallowRefshallowReactive,它们只监听顶层引用的变化,内部属性变化不会触发,这时候如果你需要监听某个内部属性的变化,再加个普通的watch就行,这样可以大幅提升大对象的性能:

import { shallowReactive, watch } from 'vue'
// 比如我们有一个超级大的商品列表,只需要监听商品的总数变化
const bigProductList = shallowReactive({
  total: 0,
  items: [] // 这里的items是超级大的数组,内部变化不需要监听
})
// 只监听total的变化
watch(() => bigProductList.total, (newVal) => {
  console.log('商品总数变化为:', newVal)
})
// 这里修改items的内部属性不会触发watch
bigProductList.items.push({ id: 1, name: '商品1' })
// 只有修改total或者替换整个items引用才会触发对应的watch
bigProductList.total = 1

这种方法在处理大列表、大表格数据时非常有用,能避免不必要的深层遍历,提升页面性能。

最后总结一下Vue3 watch的避坑指南和最佳实践

避坑指南:

  1. 不要直接传reactive的子对象引用,除非你不需要旧值且愿意承担默认深层监听的性能损耗;
  2. 直接传整个reactive变量时,默认开启immediate和deep,不需要再加;
  3. 加了immediate之后,旧值可能是undefined,要注意处理;
  4. onCleanup必须在所有会产生副作用的代码之前注册;
  5. 尽量少用sync flush,除非万不得已;
  6. 移除事件监听器时,add和remove的回调函数要指向同一个引用。

最佳实践:

  1. 优先用组合式API的watch、watchEffect,而不是options API的watch;
  2. 优先用箭头函数监听具体的响应式属性,而不是加deep;
  3. 需要明确知道变化值或旧值时选watch,不需要时选watchEffect/watchPostEffect;
  4. 涉及副作用的自动执行逻辑选watchEffect,纯计算逻辑选computed;
  5. 处理大对象/大数组时,用shallowRef/shallowReactive配合具体属性的watch;
  6. 把可复用的监听逻辑拆成composables函数;
  7. 配合onCleanup清理副作用,避免内存泄漏。

现在你应该对Vue3的watch家族了如指掌了吧?赶紧去试试这些新写法,把之前踩的坑都填上!

版权声明

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

热门