1. 简介
如果我们要在状态变化时执行一些「副作用」(例如改变 DOM,或者根据异步操作的结果去改变另一状态),可以用 watch
或者 watchEffect
函数,它们在响应式状态变化时会触发回调。
2. watch
watch
的第一个参数可以是不同形式的「数据源」:ref (包括计算属性)、响应式对象、getter 函数、或多个数据源的数组:
const x = ref(0)
const y = ref(0)
// ref
watch(x, (newX) => {
console.log(`x is ${newX}`)
})
// getter 函数
watch(
() => x.value + y.value,
(sum) => {
console.log(`sum of x + y is: ${sum}`)
}
)
// 包含多个数据源的数组
watch([x, () => y.value], ([newX, newY]) => {
console.log(`x is ${newX} and y is ${newY}`)
})
注意,不能直接侦听响应式对象的属性值:
const obj = reactive({ count: 0 })
// 错误,因为传递给 watch() 的参数是一个 number
watch(obj.count, (count) => {
console.log(`count is: ${count}`)
})
而应使用 getter
函数:
watch(
() => obj.count,
(count) => {
console.log(`count is: ${count}`)
}
)
直接对响应式对象使用 watch()
,会隐式地创建深层侦听器 —— 所有嵌套属性变化时都会触发回调:
const obj = reactive({ count: 0 })
watch(obj, (newValue, oldValue) => {
// 嵌套属性变化时触发
// 注意:`newValue` 和 `oldValue` 相等,因为它们指向同一对象!
})
obj.count++
不同的是,返回响应式对象的 getter 函数 —— 只有在 getter 返回不同对象时,才会触发回调:
watch(
() => state.someObject,
() => {
// 仅当 state.someObject 被替换时触发
}
)
然而,也可以给使用 deep
选项,强制转成深层侦听器:
watch(
() => state.someObject,
(newValue, oldValue) => {
// 注意:`newValue` 和 `oldValue` 相等,除非 state.someObject 被替换了
},
{ deep: true }
)
谨慎使用
深层侦听要遍历侦听对象的所有嵌套属性,当用于大型数据结构时,开销很大。因此仅在必要时才使用它,同时留意性能。
3. watchEffect
watch()
是懒执行的:当数据源变化时,才会执行回调。但在某些场景下,我们可能想提前执行相同的回调逻辑。举例来说,我们可能想获取部分初始数据,然后在相关状态变化时再次获取数据。可以这样做:
const url = ref('https://...')
const data = ref(null)
async function fetchData() {
const response = await fetch(url.value)
data.value = await response.json()
}
// 立即获取
fetchData()
// ...再侦听 url 变化
watch(url, fetchData)
以上可以用 watchEffect
函数简化。watchEffect()
在立即执行副作用回调的同时,会自动追踪副作用的响应式依赖。上面的例子可以重写为:
watchEffect(async () => {
const response = await fetch(url.value)
data.value = await response.json()
})
本例中,回调会立即执行。执行期间,它也会自动追踪 url.value
作为依赖(类似于计算属性)。每当 url.value
变化时,都会再次执行回调。
提示
watchEffect
只会在其同步执行期间追踪依赖。当和异步回调一起使用时,在第一个await
前访问的属性才会被追踪。
watchEffect
的第一个参数就是要运行的副作用函数。这个副作用函数的参数也是一个函数,用来注册清理回调。清理回调会在该副作用下一次执行前被调用,可以用来清理无效的副作用。
watchEffect(async (onCleanup) => {
const { response, cancel } = doAsyncWork(id.value)
// `cancel` 会在 `id` 变化时调用,以便取消之前未完成的请求
// sideEffect 返回一个 Promise,需要把 onCleanup 注册提到 Promise 解析之前
onCleanup(cancel)
data.value = await response
})
4. watch 对比 watchEffect
watch
和 watchEffect
都能响应式地执行副作用回调。主要区别在于追踪响应式依赖的方式不同:
watch
只追踪明确侦听的数据源。不会追踪回调中访问的任何属性。此外,仅在数据源本身改变时才触发回调。watch
将追踪依赖从副作用中剥离出来,让我们对何时触发回调函数有更明确的控制。watchEffect
结合了依赖追踪和副作用。它会在同步执行期间,自动追踪访问的每个响应式属性。这更方便,并且代码往往更简洁,但使得响应性依赖关系不那么明确。
5. 回调触发时机
当响应式状态改变时,可能会同时触发 Vue 组件更新和侦听器回调。
默认情况下,用户创建的侦听器回调会在 Vue 组件更新前调用。这意味着如果在侦听器回调中访问 DOM,获取到的 DOM 将是 Vue 应用任何更新前的状态。
如果要在侦听器回调中访问 Vue 更新后的 DOM,需指定 flush: 'post'
选项:
watch(source, callback, {
flush: 'post'
})
watchEffect(callback, {
flush: 'post'
})
后置刷新的 watchEffect()
也有个更方便的别名 watchPostEffect()
:
import { watchPostEffect } from 'vue'
watchPostEffect(() => {
/* 在 Vue 更新后执行 */
})
6. 停止侦听器
在 setup()
或 <script setup>
中用同步声明的侦听器,会自动绑定到宿主组件实例上,并且会在宿主组件卸载时自动停止。因此,在大多数情况下,不用担心怎么停止侦听器。
一个关键点是,侦听器必须同步创建:如果在异步回调中创建侦听器,那么它不会绑定到宿主组件上,必须手动停止它,以防内存泄漏。下面是个例子:
<script setup>
import { watchEffect } from 'vue'
// 它会自动停止
watchEffect(() => {})
// ...这个则不会!
setTimeout(() => {
watchEffect(() => {})
}, 100)
</script>
要手动停止侦听器,请调用 watch
或 watchEffect
返回的函数:
const unwatch = watchEffect(() => {})
// ...当不再需要该侦听器时
unwatch()
注意,需要异步创建侦听器的情况很少,请尽可能选择同步创建。如果需要等待部分异步数据,可以使用条件式的侦听逻辑:
// 需要异步加载的数据
const data = ref(null)
watchEffect(() => {
if (data.value) {
// 数据加载后执行的操作
}
})
7. 示例分析
以下示例用于综合理解侦听器。
7.1 前置刷新 flush: 'pre'
<template>
<h1 @click="open = !open">{{ `open=${open}` }}</h1>
<h1>{{ `count=${count}` }}</h1>
<div v-if="open" ref="div" :id="count">div</div>
</template>
<script setup>
import { ref, watchEffect } from "vue";
const open = ref(false);
const div = ref(null);
const count = ref(0);
const unused = ref();
watchEffect(
(onCleanup) => {
console.log("开始");
onCleanup(() => {
console.log("清除副作用");
});
unused.value;
const id = Number(div.value ? div.value.id : -1);
if (open.value) {
count.value = id + 10;
console.log("+10", count.value);
} else {
count.value = id + 1;
console.log("+1", count.value);
}
console.log("结束");
},
{
onTrack(e) {
console.log("onTrack:", e);
},
onTrigger(e) {
console.log("onTrigger:", e);
}
}
);
</script>
首次运行结果如下:
初次运行时,运行副作用回调并自动收集依赖,四个 onTrack
分别对应 open
,div
,count
和 unused
。由于默认侦听器回调在组件更新前调用,此时 DOM 还没有渲染,id = -1
,count.value = -1 + 1 = 0
。副作用回调在下一次执行前调用,初次运行时并未调用。
接下来,点击第一个 h1
,将 open
值改为 true
,运行结果如下:
open
值由 false
变为 true
状态改变,第二次运行,首先清除副作用,由于同样在 DOM 未更新前调用, div
对应的 DOM 依旧未渲染,id = -1
,count.value = -1 + 10 = 9
,本轮回调完成。
然后,Vue 更新 div
节点,div
值由 null
变为 DOM 对象
,触发第三次回调,这一次 id = 9
,count.value = 9 + 10 = 19
。
再次点击第一个 h1
,将 open
值改为 false
,运行结果如下:
open
值由 true
变为 false
状态改变,第四次运行,首先清除副作用,由于在 DOM 未更新前调用,id = 19
,count.value = 19 + 1 = 20
,本轮回调完成。
然后,Vue 更新 div
节点,div
值由 DOM 对象
变为 null
,触发第五次回调,这一次 id = -1
,count.value = -1 + 1 = 0
。
7.2 后置刷新 flush: 'post'
我们为 watchEffect
添加 flush: 'post'
选项,同样的操作结果如下。
初次运行结果:
第二次运行结果:
第三次运行结果:
7.3 同步刷新 flush: 'sync'
我们为 watchEffect
添加 flush: 'sync'
选项,同样的操作结果如下。
初次运行结果:
第二次运行结果:
第三次运行结果: