Vue中的watch

深入理解 watch

watchVue 组合式 API (Composition API) 中的一个核心功能,它允许我们侦听 一个或多个响应式数据源,并在数据源变化时执行一个回调函数。这对于执行异步操作或基于数据变化执行复杂逻辑非常有用。

1. 基本用法

最简单的用法是侦听一个 ref

  • 参数 :
    1. watch 的第一个参数可以是不同形式的"数据源":它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组:
    2. 数据变化时执行的回调函数,该函数接收新值 (newValue) 和旧值 (oldValue)。
js 复制代码
import { ref, watch } from 'vue'
// 1. 定义一个响应式数据
const count = ref(0)
// 2. 侦听 count 的变化
watch(count, (newValue, oldValue) => {
 console.log(`count 从 ${oldValue} 变成了 ${newValue}`)
})
// 3. 修改数据以触发 watch
setTimeout(() => {
 count.value++ // 控制台将输出: "count 从 0 变成了 1"
}, 1000)

2. 侦听响应式对象

当侦听一个 reactive 对象时,watch 会隐式地创建一个深层侦听器。这意味着对象内部任何嵌套属性的变化都会触发回调。

注意 :此时 newValueoldValue 将是同一个对象引用,因为它们都指向同一个 reactive 对象。

js 复制代码
import { reactive, watch } from 'vue'

const state = reactive({
 id: 1,
 user: {
  name: 'Alice',
  age: 20
 }
})

watch(state, (newState, oldState) => {
 console.log('state 对象发生了变化')
 // 注意:newState === oldState
})
// 修改嵌套属性会触发 watch
state.user.age++ // 控制台将输出: "state 对象发生了变化"

3. 侦听 Getter 函数

为了更精确地控制侦听的目标,或者只侦听响应式对象中的某个属性 ,我们可以向 watch 传递一个 getter 函数 () => ...

这是最常用和推荐的方式之一,因为它具有更好的性能和更明确的意图。

js 复制代码
// ...接上一个例子
// 只侦听 state.user.age 的变化
watch(
 () => state.user.age,
 (newAge, oldAge) => {
  console.log(`年龄从 ${oldAge} 变成了 ${newAge}`)
 }
)
state.user.age++ // 控制台将输出: "年龄从 20 变成了 21"
state.id++    // 不会触发这个 watch

相比侦听响应式对象 ,一个返回响应式对象的 getter 函数,只有在返回不同的对象时,才会触发回调:

js 复制代码
watch(
  () => state.someObject,
  (newValue, oldValue) => {
    // 注意:`newValue` 此处和 `oldValue` 是相等的
    // *除非* state.someObject 被整个替换了
  },
  { deep: true }	// 可以给上面这个例子显式地加上 deep 选项,强制转成深层侦听器:
)

4. 侦听多个数据源

watch 也可以同时侦听多个数据源,只需将它们放在一个数组中即可。回调函数接收的新旧值也将是数组。

js 复制代码
const firstName = ref('John')
const lastName = ref('Doe')

watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
 console.log(`姓名从 ${oldFirst} ${oldLast} 变成了 ${newFirst} ${newLast}`)
})
firstName.value = 'Jane' // 控制台将输出: "姓名从 John Doe 变成了 Jane Doe"

5. 配置选项

