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

Vue3里的watch怎么正确使用?和computed/watchEffect比选哪个?

terry 3小时前 阅读数 53 #Vue

首先说啊,不少刚从Vue2转过来或者刚学Vue3的朋友,一打开Composition API的文档,先懵的就是组合式里有好几个能监听数据的东西:老面孔watch改了规则,还有watchEffect、watchPostEffect、watchSyncEffect这仨新冒出来的Effect后缀家族,甚至有时候还会纠结到底用watch还是computed,其实搞懂它们的设计初衷、参数细节、触发机制,这些问题根本不用挠头,今天咱们就从最基础的用法聊起,一步步说到进阶场景和避坑技巧。

Vue2和Vue3 watch的核心区别,别再踩这几个经典坑

聊Vue3的watch之前,得先提一嘴和Vue2的不一样,不然刚转过来的同学很容易用习惯旧写法踩新雷。

第一个最直观的区别,是监听方式的适配性,在Vue2里,你只能在选项式的watch对象里写监听函数,每个键要么是data/props里的路径字符串,要么是一个返回值的函数,然后值是处理函数或者带immediate、deep的配置对象,但到了Vue3的组合式API里,watch变成了一个需要显式从vue包里导入的工具函数,第一个参数支持的类型更多了:可以是单个ref/reactive对象的属性路径字符串?不对,得是函数形式!单个ref可以直接传,单个reactive要监听整个对象的话直接传reactive实例,但要监听reactive里的某个属性,就必须写一个getter函数返回这个属性,不然监听的是整个对象的引用?等下举个例子,比如你有个const user = reactive({ name: '张三', age: 25 }),如果直接写watch(user.name, (newVal) => {})是没用的,因为user.name是个普通字符串,不是响应式源,得改成watch(() => user.name, (newVal) => {})才对;但如果是const userName = ref('张三'),直接传watch(userName, (newVal) => {})就没问题,因为ref本身是带响应式引用的对象。

第二个区别是deep的默认值变化?哦不对,默认值其实都是false,但监听reactive对象的时候,Vue3的watch会自动开启deep,这个要注意!比如刚才的user例子,直接传watch(user, (newVal, oldVal) => {}),不管你改name还是age,都会触发,而且这时候newVal和oldVal是同一个对象引用,因为Vue3在监听整个reactive的时候,为了性能,不会做旧值的深拷贝,这和Vue2是一样的,但因为Vue3组合式里监听整个reactive是直接允许的(Vue2其实也允许,但选项式里可能大家习惯写路径),所以踩这个“新旧值相同”坑的人特别多,要是你需要拿到整个reactive对象变化前的旧值,要么监听时拆成getter函数加deep:true(比如watch(() => ({...user}), (newVal, oldVal) => {}, { deep: true }),这里用了展开运算符做浅拷贝,如果有嵌套对象的话要自己用JSON.parse(JSON.stringify())或者lodash的cloneDeep做深拷贝,不过后者要注意性能),要么干脆监听内部的单个属性。

第三个区别是immediate的用法没变,但触发时机可能和watchEffect搞混,后面聊Effect家族的时候会细讲,第四个区别是停止监听的方式,Vue2里是给watch的处理函数命名或者用vm.$watch返回的unwatch函数,组合式里更直接了,watch调用本身就会返回一个停止监听的函数,比如const stopWatch = watch(userName, () => {}),后面想停的时候直接stopWatch()就行,这个在组件卸载的时候其实会自动调用,但如果你在某个逻辑中需要提前手动停止,就很方便。

Vue3 watch的完整参数拆解,每个配置都有它的用处

搞清楚新旧区别之后,咱们再把组合式watch的参数一条一条掰碎了说,一共有四个参数:第一个是监听源,第二个是回调函数,第三个是可选的配置对象,第四个好像是很少用到的调试钩子?不过一般开发不用,咱们重点说前三个。

监听源:不止单个,还能批量监听多个响应式数据

