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

Vue3项目里怎么让watch只执行一次?有几种靠谱的实现方式?

terry 1小时前 阅读数 22 #Vue
文章标签 一次性watch

做Vue3开发的时候,大家肯定都用过watch监听数据变化,但有时候不需要它每次数据变都触发——比如初始化完组件之后只看一次某个接口返回的状态,或者用户第一次修改表单提交前做个预检查就行,这时候得让watch乖乖听话只跑一次,别瞎凑热闹,那到底有哪些稳当的方法呢?今天就掰开揉碎了讲,从官方API的原生用法,到大家自己平时攒的小技巧,再到一些特殊场景的处理,都覆盖到,保证你看完能直接用到项目里。

先提个醒:watch、watchEffect和computed的区别别搞混

在讲具体实现之前,得先明确一下——不是所有“监听一次”的需求都适合用watch哦?Vue3有三个核心的响应式监听/计算工具,得先选对基础,不然可能反而绕弯路。

首先说computed,它主要是用来依赖响应式数据生成新数据的,默认是缓存的,只有依赖变了才会重新计算,但computed的回调不能有副作用(比如发请求、改DOM、存本地存储这些),所以如果你的需求是“等某个数据到位/改了之后做一件只有一次的、带副作用的事”,computed直接排除。

然后是watchEffect,它是自动追踪依赖的,不需要指定要监听谁,回调里用了啥响应式数据就自动监听啥,而且默认会立即执行一次(immediate: true),那如果只是“第一次依赖变化执行一次,之后不管咋变都不动”,理论上也可以用watchEffect,但官方没给原生的“只执行一次就停止追踪”的配置,得自己加逻辑,和用原生配置的watch比起来会多写两行,不过特殊场景下(比如不知道要监听具体哪个嵌套属性,只知道在某个大对象里找符合条件的变化),watchEffect加手动停止反而更方便。

最后就是我们今天的主角watch了:它是显式指定监听源的,默认惰性执行(第一次不会跑,等数据真变了才触发),官方原生给了好几个和“执行时机”“监听范围”相关的配置,只执行一次”的需求,大多数情况下用watch是最顺的。

第一种方法:官方原生最靠谱——用watch的onCleanup

这个应该是Vue3官方文档里偷偷藏着但非常好用的小技巧吧?很多人可能只知道watch的immediate、deep配置,不知道watch回调里还能接收一个清理函数,这个清理函数本来是用来干啥的呢?比如你在watch里发了个请求,下一次数据又变了要发新请求,这时候得把上一次没完成的请求取消掉,清理函数就是干这个的——每次watch触发之前(包括第一次触发前,如果有immediate的话),都会先调用上一次注册的清理函数。

那怎么用它实现只执行一次呢?原理很简单:第一次执行watch回调的时候,直接调用停止监听的函数——停止监听的函数其实就是watch本身的返回值!等下,怎么在回调里拿到watch的返回值?这里得用个小变量先存着。

举个例子吧,比如我们有个用户登录状态isLoggedIn,是ref类型的,默认是false,等后端接口返回登录成功之后变成true,我们需要只在第一次从false变true的时候,弹个欢迎回来的提示,并且把后端返回的用户头像存到本地存储里,代码大概长这样:

import { ref, watch } from 'vue'
const isLoggedIn = ref(false)
const userInfo = ref({}) // 假设接口会把用户信息塞这里
// 先声明一个变量存停止监听的函数
let stopWatchLogin
stopWatchLogin = watch(isLoggedIn, (newVal) => {
  if (newVal === true) {
    // 做我们需要的一次性副作用
    alert(`欢迎回来,${userInfo.value.nickname}!`)
    localStorage.setItem('userAvatar', userInfo.value.avatar)
    // 直接调用停止函数,之后isLoggedIn再变也不会触发这个watch了
    stopWatchLogin()
  }
})