watch 接受第三个参数,一个配置对象,用于自定义其行为。

  • immediate: true : 使侦听器在创建时立即执行一次回调。此时 oldValueundefined

    js 复制代码
    watch(count, (newValue) => {
     console.log(`当前 count 是: ${newValue}`)
    }, { immediate: true }) // 控制台会立即输出: "当前 count 是: 0"
  • deep: true : 强制开启深层侦听。当侦听一个 ref 包裹的对象时,必须使用此选项才能侦听到对象内部属性的变化。

    js 复制代码
    const state = ref({ nested: { count: 0 } })
    
    // 必须加 deep: true 才能侦听到 state.value.nested.count 的变化
    watch(state, (newState) => {
     console.log('state 内部发生了变化')
    }, { deep: true })
    state.value.nested.count++ // 控制台将输出: "state 内部发生了变化"

    在 Vue 3.5+ 中,deep 选项还可以是一个数字,表示最大遍历深度------即 Vue 应该遍历对象嵌套属性的级数。

    谨慎使用

    深度侦听需要遍历被侦听对象中的所有嵌套的属性,当用于大型数据结构时,开销很大。因此请只在必要时才使用它,并且要留意性能。

  • once: true:每当被侦听源发生变化时,侦听器的回调就会执行。如果希望回调只在源变化时触发一次

    js 复制代码
    watch(
      source,
      (newValue, oldValue) => {
        // 当 `source` 变化时,仅触发一次
      },
      { once: true }
    )
    • 仅支持 3.4 及以上版本
  • flush :调整回调函数的刷新时机。参考回调的刷新时机watchEffect()

    js 复制代码
    watch(source, callback, {
      flush: 'post'	//'pre' | 'post' | 'sync' 默认:'pre'
    })
    // watchEffect的第二个参数
    watchEffect(callback, {
      flush: 'post'
    })

6. watch vs watchEffect

特性 watch watchEffect
依赖追踪 手动指定,更明确 自动追踪,更方便
执行时机 默认懒执行(数据变化后才执行) 立即执行一次,然后自动追踪
访问旧值 可以 不可以
使用场景 需要知道新旧值、需要精确控制依赖、执行异步或复杂逻辑 简单的副作用,如根据 A 的值更新 B,或打印日志

✨TIP

watchEffect 仅会在其同步 执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

总结
  • 当你需要精确控制 侦听哪个数据时,使用 watch
  • 当你需要访问变化前后的值 时,使用 watch
  • 当你需要在数据变化时执行异步操作 或开销较大的操作时,watch 提供了更清晰的控制。
  • 对于简单的、需要立即执行且自动追踪依赖的副作用,可以考虑使用 watchEffect

7. 副作用清理

在侦听器中执行一个异步任务,但是在异步任务获取结果之前又重新触发了侦听器

例如通过 id 请求一个数据接口,再数据返回之前 id 变化了,又重新触发了新的请求理想情况下,我们希望能够在 id 变为新值时取消过时的请求。

watch 回调中处理异步操作时遇到的**竞态条件(Race Condition)**问题。

我们可以使用 onWatcherCleanup() (3.5+)API 来注册一个清理函数,当侦听器失效并准备重新运行时会被调用

侦听器何时生效?

  1. 源数据再次变化,导致侦听器准备重新运行时(这是为了处理竞态条件)。
  2. 侦听器被手动停止 (调用 stop())。
  3. 侦听器因组件卸载而被自动停止
  4. 侦听器因 once: true 完成其唯一一次任务而被自动停止
js 复制代码
import { watch, onWatcherCleanup } from 'vue'

watch(id, (newId) => {
  const controller = new AbortController()
  fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
    // 回调逻辑
  })
  onWatcherCleanup(() => {
    // 终止过期请求
    controller.abort()
  })
})

请注意,onWatcherCleanup 仅在 Vue 3.5+ 中支持,并且必须在 watchEffect 效果函数或 watch 回调函数的同步执行期间调用:你不能在异步函数的 await 语句之后调用它。

作为替代,onCleanup 函数还作为第三个参数传递给侦听器回调,以及 watchEffect 作用函数的第一个参数:

js 复制代码
watch(id, (newId, oldId, onCleanup) => {
  // ...
  onCleanup(() => {
    // 清理逻辑
  })
})