第一个参数是最灵活的,刚才说了单个ref直接传,单个reactive属性用getter,整个reactive直接传,那如果要同时监听多个数据怎么办?比如同时监听user.name和user.age,或者同时监听userName(ref)和userAge(ref),这时候可以把监听源放在一个数组里传进去,回调函数的前两个参数也会变成对应顺序的新值数组和旧值数组,举个例子:

import { ref, reactive, watch } from 'vue'
const userName = ref('张三')
const userAge = ref(25)
const user = reactive({ name: '李四', age: 30 })
// 同时监听两个ref
watch([userName, userAge], ([newName, newAge], [oldName, oldAge]) => {
  console.log(`名字从${oldName}变成${newName},年龄从${oldAge}变成${newAge}`)
})
// 同时监听reactive的两个属性(用getter数组)
watch([() => user.name, () => user.age], ([newName, newAge], [oldName, oldAge]) => {
  console.log(`李四的名字从${oldName}变成${newName},年龄从${oldAge}变成${newAge}`)
})

批量监听的时候有个细节要注意:只要数组里的任意一个监听源发生变化,回调就会触发一次,不是等所有都变才触发,旧值数组里对应没变化的那个源的位置,会保留上一次变化后的值,第一次触发(如果开了immediate)的话,旧值数组里都是undefined或者初始值?等下,单个监听源开immediate的话,第一次触发oldVal是undefined,批量监听开immediate的话,oldVal数组里所有元素都是undefined吗?不对,你自己试一下就知道:单个ref初始是'张三',开immediate第一次触发,oldVal是undefined;批量监听[userName, userAge],userName初始'张三',userAge初始25,开immediate第一次触发,newVal是['张三',25],oldVal是[undefined, undefined],对的,这个要记住,别在immediate第一次触发的时候去判断oldVal做逻辑,会报错。

回调函数:可以拿到新旧值,还能做异步操作

第二个参数是回调函数,有三个参数:newVal(新值,如果是批量监听就是数组)、oldVal(旧值,批量监听是数组,第一次触发immediate的话全是undefined)、还有一个onCleanup函数,这个是Vue2 watch里没有的!哦对,刚才差点漏了新旧区别里的这个新特性,onCleanup函数太重要了,尤其是做异步操作的时候。

什么是onCleanup?简单说就是“清理上一次的副作用”,举个最常见的例子:搜索框实时输入关键词,然后请求后端接口,如果你不用节流防抖的话,输入速度快的时候会发很多请求,但哪怕用了节流防抖,有时候也会出现“前一个请求比后一个请求慢回来,导致搜索结果覆盖了最新的”问题,这时候onCleanup就派上用场了,比如你在watch的回调里发起一个fetch请求,给这个请求加个AbortController控制器,然后在onCleanup里调用controller.abort(),这样每次watch触发新的回调之前,都会先把上一次没完成的请求给取消掉,完美解决覆盖问题。

给你写个带AbortController和onCleanup的搜索框示例:

import { ref, watch } from 'vue'
const searchKeyword = ref('')
const searchResults = ref([])
const isLoading = ref(false)
const errorMsg = ref('')
watch(searchKeyword, async (newKeyword) => {
  // 清空上一次的错误和结果
  errorMsg.value = ''
  // 这里可以加节流防抖,比如用lodash的debounce
  // 但为了演示onCleanup,先不加
  // 每次新的请求前,先创建一个新的AbortController
  const controller = new AbortController()
  const signal = controller.signal
  // 定义清理函数:取消上一次的请求
  const cleanup = () => {
    controller.abort()
  }
  // 把清理函数传给onCleanup
  // 注意:onCleanup要在异步操作开始前调用!
  onCleanup(cleanup)
  if (!newKeyword.trim()) {
    searchResults.value = []
    return
  }
  isLoading.value = true
  try {
    const res = await fetch(`/api/search?keyword=${encodeURIComponent(newKeyword)}`, { signal })
    if (!res.ok) throw new Error('请求失败')
    searchResults.value = await res.json()
  } catch (err) {
    // 只有不是主动取消的请求,才显示错误
    if (err.name !== 'AbortError') {
      errorMsg.value = err.message
      searchResults.value = []
    }
  } finally {
    isLoading.value = false
  }
})