对,就是这么简单!而且这里要注意,我们给stopWatchLogin赋值的顺序没问题——因为watch的回调是惰性执行的(除非加immediate),所以第一次赋值的时候,回调还没跑,stopWatchLogin已经被赋值成停止函数了,等isLoggedIn变成true触发回调的时候,就能正常调用stopWatchLogin。

那如果需求是第一次不管是从什么值变到什么值,甚至是立即执行一次之后就停止呢?比如我们要在组件挂载后(其实就是immediate: true)立即获取一次某个配置信息,之后不管这个配置的原始响应式数据咋变,都不用再更新本地的配置缓存了,这时候只要把deep(如果是监听对象的话需要deep)和immediate加上,然后在回调的第一行就调用停止函数就行:

import { ref, watch } from 'vue'
const globalConfig = ref({ theme: 'light', fontSize: 14 })
let stopWatchConfig
stopWatchConfig = watch(globalConfig, (newVal) => {
  // 先停止,再做操作也可以,反正之后不会再触发了
  stopWatchConfig()
  localStorage.setItem('appGlobalConfig', JSON.stringify(newVal))
}, { immediate: true, deep: true })

这里为啥要先停止再操作?其实无所谓,顺序不影响,因为stopWatchLogin/stopWatchConfig一旦被调用,这个watch的监听就立刻失效了,之后哪怕newVal还在变化(虽然这里是同步执行,newVal在这一次回调里不会变),也不会再进这个回调。

第二种方法:用标志位——传统但灵活

有些新手可能对watch的清理函数和返回值不太熟悉,或者项目里有些特殊的逻辑,只执行一次,但这个一次是有条件的,不是数据第一次变就一定执行,得满足某个额外条件才算数,没满足的话还要继续等下一次”,这时候用标志位就更灵活了。

标志位的原理也很直白:用一个ref或者普通变量(推荐ref,不过如果是在setup顶层的话普通变量也不会丢)记录“是否已经执行过目标操作”,每次watch触发的时候先检查标志位,如果已经是true就直接return,不做操作;如果是false,就做操作,然后把标志位设为true

还是刚才那个用户登录的例子,但这次加个额外条件:用户信息里必须有手机号才算正式登录成功,这时候才弹欢迎提示存头像,不然哪怕isLoggedIn变成true,只要没手机号,下次接口再更新userInfo有手机号了,还要再执行一次(当然执行完就不会再动了),这时候用标志位就比直接用stopWatchLogin更合适,因为如果第一次isLoggedIn变true的时候userInfo没手机号,直接stopWatchLogin的话,下次有手机号也没机会触发了。

用标志位的代码大概长这样:

