深入理解 watch
watch 是 Vue 组合式 API (Composition API) 中的一个核心功能,它允许我们侦听 一个或多个响应式数据源,并在数据源变化时执行一个回调函数。这对于执行异步操作或基于数据变化执行复杂逻辑非常有用。
1. 基本用法
最简单的用法是侦听一个 ref。
- 参数 :
watch的第一个参数可以是不同形式的"数据源":它可以是一个ref(包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组:- 数据变化时执行的回调函数,该函数接收新值 (
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 会隐式地创建一个深层侦听器。这意味着对象内部任何嵌套属性的变化都会触发回调。
注意 :此时 newValue 和 oldValue 将是同一个对象引用,因为它们都指向同一个 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: 使侦听器在创建时立即执行一次回调。此时oldValue是undefined。jswatch(count, (newValue) => { console.log(`当前 count 是: ${newValue}`) }, { immediate: true }) // 控制台会立即输出: "当前 count 是: 0" -
deep: true: 强制开启深层侦听。当侦听一个ref包裹的对象时,必须使用此选项才能侦听到对象内部属性的变化。jsconst 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:每当被侦听源发生变化时,侦听器的回调就会执行。如果希望回调只在源变化时触发一次jswatch( source, (newValue, oldValue) => { // 当 `source` 变化时,仅触发一次 }, { once: true } )- 仅支持 3.4 及以上版本
-
flush:调整回调函数的刷新时机。参考回调的刷新时机及watchEffect()。jswatch(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 来注册一个清理函数,当侦听器失效并准备重新运行时会被调用:
侦听器何时生效?
- 源数据再次变化,导致侦听器准备重新运行时(这是为了处理竞态条件)。
- 侦听器被手动停止 (调用
stop())。- 侦听器因组件卸载而被自动停止。
- 侦听器因
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. 停止侦听器
默认情况下,组件卸载时会自动停止侦听器,但是侦听器默认是在同步创建的,如果异步创建,必须手动停止,防止内存泄漏!!!
当你把 watch 或 watchEffect 的调用放在一个异步回调中时(例如 setTimeout, Promise.then, async/await 之后),情况就完全不同了。
- 此时,组件的
setup()函数已经执行完毕。 - 当你的异步回调函数最终执行时,Vue 的执行上下文已经改变,
getCurrentInstance()会返回null。
侦听器在创建时,环顾四周发现:"我不知道我属于哪个组件!" 它无法找到一个可以"报到"的组件实例。
这个侦听器就成了一个**"孤儿"**,它独立于任何组件的生命周期之外。因此,当原来的组件被卸载时,Vue 的清理机制根本不知道这个"孤儿"侦听器的存在,自然也无法为它调用 stop()。
vue
<script setup>
import { watchEffect } from 'vue'
// 它会自动停止
watchEffect(() => {})
// ...这个则不会!
setTimeout(() => {
watchEffect(() => {})
}, 100)
</script>
要手动停止一个侦听器,请调用 watch 或 watchEffect 返回的函数:
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 中,computed 和 watch 都是处理响应式数据变化的核心工具,但它们的应用场景和设计理念完全不同。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: 这是要侦听的源。可以是ref、reactive对象、getter函数等。注意它的类型T | false | null | undefined,这明确表示whenever就是为了处理可能为"假值 (falsy)"的源而设计的。cb: 这是当source变为真值时要执行的回调函数。options: 这是一个可选的配置对象,它继承了watch的所有选项(如immediate,deep),并额外增加了一个once选项。
js
// 2. 函数的核心:调用 Vue 内置的 watch 函数
const stop = watch(
whenever 的本质就是一个 watch。它返回 watch 的 stop 函数,这意味着你可以像停止一个普通 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): 这是整个函数的核心。v是source的新值。这个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选项。但是whenever的once逻辑是自己实现的(为了配合真值检查)。为了防止与 Vue 内置的once行为冲突,这里强制将传递给watch的once选项设置为false,确保whenever自己的once逻辑能够正常工作。
js
// 5. 返回 stop 函数
return stop
}
最后,将内部 watch 返回的 stop 函数返回给调用者,让用户可以随时手动停止侦听。
总结
computed是为了计算出一个新值。watch是为了在数据变化时做事。whenever是watch的一个变种,它通过巧妙地使用nextTick和覆盖once选项,实现了自定义的、与真值判断相结合的"执行一次"功能。