看到没?这里的关键是onCleanup必须在异步操作(比如fetch)开始之前调用,这样Vue才能在下次回调触发时先执行这个清理函数,除了取消请求,onCleanup还能用来清除定时器、取消事件监听这些,都是清理副作用的场景。

配置对象:immediate、deep、flush,三个最常用的都得会

第三个参数是可选的配置对象,里面有几个常用的属性:immediate、deep、flush,还有flush之外的其他几个调度相关的,但开发中一般用不到,咱们说三个核心的。

第一个是immediate,刚才提过几次了,作用是让watch在组件挂载(或者说在watch声明之后的第一次响应式更新触发前?不对,得看flush的配置)的时候立即执行一次回调,不需要等监听源变化,这个场景也很常见,比如刚才的搜索框,如果初始化的时候searchKeyword有值(比如从路由参数里拿的),那就需要开immediate,让组件一挂载就去请求初始数据。

第二个是deep,作用是开启深层监听,不管监听源是ref(ref里存的是对象的话)还是getter返回的对象,只要对象内部的属性发生变化(不管嵌套多少层),都会触发回调,刚才说过监听整个reactive对象的时候会自动开启deep,但如果是监听getter返回的reactive属性的属性(比如() => user.address.city,这里user是reactive,address也是reactive里的对象),这时候不需要开deep,因为getter已经明确指向了嵌套属性;但如果是监听getter返回的整个user对象的浅拷贝(比如() => ({...user})),这时候如果user.address.city变了,浅拷贝的对象里的address还是原来的引用,所以不会触发,这时候就要手动加deep:true了,不过开deep要注意性能,因为Vue要遍历整个对象的所有嵌套属性,建立依赖关系,如果对象特别大(比如有几千个键的列表数据),最好还是只监听具体需要的属性,不要随便开deep。

第三个是flush,这个是Vue3 watch里新加的调度配置,作用是控制回调函数的触发时机,有三个可选值:'pre'(默认值)、'post'、'sync',这个和后面要聊的Effect家族对应上了:'pre'对应watchEffect默认的flush,'post'对应watchPostEffect,'sync'对应watchSyncEffect,那这三个值到底有啥区别?咱们举个DOM操作的例子,因为DOM操作是最容易体现时机差异的:

  • flush: 'pre'(默认):回调会在Vue的组件更新之前触发,这时候DOM还没更新,如果你在回调里直接访问DOM元素的最新状态,比如获取input的value或者div的高度,拿到的还是更新前的旧值。
  • flush: 'post':回调会在Vue的组件更新之后触发,这时候DOM已经更新好了,直接访问就能拿到最新状态。
  • flush: 'sync':回调会在监听源变化的同步代码执行完之后立即触发,不管Vue的组件更新队列,触发时机非常早,但性能损耗也最大,因为会打断Vue的批量更新,一般只有在非常特殊的场景(比如需要在DOM更新前做一些高优先级的同步状态处理)才会用,平时尽量别用。

给你写个演示flush时机的小例子,你可以复制到Vue3的单文件组件里试试:

<template>
  <div ref="countDiv">{{ count }}</div>
  <button @click="count++">+1</button>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
const count = ref(0)
const countDiv = ref(null)
onMounted(() => {
  console.log('onMounted DOM高度:', countDiv.value.offsetHeight)
})
// 默认 flush: 'pre'
watch(count, (newCount) => {
  console.log('默认pre: 新count是', newCount, ', DOM高度:', countDiv.value?.offsetHeight)
})
// flush: 'post'
watch(count, (newCount) => {
  console.log('flush post: 新count是', newCount, ', DOM高度:', countDiv.value?.offsetHeight)
}, { flush: 'post' })
// flush: 'sync'
watch(count, (newCount) => {
  console.log('flush sync: 新count是', newCount, ', DOM高度:', countDiv.value?.offsetHeight)
}, { flush: 'sync' })
</script>

