Appearance
✨ 侦听器 👌
侦听器和计算属性类似,都是依赖响应式数据。不过计算属性是在依赖的数据发生变化的时候,重新做二次计算,不会涉及到副作用的操作。而侦听器则刚好相反,在依赖的数据发生变化的时候,允许做一些副作用的操作,例如更改 DOM、发送异步请求…
要点速览
watch:显式指定依赖,默认懒执行;适合副作用与精确控制。watchEffect:自动收集依赖,立即执行;适合快速响应多依赖场景。- 数据源:
ref、computed、reactive、Getter 函数、依赖数组。 - 深度侦听:侦听
reactive对象默认是深层次;computed/Getter 返回对象默认不是深层次,需{ deep: true }。 - DOM 时机:访问更新后的 DOM 用
{ flush: 'post' }。 - 停止侦听:保存返回的停止函数
const stop = watch(...); stop();异步创建的侦听器不会自动停止。
快速入门
vue
<template>
<div>
<h1>智能机器人</h1>
<div>
<input v-model="question" placeholder="请输入问题" />
</div>
<div v-if="loading">正在加载中...</div>
<div v-else>{{ answer }}</div>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
const question = ref(""); // 存储用户输入的问题,以 ? 结束
const answer = ref(""); // 存储机器人的回答
const loading = ref(false); // 是否正在加载中
// 侦听器所对应的回调函数,接收两个参数
// 一个是依赖数据的新值,一个是依赖数据的旧值
watch(question, async (newValue) => {
if (newValue.includes("?")) {
loading.value = true;
answer.value = "思考中....";
try {
const res = await fetch("https://yesno.wtf/api");
const result = await res.json();
answer.value = result.answer;
} catch (err) {
answer.value = "抱歉,我无法回答您的问题";
} finally {
loading.value = false;
}
}
});
</script>在上面的示例中,watch 就是一个侦听器,侦听 question 这个 ref 状态的变化,每次当 ref 状态发生变化的时候,就会重新执行后面的回调函数,回调函数接收两个参数:
- 新的状态值
- 旧的状态值
并且在回调函数中,支持副作用操作。
和计算属性的区别
计算属性用于“纯计算”(无副作用),watch 用于“副作用”(如网络请求、修改 DOM、日志等)。当需要在数据变动时做外部动作,优先考虑 watch。
各种细节
1. 侦听的数据源类型
除了上面快速入门中演示的侦听 ref 类型的数据以外,还支持侦听一些其他类型的数据。
计算属性
vue
<template>
<div>
<input type="text" v-model="firstName" placeholder="first name" />
<input type="text" v-model="lastName" placeholder="last name" />
<p>全名:{{ fullName }}</p>
</div>
</template>
<script setup>
import { ref, computed, watch } from "vue";
const firstName = ref("John");
const lastName = ref("Doe");
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
// 设置侦听器
watch(fullName, (newVal, oldVal) => {
console.log(`new: ${newVal}, old: ${oldVal}`);
});
</script>reactive 响应式对象
vue
<template>
<div>
<input type="text" v-model="user.name" placeholder="name" />
<input type="text" v-model="user.age" placeholder="age" />
<p>用户信息:{{ user.name }} - {{ user.age }}</p>
</div>
</template>
<script setup>
import { reactive, watch } from "vue";
const user = reactive({
name: "John",
age: 18,
});
// 设置侦听器
watch(user, () => {
console.log("触发了侦听器回调函数");
});
</script>Getter 函数
vue
<template>
<div>
<input type="number" v-model="count" />
<p>是否为偶数?{{ isEven() }}</p>
<div>count2: {{ count2 }}</div>
<button @click="count2++">+1</button>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
const count = ref(0);
const count2 = ref(0);
// 注意这个函数本身,是每次重新渲染的时候都会重新执行的
function isEven() {
console.log("isEven 函数被重新执行了");
if (count2.value === 5) {
return "this is a test";
}
return count.value % 2 === 0;
}
// 设置侦听器
// 这里侦听的是函数的返回值结果
// 如果函数返回值发生变化,就会触发侦听器回调函数
watch(isEven, () => {
console.log("触发了侦听器回调函数");
});
</script>- 当 watch 第一次注册时,getter 函数会立即被调用,以获取初始值。
- 当 getter 函数依赖的响应式数据发生变化时,getter 函数会被调用。
- 当 getter 函数的返回结果发生变化时,监听器的回调函数会被触发。
多个数据源所组成的数组
vue
<template>
<div>
<div>
<input type="text" v-model="title" />
</div>
<div>
<textarea v-model="description" cols="30" rows="10"></textarea>
</div>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
const title = ref("");
const description = ref("");
// 这里侦听的是多个数据源所组成的数组
// 数组里面任何一个数据发生变化,都会触发回调函数
watch([title, description], () => {
console.log("侦听器的回调函数执行了");
});
</script>2. 侦听层次
这个主要是针对 reactive 响应式对象,当侦听的数据源是 reactive 类型数据的时候,默认是深层次侦听,这意味着哪怕是嵌套的属性值发生变化,侦听器的回调函数也会重新执行。
vue
<template>
<div>
<h1>任务列表</h1>
<ul>
<li v-for="task in tasks.list" :key="task.id">
{{ task.title }} - {{ task.completed ? "已完成" : "未完成" }}
<button @click="task.completed = !task.completed">切换状态</button>
</li>
</ul>
</div>
</template>
<script setup>
import { reactive, watch } from "vue";
const tasks = reactive({
list: [
{ id: 1, title: "Task 1", completed: false },
{ id: 2, title: "Task 2", completed: true },
],
});
watch(tasks, () => {
console.log("侦听器触发了!");
});
</script>通过上面的例子,我们可以看出,当侦听的是 reactive 类型的响应式对象时,是深层次侦听的。
性能注意
深层次侦听非常方便,但在大型数据结构上开销较大。优先侦听具体属性(通过 Getter),仅在必要时使用深度侦听。
另外补充一个点,当侦听的是 reactive 对象的时候,不能直接侦听响应式对象的属性值:
常见误区
watch(obj.count, ...) 会把一个基本类型(如 number)作为数据源传入,无法建立响应追踪。应改为 watch(() => obj.count, ...)。
js
const obj = reactive({ count: 0 });
// 错误,因为 watch() 得到的参数是一个 number
watch(obj.count, (count) => {
console.log(`count is: ${count}`);
});可以将上面的例子修改为一个 Getter 函数:
js
const obj = reactive({ count: 0 });
watch(
() => obj.count,
(count) => {
console.log(`count is: ${count}`);
}
);为什么 obj 和 obj.count 都是 proxy 而 obj.count.count1 不是 proxy?
const obj = reactive({ count: { count1: 0 } })
Proxy 行为解析
在 Vue 3 中,reactive 会返回一个代理(Proxy)来拦截访问与修改:
- 对象代理:
reactive({ count: { count1: 0 } })返回的整体是代理对象。 - 递归代理:当访问
obj.count时,若其值为对象,也会被递归转换为代理,因此obj.count也是代理对象。 - 基本类型:
count1是基本类型(数字/字符串等),不能被代理;但修改obj.count.count1会被其所在对象的代理拦截,从而触发更新。
3. 第三个参数
- 第一个参数:侦听的数据源
- 第二个参数:数据发生变化时要执行的回调函数
- 第三个参数:选项对象
immediate: true | false- 默认情况下,
watch的回调是懒执行;设置为true可在注册时立即执行一次(常用于初始化请求)。
- 默认情况下,
deep: true | false- 强制深层次侦听;当侦听一个由计算属性或 Getter 返回的对象时,默认不是深层次,需显式指定。
flush: 'pre' | 'post' | 'sync'- 控制回调触发时机:
pre(默认,渲染前)、post(组件更新后,适合读 DOM)、sync(同步触发,谨慎使用)。
- 控制回调触发时机:
once: true | false- 让回调只触发一次(Vue 3.4+ 可用);更低版本可在回调中调用返回的停止函数来实现一次性。
vue
<template>
<div>
<div v-for="task in tasks" :key="task.id" @click="selectTask(task)">
{{ task.title }} ({{ task.completed ? "Completed" : "Pending" }})
</div>
<hr />
<div v-if="selectedTask">
<h3>Edit Task</h3>
<input v-model="selectedTask.title" placeholder="Edit title" />
<label>
<input type="checkbox" v-model="selectedTask.completed" />
Completed
</label>
</div>
</div>
</template>
<script setup>
import { reactive, computed, watch } from "vue";
const tasks = reactive([
{ id: 1, title: "Learn Vue", completed: false },
{ id: 2, title: "Read Documentation", completed: false },
{ id: 3, title: "Build Something Awesome", completed: false },
]);
const selectedId = reactive({ id: null });
// 这是一个计算属性
const selectedTask = computed(() => {
return tasks.find((task) => task.id === selectedId.id);
});
// 侦听的是一个 Getter 函数
// 该 Getter 函数返回计算属性的值
watch(
() => selectedTask.value, // 计算属性返回ref
() => {
console.log("Task details changed");
},
{ deep: true }
);
function selectTask(task) {
selectedId.id = task.id;
}
</script>watchEffect
watchEffect 相比 watch 而言,能够自动跟踪回调里面的响应式依赖,对比如下:
watch
js
const todoId = ref(1);
const data = ref(null);
watch(
todoId, // 第一个参数需要显式的指定响应式依赖
async () => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${todoId.value}`
);
data.value = await response.json();
},
{ immediate: true }
);watchEffect
js
// 不再需要显式的指定响应式数据依赖
// 在回调函数中用到了哪个响应式数据,该数据就会成为一个依赖
watchEffect(async () => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${todoId.value}`
);
data.value = await response.json();
});对于只有一个依赖项的场景来讲,watchEffect 的收益不大,但是如果涉及到多个依赖项,那么 watchEffect 的好处就体现出来了。
watchEffect 相比 watch 还有一个特点:如果你需要侦听一个嵌套的数据结构的几个属性,那么 watchEffect 只会侦听回调中用到的属性,而不是递归侦听所有的属性。
watchEffect 会立即执行一次。
vue
<template>
<div>
<h1>团队管理</h1>
<ul>
<li v-for="member in team.members" :key="member.id">
{{ member.name }} - {{ member.role }} - {{ member.status }}
</li>
</ul>
<button @click="updateLeaderStatus">切换领导的状态</button>
<button @click="updateMemberStatus">切换成员的状态</button>
</div>
</template>
<script setup>
import { reactive, watchEffect } from "vue";
const team = reactive({
members: [
{ id: 1, name: "Alice", role: "Leader", status: "Active" },
{ id: 2, name: "Bob", role: "Member", status: "Inactive" },
],
});
// 有两个方法,分别是对 Leader 和 Member 进行状态修改
function updateLeaderStatus() {
const leader = team.members.find((me) => me.role === "Leader");
// 切换状态
leader.status = leader.status === "Active" ? "Inactive" : "Active";
}
function updateMemberStatus() {
const member = team.members.find((member) => member.role === "Member");
member.status = member.status === "Active" ? "Inactive" : "Active";
}
// 添加一个侦听器
watchEffect(() => {
// 获取到 leader
const leader = team.members.find((me) => me.role === "Leader");
// 输出 leader 当前的状态
console.log("Leader状态:", leader.status);
});
</script>回调触发的时机
默认情况下,侦听器回调的执行时机在父组件更新 之后,所属组件的 DOM 更新 之前 被调用。这意味着如果你尝试在回调函数中访问所属组件的 DOM,拿到的是 DOM 更新之前的状态。
vue
<template>
<div>
<button @click="isShow = !isShow">显示/隐藏</button>
<div v-if="isShow" ref="divRef">
<p>this is a test</p>
</div>
<p>上面的高度为:{{ height }} pixels</p>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
const isShow = ref(false);
const height = ref(0); // 存储高度
const divRef = ref(null); // 获取元素
watch(isShow, () => {
// 获取高度,将高度值给 height
height.value = divRef.value ? divRef.value.offsetHeight : 0;
console.log(`当前获取的高度为:${height.value}`);
});
</script>如果我们期望侦听器的回调在 DOM 更新之后再被调用,那么可以将第三个参数 flush 设置为 post 即可,如下:
js
watch(
isShow,
() => {
// 获取高度,将高度值给 height
height.value = divRef.value ? divRef.value.offsetHeight : 0;
console.log(`当前获取的高度为:${height.value}`);
},
{
flush: "post",
}
);停止侦听器
大多数情况下你是不需要关心如何停止侦听器,组件上面所设置的侦听器会在组件被卸载的时候自动停止。
但是上面所说的自动停止仅限于同步设置侦听器的情况,如果是异步设置的侦听器,那么组件被销毁也不会自动停止:
vue
<script setup>
import { watchEffect } from "vue";
watchEffect(() => {}); // 它会自动停止
setTimeout(() => {
watchEffect(() => {}); //...这个则不会!
}, 100);
</script>这种情况下,就需要手动的去停止侦听器。
要手动的停止侦听器,就和 setTimeout 或者 setInterval 类似,调用一下返回的函数即可。
js
const unwatch = watchEffect(() => {});
// 手动停止
unwatch();下面是一个具体的示例:
vue
<template>
<div>
<button @click="a++">+1</button>
<p>当前 a 的值为:{{ a }}</p>
<p>{{ message }}</p>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
const a = ref(1); // 计数器
const message = ref(""); // 消息
// 假设我们期望 a 的值到达一定的值之后,停止侦听
const unwatch = watch(
a,
(newVal) => {
// 当值大于 5 的时候,停止侦听
if (newVal > 5) {
unwatch();
}
message.value = `当前 a 的值为:${a.value}`;
},
{ immediate: true }
);
</script>