什么是侦听器(监听器)?
侦听器主要用于侦听依赖的数据(也就是响应式数据),当依赖发生变更的时候要能侦听到(监听到)这个值的变化,从而给开发者一个 API 接口能完成接下来自定义的任务。
function test(a, b, callback) {const result = a + b;// 完成任务callback(result);}test(1, 2, (result)=>{console.log(result);})
以上代码在运行完a + b的结果后就会执行callback,然后开发者就可以在callback()内编写自定义的任务,而这个callback就是一个函数的 API 接口。
基本使用
Vue 在组合式 API 同提供了一个watch()函数让我们可以侦听依赖的变化。watch()函数的第一个参数表示要侦听的数据源,第二个参数表示当依赖数据发生变化时被执行的回调函数,在该函数内会接收两个参数分别是:依赖变化的新值、依赖变化的旧值。
简单说,就是当依赖的数据发生了变化,回调函数会立即执行。
import { ref, watch } from "vue";export default {setup(){const x = ref("test content");watch(x, (newVal, oldVal) => {console.log(`x is ${newVal}`)})}}
watch()的第一个参数可以是不同形式的“数据源”:它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组:
const x = ref(0)const y = ref(0)// 单个 refwatch(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}`)})
如果你侦听的数据源是一个 getter 函数,那么当 getter 函数内任意一个值发生变化的时候,watch()都会重新执行一次该函数,然后把函数返回的结果传递给 callback:
watch(() => `我演讲的题目是「${ title.value }」,我要讲的内容是「${ content.value }」`,(newVal){console.log(newVal); // 我演讲的题目是 xxx ,我要讲的内容是 xxx})
但是,如果 getter 函数内两个(或多个)值同时发生了变化,**watch()**的回调只会执行一次!!!
这是因为当一次性操作两个数据变化的时候,watch()会进行依赖收集,缓存回调的执行,然后合并为一次来操作,这和computed()依赖收集很类似。
你不能直接把一个 Reactive 对象的属性传递给watch()进行侦听,因为这样被侦听的数据不是响应式的,这和 Reactive 对象的属性不能解构和当作函数参数进行传递是一样的道理。
const obj = reactive({ count: 0 })// 错误,因为 watch() 得到的参数是一个 numberwatch(obj.count, (count) => {console.log(`count is: ${count}`)})
你可以改写为一个 getter 函数:
const obj = reactive({ count: 0 })watch(() => obj.count, (count) => {console.log(`count is: ${count}`)})
当内部数据发生变化的时候,整个 getter 函数将作为侦听的依赖,把函数作为一个引用。所以被侦听的数据必须是引用数据类型的对象,这样才能获取到数据的变化!!!
深度侦听
如果你侦听的数据源是一个响应式对象,会隐式地创建一个深层侦听器,该回调函数在所有属性的变更时都会被触发:
const obj = reactive({ count: 0 });setTimeout(() => {obj.count++;}, 1000);watch(obj, (newObj, oldObj) => {// 注意:`newObj` 此处和 `oldObj` 是相等的// 因为它们是同一个对象!console.log(`count is: ${newObj.count}`);});
如果侦听的是一个 getter 函数返回对象的时候,那么当对象内属性发生变化的时候是侦听不到的,你需要深度侦听:
const obj = reactive({ count: 0 });setTimeout(() => {obj.count++;}, 1000);watch(() => obj, // count 变化无法被侦听(newObj, oldObj) => {console.log(`count is: ${newObj.count}`);});
我们需要给watch()传入第 3 个参数,该参数表示一个配置项:
const obj = reactive({ count: 0 });setTimeout(() => {obj.count++;}, 1000);watch(() => obj,(newObj, oldObj) => {console.log(`count is: ${newObj.count}`);},{// 表示深度监听deep: true});
这样不仅可以侦听到obj本身还可以侦听到obj下面属性的变化。
:::warning
⚠️ 注意
深度侦听需要遍历被侦听对象中的所有嵌套的属性,当用于大型数据结构时,开销很大。因此请只在必要时才使用它,并且要留意性能。
:::
即时回调的侦听器
watch()默认是懒加载的,仅当数据源变化时,才会执行回调。但在某些场景中,我们希望在创建侦听器时,立即执行一遍回调。
举例来说,我们想请求一些初始数据,然后在相关状态更改时重新请求数据。这个时候我们可以为watch()配置immediate: true来解决这个问题:
watch(source, (newValue, oldValue) => {// 立即执行,且当 `source` 改变时再次执行}, { immediate: true })
这样,组件会在创建后立即执行一次watch()的回调。
回调触发的时机
默认情况下,watch()的回调会在组件更新之前被调用,这就导致我们有时候想获取 DOM 的内容获取到的是更新之前的内容。
<div ref="titleRef">{{ title }}</div>
const title = ref("This is title.");const titleRef = ref(null);setTimeout(() => {title.value = "这是标题";}, 1000);watch(title, (newVal, oldVal) => {// 当 title 数据变化时候// 获取 DOM 的内容依然是 This is title.console.log(titleRef.value.innerText);});
如果你想获取到组件更新之后的 DOM 内容,可以给watch()配置一个flush: "post"来表示在组件更新之后再执行回调:
const title = ref("This is title.");const titleRef = ref(null);setTimeout(() => {title.value = "这是标题";}, 1000);watch(title,(newVal, oldVal) => {// 这样获取到的 DOM 内容就是“这是标题”console.log(titleRef.value.innerText);},{flush: "post"});
onTrack() 与 onTrigger()
onTrack()和onTrigger()是提供给开发者在开发模式下调试使用的,onTrack()会在当依赖源被追踪调用的时候执行,onTrigger()会在依赖源被更改的时候执行。
详见文档:
深入响应式系统 | Vue.js
watch(title,(newVal, oldVal) => {console.log(newVal, oldVal);},{onTrack: (e) => {debugger;console.log(e);},onTrigger: (e) => {debugger;console.log(e);}});
停止侦听
在组合式 API 中用同步语句创建的侦听器,会自动绑定到宿主组件实例上,并且会在宿主组件卸载时自动停止。因此,在大多数情况下,你无需关心怎么停止一个侦听器。
需要注意的是,侦听器必须用同步语句创建:如果用异步回调创建一个侦听器,那么它不会绑定到当前组件上,你必须手动停止它,以防内存泄漏。
// 它会自动停止watchEffect(() => {})// ...这个则不会!setTimeout(() => {watchEffect(() => {})}, 100)
要手动停止一个侦听器,请调用watch()或watchEffect()返回的函数:
const unwatch = watchEffect(() => {})// ...当该侦听器不再需要时unwatch()
watchEffect()
Vue 还提了watchEffect()方法用来监听数据:
watchEffect(() => {// do something});
watchEffect()的名字就是watch+Effect,Effect表示副作用,那么什么是前端中的副作用呢?
简单的来说,只要是跟函数外部环境发生的交互就都是副作用。从“副作用”这个词语来看,它更多的情况在于“改变系统状态”。包括:
- 更改文件系统
- 往数据库插入记录
- 发送一个 http 请求
- 可变数据
- 打印/log
- 获取用户输入
- DOM 查询
- 访问系统状态
而watchEffect()函数就是让我们在回调函数内处理副作用的,它会观察副作用函数内部的依赖数据是不是发生了变化,然后执行回调函数。
watchEffect()和watch()的区别:
1、watch()需要指定被侦听的依赖数据。watchEffect()不需要指定被侦听的数据,它会的追踪回调函数中的响应式依赖数据。
2、watch()是懒执行的,并不是在某个时间立即执行的,而是看侦听的数据是否发生了变化来决定是否执行回调(这里不指immediate: true的情况)。watchEffect()是在侦听器创建和依赖的数据发生变更的时候都会执行回调。
3、watch()可以在回调函数内通过形参的方式拿到依赖变化的新值和旧值。watchEffect()无法获取到旧值,新值也是通过依赖数据本身来获取的,无法在回调函数内通过形参的方式获取值。
综上,如果你想拿到依赖数据的新值和旧值,使用watch()更加合适。
watchEffect()是会在被创建的时候首先执行一次,这其实是有目的的,是为了收集依赖数据。
watchEffect(() => {// 触发 title 的 getter 机制,依赖被收集console.log(title.value);});
watchEffect()方法同样也支持对侦听器进行配置,flush: "pre"是它的默认配置,表示在组件挂载、「组件更新之前」执行副作用回调函数,是异步执行的。如果一次性改变多个依赖数据,那么回调函数「只会执行一次」:
const title = ref("This is title.");const content = ref("This is content.");watchEffect(() => {const str = title.value + "--" + content.value;console.log(str);},{flush: "pre"});setTimeout(() => {// 同时更改 title 和 content 的值title.value = "这是标题。";content.value = "这是内容。";}, 1000);

当给watchEffect()配置flush: "post"的时候,和watch()效果是一样的,都会在组件挂载,「组件更新之后」执行回调函数:
watchEffect(() => {const str = title.value + "--" + content.value;console.log(str);},{flush: "post"});
当给watchEffect()配置flush: "sync"的时候,也会在组件挂载、「组件更新之前」执行回调函数,但是不会进行缓存,它是同步执行的。简单说,就是有多少个依赖变化,那么回调函数就会执行多少次。
watchEffect(() => {const str = title.value + "--" + content.value;console.log(str);},{flush: "sync"});setTimeout(() => {// 同时更改 title 和 content 的值title.value = "这是标题。";content.value = "这是内容。";}, 1000);
:::warning
⚠️ 注意
你应该谨慎使用,因为如果有多个属性同时更新,这将导致一些性能和数据一致性的问题。
:::
onCleanup
watchEffect()的第一个参数是一个副作用回调函数,这个我们上面已经说过了,而这个副作用的回调函数也有一个参数,该参数也是一个函数,这就是onCleanup。
watchEffect((onCleanup) => {onCleanup(()=>{// do something})})
onCleanup的作用?官方是这么说的:
第一个参数就是要运行的副作用函数。这个副作用函数的参数也是一个函数,用来注册清理回调。清理回调会在该副作用下一次执行前被调用,可以用来清理无效的副作用。
什么意思呢?我们通过一个案例来解释:
function sendRequest(id) {let timer = null;function response() {return new Promise((resovle, reject) => {timer = setTimeout(() => {resovle(`ID为「${id}」的结果是「1234567890」`);}, 1000);});}function cancel() {clearTimeout(t);}return { response, cancel };}
以上代码,我们模拟了一个 Ajax 的请求,暴露出去一个response()方法用于发起请求,cancel()用于取消请求。
<template><div class=""><button @click="changeID">请求数据</button>{{ id }}</div></template>
const id = ref(new Date().getTime());const changeID = () => (id.value = new Date().getTime());watchEffect(async () => {console.log(1);const { response, cancel } = sendRequest(id.value);let result = await response();console.log(result);});
以上代码,我们通过点击按钮来更改id,当id改变后watchEffect()就会执行回调。
通过图片我们可以看到一个现象:当我在请求数据返回后再点击下一次请求这样是没问题的。可是当我在数据请求还没返回的时候连续点击,这样就会连续触发watchEffect()的回调,也就是连续发起请求。
所以,我需要想一个办法就是当我点击按钮的时候,如果上一次的请求还没有返回,那么我就停止上一次的请求并发起一个新的请求。
嗯。。。好像可以用函数防抖来优化一下哈。
而这里的onCleanup()方法,就是为了解决这样类似的问题而产生的,整个流程类似函数防抖的流程。
:::info
**onCleanup()**方法就是为了清除上一次副作用回调函数中的某些程序(例如网络请求),**onCleanup()**会在下一次回调执行之前执行,然后再执行副作用回调函数。
:::
const id = ref(new Date().getTime());const changeID = () => (id.value = new Date().getTime());watchEffect(async (onCleanup) => {console.log(1);const { response, cancel } = sendRequrst(id.value);// 清除上一次的请求任务onCleanup(cancel);let result = await response();console.log(result);});

这样,当我们连续点击按钮的时候,请求任务都会被清除停止,然后在重新发起一个请求!