import { ref, watch } from 'vue'
const isLoggedIn = ref(false)
const userInfo = ref({})
// 标志位,false表示还没执行过一次性操作
const hasWelcomedUser = ref(false)
// 这次监听两个源:isLoggedIn和userInfo,或者直接监听userInfo加上deep也行
// 不过显式监听两个更清晰,因为两个条件都要满足
watch([isLoggedIn, userInfo], ([newIsLoggedIn, newUserInfo]) => {
  // 先检查标志位,如果已经欢迎过了就直接走
  if (hasWelcomedUser.value) return
  // 再检查业务条件:已登录+有手机号
  if (newIsLoggedIn && newUserInfo.phone) {
    alert(`欢迎回来,${newUserInfo.nickname}!记得绑定的手机号是${newUserInfo.phone}哦`)
    localStorage.setItem('userAvatar', newUserInfo.avatar)
    localStorage.setItem('userPhoneMasked', newUserInfo.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'))
    // 业务条件满足了,把标志位设为true,之后再也不执行
    hasWelcomedUser.value = true
  }
}, { deep: true })

这里要注意,如果监听的是嵌套对象或者数组,记得加deep配置,不然Vue3只会监听对象/数组的引用变化,内部属性变了不会触发watch,用ref存标志位比用普通变量好吗?其实在setup顶层的话,普通变量的作用域也不会被回收,没问题,但如果标志位需要在模板里显示(比如显示“已获取配置”这种状态),那必须用ref,不然模板里不会更新。

第三种方法:用组合式API封装——复用性最强

如果你在项目里经常遇到“watch只执行一次”的需求,比如好几个页面都要监听用户第一次登录、第一次滚动到某个位置、第一次获取到商品详情,那每次都写stopWatchLogin或者hasWelcomedUser太麻烦了,这时候可以把它封装成一个组合式函数(Composable),直接复用。

封装的思路其实就是把前两种方法结合起来,做成一个通用的API,比如叫watchOnce,参数和Vue3原生的watch完全一样,这样用起来和原生watch几乎没区别,只是会自动在第一次满足条件(或者不管啥条件,只要执行一次就停止)的时候停止监听。

先给大家看一个最简单的、和原生watch参数一致、只要执行一次回调就自动停止的watchOnce封装:

import { watch } from 'vue'
export function watchOnce(source, callback, options = {}) {
  let stop
  // 直接调用原生watch,拿到停止函数
  stop = watch(
    source,
    // 给原生的callback包一层,先执行callback,再调用stop
    (...args) => {
      callback(...args)
      stop()
    },
    options
  )
  // 也可以把stop返回给调用者,万一调用者想提前手动停止呢?
  return stop
}

这个封装是不是超级简单?用起来的话,就和原生watch一模一样:

import { ref } from 'vue'
import { watchOnce } from '@/composables/watchOnce'
const count = ref(0)
// 第一次count变的时候(不管加还是减),弹提示,之后不管咋变都不弹
watchOnce(count, (newCount, oldCount) => {
  alert(`count第一次变啦!从${oldCount}变成了${newCount}`)
})
// 如果要立即执行一次,就加immediate: true
const globalConfig = ref({})
watchOnce(globalConfig, (newConfig) => {
  console.log('立即获取到的全局配置:', newConfig)
}, { immediate: true, deep: true })

那如果我们需要带条件的watchOnce呢?比如刚才那个必须有手机号的例子?也可以稍微改一下封装,加一个shouldStop的可选参数,shouldStop是一个函数,接收和callback一样的参数,返回true的时候才停止监听,返回false的话继续等下一次:

import { watch } from 'vue'
export function watchOnce(source, callback, options = {}, shouldStop = () => true) {
  let stop
  stop = watch(
    source,
    (...args) => {
      // 先执行回调(或者先检查shouldStop?顺序可以根据需求调整,这里默认先做操作再停止)
      callback(...args)
      // 检查是否满足停止条件
      if (shouldStop(...args)) {
        stop()
      }
    },
    options
  )
  return stop
}

用带shouldStop的watchOnce实现刚才的登录欢迎例子:

import { ref } from 'vue'
import { watchOnce } from '@/composables/watchOnce'
const isLoggedIn = ref(false)
const userInfo = ref({})
watchOnce(
  [isLoggedIn, userInfo],
  ([_, newUserInfo]) => {
    alert(`欢迎回来,${newUserInfo.nickname}!`)
    localStorage.setItem('userAvatar', newUserInfo.avatar)
  },
  { deep: true },
  // shouldStop函数,只有当已登录+有手机号的时候才返回true,停止监听
  ([newIsLoggedIn, newUserInfo]) => newIsLoggedIn && newUserInfo.phone
)

这样封装之后,不管是简单的还是复杂的“只执行一次”需求,都能轻松搞定,而且代码复用性超强,不会有重复的逻辑。

特殊场景处理:用watchEffect怎么只执行一次?

刚才一开始提过,不是所有情况都适合用watch,比如你不知道要监听具体哪个嵌套属性,只知道回调里用了某个大对象里的某个值,只要那个值第一次变化(或者第一次执行immediate之后)就停止,这时候用watchEffect加手动停止也可以。

watchEffect手动停止的方法和watch一样,它本身也会返回一个停止函数,不过watchEffect的回调是没有参数的,因为它是自动追踪依赖的,所以没法像watch那样拿到newVal和oldVal,但如果你只需要“依赖变化一次就停止”,或者“立即执行一次就停止”,没问题。

举个例子,假设我们有个复杂的appConfig对象,是reactive类型的,里面有很多嵌套属性,我们不知道开发的时候会用到哪个,但只要第一次在回调里用到的任何一个依赖发生变化,就把当前的appConfig存到本地存储,然后停止监听:

import { reactive, watchEffect } from 'vue'
const appConfig = reactive({
  ui: {
    theme: 'light',
    sidebar: {
      collapsed: false,
      width: 240
    }
  },
  api: {
    baseUrl: 'https://api.example.com',
    timeout: 10000
  }
})
let stopWatchConfigEffect
stopWatchConfigEffect = watchEffect(() => {
  // 这里随便用appConfig里的哪个属性,都会自动追踪
  console.log('当前用到的配置:', appConfig.ui.theme, appConfig.api.baseUrl)
  localStorage.setItem('usedAppConfig', JSON.stringify({
    theme: appConfig.ui.theme,
    baseUrl: appConfig.api.baseUrl
  }))
  // 立即停止的话就直接调用,等依赖变化一次再停止的话可以加个标志位
  // 这里演示等依赖变化一次再停止,所以加个标志位
})

哦,刚才说了,watchEffect默认immediate: true,所以如果直接在回调里调用stopWatchConfigEffect,会立即执行一次然后停止,和加了immediate: true的watchOnce一样,那如果要等依赖变化一次再停止,就得加个标志位,记录是不是第一次执行(因为第一次是immediate触发的,不算“变化”):

import { reactive, watchEffect } from 'vue'
const appConfig = reactive({
  ui: { theme: 'light' },
  api: { baseUrl: 'https://api.example.com' }
})
let stopWatchConfigEffect
// 标志位,true表示是第一次immediate触发的
let isFirstImmediateRun = true
stopWatchConfigEffect = watchEffect(() => {
  console.log('当前配置:', appConfig.ui.theme, appConfig.api.baseUrl)
  // 如果不是第一次immediate触发的,说明是依赖变化了,这时候做操作然后停止
  if (!isFirstImmediateRun) {
    localStorage.setItem('changedAppConfig', JSON.stringify(appConfig))
    stopWatchConfigEffect()
  }
  // 第一次immediate执行完之后,把标志位设为false
  isFirstImmediateRun = false
})

这个标志位的顺序很重要哦!得先检查isFirstImmediateRun,再把它设为false,不然第一次immediate触发的时候,先设为false,再检查,就会直接做操作然后停止,那就和immediate: true的watchOnce一样了。

总结一下几种方法的适用场景

刚才讲了四种方法?不对,是三种watch相关的,一种watchEffect相关的,大家可以根据自己的需求选:

  1. 官方原生用watch返回值+回调里调用停止:适合明确知道监听源,且第一次触发(不管是惰性还是immediate)就一定满足业务条件,不需要再等的简单需求,代码最少,最稳当,官方文档虽然没专门提,但属于官方API的正常用法,不用担心兼容性。
  2. 用标志位:适合需要满足额外业务条件才算“一次”,没满足的话还要继续等下一次的复杂需求,或者新手对watch返回值不太熟悉的情况,灵活性最高。
  3. 封装成watchOnce组合式函数:适合项目里经常用到“只执行一次”需求的情况,复用性最强,维护起来也方便,改一处封装,所有用的地方都能生效(比如以后想加个日志功能,直接在封装里加就行)。
  4. watchEffect加标志位/直接停止:适合不知道要监听具体哪个嵌套属性,只知道回调里会用到某个大对象里的值的特殊场景,但因为watchEffect自动追踪依赖,有时候可能会不小心监听了不该监听的东西,所以尽量少用,除非真的没办法显式指定监听源。

避坑指南:这几个地方容易出错

最后给大家提几个常见的坑,避免踩雷:

  1. 忘记加deep配置:如果监听的是reactive对象或者ref包裹的对象/数组,默认只会监听引用变化,内部属性变了不会触发watch,这时候得加deep: true。

  2. stopWatch赋值的顺序不对:如果用第一种方法,必须先声明stopWatch变量,再给它赋值成watch的返回值,不然回调里调用stopWatch的时候会是undefined,不过因为watch默认是惰性执行的,所以这个顺序一般不会有问题,但如果加了immediate: true,会不会有问题?比如先调用watch(immediate: true),再把返回值赋给stopWatch?其实不会!因为Vue3的watch是同步执行的吗?不对,watch的回调如果是同步的,immediate: true的时候会在watch函数返回之前就执行吗?等下,这里要做个小测试——其实不管加不加immediate: true,watch函数的返回值都是先赋值给stopWatch的,因为watch的执行逻辑是:先创建监听实例,然后如果有immediate: true,就同步调用一次回调,然后再返回停止函数,哦,不对,刚才说反了!如果加了immediate: true,回调会在watch函数返回之前就执行,这时候如果stopWatch还没赋值的话,回调里调用stopWatch就是undefined!那这时候怎么办? 哦,对,刚才的第一种方法如果加immediate: true的话,得用闭包或者函数提升吗?不对,不用那么麻烦,直接用let先声明stopWatch,然后再赋值,虽然回调是在watch返回之前执行的,但JavaScript的变量声明是提升的,stopWatch已经被声明了,只是初始值是undefined?等下,我们写个简单的测试代码看一下:

    import { ref, watch } from 'vue'
    const count = ref(0)
    let stop
    console.log('调用watch之前的stop:', stop) // undefined
    stop = watch(count, () => {
      console.log('watch回调里的stop:', stop) // undefined!因为回调是在赋值之前执行的
      alert('立即执行一次')
      stop() // 这时候调用会报错,因为stop是undefined
    }, { immediate: true })
    console.log('调用watch之后的stop:', stop) // 函数

    对,刚才我忽略了这个问题!如果加了immediate: true,第一种方法(直接在回调里调用stop)会报错,因为回调是在watch函数返回停止函数之前执行的,这时候stop还没被赋值,那这时候怎么办?有两种解决方法: 第一种是用标志位+第一种方法结合:

    import { ref, watch } from 'vue'
    const count = ref(0)
    let stop
    let hasRun = false
    stop = watch(count, () => {
      if (!hasRun) {
        console.log('watch回调里的stop:', stop) // 第一次immediate的时候还是undefined,但没关系,先不调用
        alert('立即执行一次')
        hasRun = true
      } else {
        stop() // 等下一次count变的时候,stop已经被赋值了,这时候调用没问题
      }
    }, { immediate: true })

    但这样的话,如果只需要immediate执行一次,不需要等下一次变化,那这种结合的方法没用,因为第一次immediate之后hasRun设为true,下一次才会调用stop,但我们不需要下一次,这时候第二种解决方法更好:用setTimeout或者nextTick把调用stop的逻辑放到下一个事件循环里,这样watch函数已经返回了,stop已经被赋值了:

    import { ref, watch, nextTick } from 'vue'
    const count = ref(0)
    let stop
    stop = watch(count, () => {
      alert('立即执行一次')
      // 用nextTick或者setTimeout(() => {}, 0)都可以,nextTick更贴合Vue的响应式系统
      nextTick(() => {
        stop()
      })
    }, { immediate: true })

    对,这个方法完美解决了immediate: true时第一种方法的问题!刚才的避坑指南太重要了,差点漏掉这个。

  3. 滥用watchOnce:不是所有“只需要执行一次”的需求都适合用监听的方法,比如组件挂载后只执行一次的操作,应该用onMounted;组件卸载前只执行一次的操作,应该用onUnmounted;依赖多个响应式数据但不需要追踪变化的,直接在setup顶层写就行,别什么都往watchOnce上套,不然代码会变得很乱。

好了,今天关于Vue3 watch只执行一次的内容就讲完了,大家可以根据自己的需求选合适的方法,记得避坑哦!如果有其他Vue3的问题,也可以在评论区留言。

版权声明

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

热门