watchEffect((onCleanup) => {
  // ...
  onCleanup(() => {
    // 清理逻辑
  })

通过函数参数传递的 onCleanup 与侦听器实例相绑定,因此不受 onWatcherCleanup 的同步限制。

旧方式 (onInvalidate 参数) 新方式 (onWatcherCleanup API)
可用版本 Vue 3.0+ Vue 3.5+
用法 watch((v, ov, onInvalidate) => { onInvalidate(...) }) watch(() => { onWatcherCleanup(...) })
状态 仍然有效,但不再是首选。 推荐使用,是未来的方向。

8. 回调的触发时机

默认情况下,侦听器回调会在父组件更新 (如有) 之后 、所属组件的 DOM 更新之前被调用。这意味着如果你尝试在侦听器回调中访问所属组件的 DOM,那么 DOM 将处于更新前的状态。

Post Watchers

如果想在侦听器回调中能访问被 Vue 更新之后 的所属组件的 DOM,你需要指明 flush: 'post' 选项:

js 复制代码
watch(source, callback, {
  flush: 'post'
})

watchEffect(callback, {
  flush: 'post'
})

后置刷新的 watchEffect() 有个更方便的别名 watchPostEffect()

js 复制代码
import { watchPostEffect } from 'vue'

watchPostEffect(() => {
  /* 在 Vue 更新后执行 */
})
同步侦听器

你还可以创建一个同步触发的侦听器,它会在 Vue 进行任何更新之前触发:

js 复制代码
watch(source, callback, {
  flush: 'sync'
})

watchEffect(callback, {
  flush: 'sync'
})

同步触发的 watchEffect() 有个更方便的别名 watchSyncEffect()

js 复制代码
import { watchSyncEffect } from 'vue'

watchSyncEffect(() => {
  /* 在响应式数据变化时同步执行 */
})

❗谨慎使用

同步侦听器不会进行批处理,每当检测到响应式数据发生变化时就会触发。可以使用它来监视简单的布尔值,但应避免在可能多次同步修改的数据源 (如数组) 上使用。

9. 停止侦听器

默认情况下,组件卸载时会自动停止侦听器,但是侦听器默认是在同步创建的,如果异步创建,必须手动停止,防止内存泄漏!!!

当你把 watchwatchEffect 的调用放在一个异步回调中时(例如 setTimeout, Promise.then, async/await 之后),情况就完全不同了。

  • 此时,组件的 setup() 函数已经执行完毕。
  • 当你的异步回调函数最终执行时,Vue 的执行上下文已经改变,getCurrentInstance() 会返回 null

侦听器在创建时,环顾四周发现:"我不知道我属于哪个组件!" 它无法找到一个可以"报到"的组件实例。

这个侦听器就成了一个**"孤儿"**,它独立于任何组件的生命周期之外。因此,当原来的组件被卸载时,Vue 的清理机制根本不知道这个"孤儿"侦听器的存在,自然也无法为它调用 stop()

vue 复制代码
<script setup>
import { watchEffect } from 'vue'
// 它会自动停止
watchEffect(() => {})

// ...这个则不会!
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>

要手动停止一个侦听器,请调用 watchwatchEffect 返回的函数:

js 复制代码
const unwatch = watchEffect(() => {})

// ...当该侦听器不再需要时
unwatch()
暂停/恢复侦听器(3.5+)
js 复制代码
const { stop, pause, resume } = watch(() => {})
// 暂停侦听器
pause()
// 稍后恢复
resume()
// 停止
stop()

const { stop, pause, resume } = watchEffect(() => {})
// 暂停侦听器
pause()
// 稍后恢复
resume()
// 停止
stop()

computed vs watch vs whenever

Vue 中,computedwatch 都是处理响应式数据变化的核心工具,但它们的应用场景和设计理念完全不同。VueUse 库则在 watch 的基础上提供了更具表达力的工具,如 whenever

1. computed vs watch:核心区别

简单来说:

  • computed :用于派生 出一个新的、可缓存的响应式数据。它关心的是返回值
  • watch :用于观察 一个数据的变化,并执行副作用(Side Effect)。它不关心返回值。
对比表格
特性 computed (计算属性) watch (侦听器)
本质 派生值 (Derivation) 副作用 (Side Effect)
返回值 必须有 ,返回一个可缓存的 ref 没有,执行一个回调函数
缓存 。依赖不变时,多次访问直接返回缓存结果 没有。每次数据变化都会执行回调
执行时机 懒执行。仅在被访问且依赖变化时才重新计算 默认懒执行。仅在数据变化后执行回调(可通过 immediate 配置立即执行)
异步操作 不支持。计算属性内部应该是同步的纯函数 支持 。可以在回调中执行 API 请求等异步操作
使用场景 从现有数据计算新数据(如 fullName 数据变化时执行异步操作、更新非 Vue 管理的 DOM、或执行复杂逻辑
代码示例

computed 示例:

js 复制代码
import { ref, computed } from 'vue';

const firstName = ref('John');
const lastName = ref('Doe');

// `fullName` 是一个派生出来的 ref,它依赖于 firstName 和 lastName
const fullName = computed(() => {
 console.log('计算 fullName...'); // 只有依赖变化时才打印
 return `${firstName.value} ${lastName.value}`;
});
// 在模板中使用 {{ fullName }} 即可

watch 示例:

js 复制代码
import { ref, watch } from 'vue';

const userId = ref(1);
const userData = ref(null);

// 侦听 userId 的变化,然后执行获取数据的副作用
watch(userId, async (newId, oldId) => {
 console.log(`User ID 从 ${oldId} 变为 ${newId},正在获取新数据...`);
    
 const response = await fetch(`https://api.example.com/users/${newId}`);
 userData.value = await response.json();
}, { immediate: true }); // immediate: true 确保组件加载时就获取一次数据

2. watch vs whenever (来自 VueUse)

VueUse 是一个基于 Composition API 的实用工具集。 whenever 是它提供的一个 watch 的"语法糖",让代码更具可读性。

核心区别
  • watch :无论新值是什么,只要数据源变化,回调总是执行。
  • whenever :只有当数据源的值变为真值 (truthy) 时,回调才会执行。它是一个带有内置条件判断的 watch

whenever 的内部其实就是使用 watchWithFilter 实现的,它等价于一个带有 filter 选项的 watch

对比表格
特性 watch whenever (VueUse)
执行条件 数据源变化就执行 数据源变化,且新值为真值 (truthy) 时才执行
可读性 需要在回调函数内部写 if 判断 非常高,语义清晰,代码即注释
使用场景 通用的侦听场景 当某个条件满足时执行一次性操作(如弹窗、登录成功后跳转)

使用 watch 的写法:

js 复制代码
import { watch, ref } from 'vue'

const myValue = ref<string | null>(null)

watch(myValue, (newValue) => {
  // 每次 myValue 变化都会执行这里
  if (newValue) { // 需要自己加一个 if 判断
    // 只有当 newValue 是真值时才执行核心逻辑
    console.log(`值变成了: ${newValue}`)
  }
})

使用 whenever 的写法 (更优雅):

js 复制代码
import { whenever } from '@vueuse/core'

const myValue = ref<string | null>(null)

whenever(myValue, (newValue) => {
  // 只有当 myValue 变为真值时,才会执行这里
  console.log(`值变成了: ${newValue}`)
})

现在我们来逐行分析它的 实现

js 复制代码
/**
 * Shorthand for watching value to be truthy
 *
 * @see https://vueuse.org/whenever
 */
export function whenever<T>(source: WatchSource<T | false | null | undefined>, cb: WatchCallback<T>, options?: WheneverOptions) {
  const stop = watch(
    source,
    (v, ov, onInvalidate) => {
      if (v) {
        if (options?.once)
          nextTick(() => stop())
        cb(v, ov, onInvalidate)
      }
    },
    {
      ...options,
      once: false,
    } as WatchOptions,
  )
  return stop
}
js 复制代码
// 1. 定义了函数的签名
export function whenever<T>(source: WatchSource<T | false | null | undefined>, cb: WatchCallback<T>, options?: WheneverOptions) {
  • source : 这是要侦听的源。可以是 refreactive 对象、getter 函数等。注意它的类型 T | false | null | undefined,这明确表示 whenever 就是为了处理可能为"假值 (falsy)"的源而设计的。
  • cb : 这是当 source 变为真值时要执行的回调函数。
  • options : 这是一个可选的配置对象,它继承了 watch 的所有选项(如 immediate, deep),并额外增加了一个 once 选项。
js 复制代码
// 2. 函数的核心:调用 Vue 内置的 watch 函数
  const stop = watch(

whenever 的本质就是一个 watch。它返回 watchstop 函数,这意味着你可以像停止一个普通 watch 一样停止 whenever

js 复制代码
// 3. watch 的回调函数,这是 whenever 的魔法所在
    source,
    (v, ov, onInvalidate) => {
      if (v) { // <--- 关键检查!
        if (options?.once)
          nextTick(() => stop())
        cb(v, ov, onInvalidate)
      }
    },

这是传递给内部 watch 的回调函数。每次 source 发生变化,这个函数都会被调用。

  • if (v) : 这是整个函数的核心。vsource 的新值。这个 if 语句检查新值是否为真值 (即不是 null, undefined, false, 0, ''NaN)。

  • 只有当 v 是真值时,才会执行 if 块内部的逻辑。

  • if (options?.once)

    : 如果用户设置了once: true,则执行 nextTick(() => stop())

    • stop() : 调用这个函数会停止当前的 watch,从而实现"只执行一次"的效果。

    • nextTick() : 为什么要用 nextTick

      ✨这是一个非常巧妙的细节。它确保了本次的回调函数 cb 能够完整执行完毕,然后在下一个 DOM 更新周期(tick)中再停止侦听。这避免了在回调函数执行过程中就销毁侦听器可能引发的潜在问题。

  • cb(v, ov, onInvalidate) : 如果 v 是真值,就调用用户传入的原始回调函数 cb,并把 watch 的所有参数(新值、旧值、失效回调)都透传过去。

js 复制代码
// 4. watch 的配置对象
    {
      ...options,
      once: false,
    } as WatchOptions,
  )

这里是传递给内部 watch 的配置对象。

  • ...options : 将用户传入的所有配置(如 immediate, deep)都传递给底层的 watch

  • once: false: 这是一个关键的覆盖操作。

    ✨Vue 3.4+ 的 watch 本身也支持 once 选项。但是 wheneveronce 逻辑是自己实现的(为了配合真值检查)。为了防止与 Vue 内置的 once 行为冲突,这里强制将传递给 watchonce 选项设置为 false,确保 whenever 自己的 once 逻辑能够正常工作。

js 复制代码
// 5. 返回 stop 函数
  return stop
}

最后,将内部 watch 返回的 stop 函数返回给调用者,让用户可以随时手动停止侦听。

总结

  1. computed 是为了计算出一个新值。
  2. watch 是为了在数据变化时做事
  3. wheneverwatch 的一个变种,它通过巧妙地使用 nextTick 和覆盖 once 选项,实现了自定义的、与真值判断相结合的"执行一次"功能。
相关推荐
狗哥哥1 小时前
我是如何治理一个混乱的 Pinia 状态管理系统的
前端·vue.js·架构
一 乐1 小时前
物业管理|基于SprinBoot+vue的智慧物业管理系统(源码+数据库+文档)
前端·javascript·数据库·vue.js·spring boot
zlpzlpzyd2 小时前
vue.js 2和vue.js 3的生命周期与对应的钩子函数区别
前端·javascript·vue.js
汝生淮南吾在北2 小时前
SpringBoot+Vue游戏攻略网站
前端·vue.js·spring boot·后端·游戏·毕业设计·毕设
老华带你飞3 小时前
英语学习|基于Java英语学习系统(源码+数据库+文档)
java·开发语言·数据库·vue.js·spring boot·后端·学习
霁月的小屋3 小时前
Vue组件通信全攻略:从基础语法到实战选型
前端·javascript·vue.js
一 乐3 小时前
校园社区系统|基于java+vue的校园悬赏任务平台系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot
m0_616188494 小时前
循环多个表单进行表单校验
前端·vue.js·elementui
幸运小圣5 小时前
关于Vue 3 <script setup> defineXXX API 总结
前端·javascript·vue.js