第一次点击按钮,count从0变成1,控制台的输出顺序应该是:

  1. flush sync: 新count是 1, DOM高度: (假设count初始是0,高度是20,这里还是20,因为DOM还没更新,但sync是同步触发,所以先打)
  2. 默认pre: 新count是 1, DOM高度: 20
  3. onMounted之后组件更新,div的高度可能变(比如加了padding或者字体变化,这里假设不变还是20,但逻辑上是更新后的)
  4. flush post: 新count是 1, DOM高度: 20 如果count变化会导致DOM高度明显变化的话,比如count每次加1,div的高度就加10px,那pre和sync拿到的是旧高度,post拿到的是新高度,这个区别就很明显了。

Vue3 watch vs computed vs watchEffect家族,到底选哪个?

聊完watch的所有细节,咱们终于到了大家最纠结的部分:这四个能处理响应式数据的东西(watch、computed、watchEffect、watchPostEffect、watchSyncEffect),每个场景到底选谁?别着急,咱们先把它们各自的设计初衷和核心特点列出来,然后给你一个清晰的选择流程图一样的东西,你对着选就行。

先理清楚各自的核心特点

  • computed:计算属性,有返回值,依赖其他响应式数据,只有依赖的数据变化时才会重新计算,而且有缓存——只要依赖没变,多次访问computed的值都会直接返回上一次的缓存结果,不会重新执行计算逻辑,computed的计算函数必须是纯函数,不能有副作用(比如不能修改其他响应式数据、不能发起请求、不能操作DOM)。
  • watch:监听器,没有返回值,专门用来处理响应式数据变化时的副作用(比如修改其他数据、发起请求、操作DOM、存储到localStorage),可以拿到新旧值,默认懒执行(第一次不触发,除非开immediate),可以指定监听源(精确控制谁变化才触发)。
  • watchEffect:Effect监听器,没有返回值,也是用来处理副作用的,但和watch不一样的是:它不能指定监听源,而是自动追踪回调函数内部用到的所有响应式数据,只要用到的任何一个数据变化,就会触发回调;默认立即执行一次(不需要开immediate),回调函数里拿不到旧值;默认flush是'pre'(组件更新前触发)。
  • watchPostEffect:其实就是watchEffect加了{ flush: 'post' }的简写,自动追踪依赖,立即执行,组件更新后触发,适合需要操作更新后DOM的副作用场景。
  • watchSyncEffect:是watchEffect加了{ flush: 'sync' }的简写,自动追踪依赖,立即执行,同步触发,性能损耗大,尽量别用。

给你一个清晰的选择判断顺序

别死记硬背,按这个顺序问自己,很快就能选对:

  1. 我需要的是一个根据其他数据生成的新值,还是要处理数据变化后的操作?
    • 如果是生成新值:选computed,记得别写副作用,要用纯函数,比如根据购物车的商品列表和单价,计算总价;根据搜索关键词和商品列表,过滤显示结果。
    • 如果是处理操作(副作用):进入下一个问题。
  2. 处理操作的时候,我需要用到数据变化前的旧值吗?或者我只想精确监听某几个特定的数据变化,不想追踪回调里所有用到的响应式数据?
    • 如果是两者任一:选watch,然后根据需要加immediate、deep、flush配置,比如搜索框的例子,需要取消上一次的请求(其实和旧值无关,但需要精确监听搜索关键词,不想因为回调里修改了searchResults、isLoading这些数据就重新触发,所以用watch);比如监听用户年龄变化,当年龄从17变成18的时候,弹出一个“成年啦”的提示,这时候必须要旧值。
    • 如果是两者都不需要:进入下一个问题。
  3. 处理操作的时候,我需要操作Vue更新后的DOM吗?
    • 如果是需要:选watchPostEffect,比如监听一个数据列表,列表变化后需要滚动到列表底部;监听一个弹窗的显示状态,弹窗显示后需要聚焦到输入框。
    • 如果是不需要:选watchEffect,比如监听用户的个人信息,只要信息变化就自动保存到localStorage;监听主题色,只要主题色变化就修改document.body的class。
  4. 有没有非常特殊的场景,必须在数据变化的同步代码执行完之后立即触发操作?
    • 如果是:选watchSyncEffect,但一定要注意性能,别滥用。

