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

watchwatchEffect 都能响应式地执行副作用回调。主要区别在于追踪响应式依赖的方式不同:

  • 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>

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

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>

首次运行结果如下:

flush: 'pre' 页面初次渲染结果 flush: 'pre' 控制台初次打印结果

初次运行时,运行副作用回调并自动收集依赖,四个 onTrack 分别对应 opendivcountunused。由于默认侦听器回调在组件更新前调用,此时 DOM 还没有渲染,id = -1count.value = -1 + 1 = 0。副作用回调在下一次执行前调用,初次运行时并未调用。

接下来,点击第一个 h1,将 open 值改为 true,运行结果如下:

flush: 'pre' 页面第二次渲染结果 flush: 'pre' 控制台第二次打印结果

open 值由 false 变为 true 状态改变,第二次运行,首先清除副作用,由于同样在 DOM 未更新前调用, div 对应的 DOM 依旧未渲染,id = -1count.value = -1 + 10 = 9,本轮回调完成。

然后,Vue 更新 div 节点,div 值由 null 变为 DOM 对象,触发第三次回调,这一次 id = 9count.value = 9 + 10 = 19

再次点击第一个 h1,将 open 值改为 false,运行结果如下:

flush: 'pre' 页面第三次渲染结果 flush: 'pre' 控制台第三次渲染结果

open 值由 true 变为 false 状态改变,第四次运行,首先清除副作用,由于在 DOM 未更新前调用,id = 19count.value = 19 + 1 = 20,本轮回调完成。

然后,Vue 更新 div 节点,div 值由 DOM 对象 变为 null ,触发第五次回调,这一次 id = -1count.value = -1 + 1 = 0

7.2 后置刷新 flush: 'post'

我们为 watchEffect 添加 flush: 'post' 选项,同样的操作结果如下。

初次运行结果:

flush: 'post' 页面初次渲染结果 flush: 'post' 控制台初次打印结果

第二次运行结果:

flush: 'post' 页面第二次渲染结果 flush: 'post' 控制台第二次打印结果

第三次运行结果:

flush: 'post' 页面第三次渲染结果 flush: 'post' 控制台第三次渲染结果

7.3 同步刷新 flush: 'sync'

我们为 watchEffect 添加 flush: 'sync' 选项,同样的操作结果如下。

初次运行结果:

flush: 'sync' 页面初次渲染结果 flush: 'sync' 控制台初次打印结果

第二次运行结果:

flush: 'sync' 页面第二次渲染结果 flush: 'sync' 控制台第二次打印结果

第三次运行结果:

flush: 'sync' 页面第三次渲染结果 flush: 'sync' 控制台第三次渲染结果