Skip to content

✨ 侦听器 👌

侦听器和计算属性类似,都是依赖响应式数据。不过计算属性是在依赖的数据发生变化的时候,重新做二次计算,不会涉及到副作用的操作。而侦听器则刚好相反,在依赖的数据发生变化的时候,允许做一些副作用的操作,例如更改 DOM、发送异步请求…

要点速览

  • watch:显式指定依赖,默认懒执行;适合副作用与精确控制。
  • watchEffect:自动收集依赖,立即执行;适合快速响应多依赖场景。
  • 数据源:refcomputedreactive、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)来拦截访问与修改:

  1. 对象代理:reactive({ count: { count1: 0 } }) 返回的整体是代理对象。
  2. 递归代理:当访问 obj.count 时,若其值为对象,也会被递归转换为代理,因此 obj.count 也是代理对象。
  3. 基本类型: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>