举几个具体的场景对比

光说判断顺序可能有点空,咱们举几个实际开发中常见的场景,看看选谁:

  • 场景1:购物车有商品列表数组,每个商品有count和price,需要计算总价。→ 选computed,因为是生成新值,而且有依赖缓存,购物车商品很多的话,缓存很重要。
  • 场景2:用户修改了个人资料表单,只要表单里的任意一个字段变化,就自动保存到localStorage,不需要旧值。→ 选watchEffect,自动追踪表单里的所有ref/reactive,立即执行(第一次把初始值存进去),不用指定监听源。
  • 场景3:用户修改了个人资料表单里的手机号,只有手机号变化的时候,才去请求后端验证手机号是否已被注册,需要取消上一次的验证请求。→ 选watch,精确监听手机号,不需要追踪表单里的其他字段,需要onCleanup取消请求。
  • 场景4:聊天界面有消息列表数组,只要有新消息进来,就自动滚动到聊天窗口的最底部。→ 选watchPostEffect,自动追踪消息列表,立即执行(第一次挂载后滚动到底部),组件更新后(新消息已经渲染到DOM里了)再滚动。
  • 场景5:路由参数变化,需要根据新的路由参数去请求页面数据,需要旧的路由参数来判断是不是同一个页面的刷新或者参数相同的重复请求。→ 选watch,监听路由参数,拿到新旧值判断。

Vue3 watch的进阶避坑技巧,别再犯这些低级错误

最后咱们再聊几个进阶的避坑技巧,都是很多人(包括我自己刚学Vue3的时候)踩过的坑:

避坑1:别用watch监听整个reactive对象来获取旧值

刚才提过,监听整个reactive对象的时候,newVal和oldVal是同一个引用,因为Vue不会做深拷贝,所以拿不到旧值,要是你真的需要整个对象的旧值,要么拆成单个属性监听,要么用getter返回浅拷贝/深拷贝加deep:true,但深拷贝要注意性能,别用在大对象上。

避坑2:watch的getter函数里别写副作用

watch的第一个参数如果是getter函数,这个函数必须是纯函数,只能用来返回要监听的响应式数据,不能修改其他数据、不能发起请求、不能操作DOM,不然会导致无限循环或者其他不可预期的问题,比如你不能写watch(() => { count.value++; return count.value }, () => {}),这会导致count一直加1,无限触发watch。

避坑3:别滥用flush: 'sync'

刚才也提过,flush: 'sync'会打断Vue的批量更新,导致性能损耗非常大,因为Vue本来会把多个响应式数据的变化合并成一次组件更新,现在每变一个就触发一次回调和可能的更新,对大组件或者复杂页面来说,卡顿会很明显,只有在非常特殊的场景(比如需要在DOM更新前,根据某个数据的变化,立即修改另一个高优先级的数据,避免DOM闪烁)才会用,平时尽量别碰。

避坑4:批量监听的时候,别在旧值数组里判断某个元素是否变化

比如你批量监听[userName, userAge],回调里的oldVal数组是[oldName, oldAge],但如果只有userName变化,oldAge还是上一次的userAge变化后的旧值,不是初始值,所以不能直接写if (oldName !== newName && oldAge !== newAge)来判断两个都变,因为oldAge可能不是初始的25,而是上次变成30后的旧值,如果你需要判断多个监听源中哪些发生了变化,可以自己在组件里维护一个旧值的ref对象,每次watch触发的时候对比更新。

避坑5:组件卸载前,记得手动停止不需要的watch(虽然大部分时候不需要)

虽然Vue3的组合式API里,watch是在setup()或者