Skip to content

Vue 面试题:深入解析 watch 的执行机制

本文档围绕一个经典的 watch 面试题展开,层层递进,深入剖析其背后的响应式原理和执行细节。

一、面试原题

请分析以下 Vue 3 代码段,并预测其最终的控制台输出。

javascript
import { reactive, watch } from 'vue';

const state = reactive({ a: 1, b: 2 });

console.log("代码开始执行");

watch(
  // 源函数 (Source)
  () => {
    const sum = state.a + state.b;
    console.log(`源函数执行, a+b = ${sum}`);
    return sum;
  },
  // 回调函数 (Callback)
  (newValue, oldValue) => {
    console.log("回调函数触发!");
  }
);

setTimeout(() => {
  console.log("1秒后, 修改 state");
  state.a++; // a 变为 2
  state.b--; // b 变为 1
}, 1000);

二、答案与解析

正确答案

代码开始执行
源函数执行, a+b = 3
1秒后, 修改 state
源函数执行, a+b = 3

核心结论:源函数执行了两次,回调函数一次也没有执行。

为什么是这个结果?分步解析

  1. 初始化阶段

    • console.log("代码开始执行") 输出。
    • watch 函数被调用。根据其规则,源函数会立即执行一次,以进行依赖收集并获取初始值。
    • 控制台输出 源函数执行, a+b = 3
    • 在这次执行中,watch 发现源函数读取state.astate.b,于是将它们作为依赖项进行追踪。
    • watch 记录下源函数的初始返回值为 3
  2. setTimeout 阶段

    • 1秒后,setTimeout 的回调开始执行。
    • console.log("1秒后, 修改 state") 输出。
    • state.a++state.b-- 被执行。由于 state.astate.b 都是被追踪的依赖项,它们的变更会触发 watch 的响应。
    • 源函数被再次触发执行
    • 在这次执行中,state.a2state.b1a+b 的结果仍然是 3
    • 控制台输出 源函数执行, a+b = 3
  3. 回调触发判断

    • 源函数第二次执行后,watch 比较其返回值。
    • 新的返回值 (3) 与上一次的返回值 (3) 完全相等。
    • 根据 watch 的核心规则,只有当源函数的返回值发生变化时,回调函数才会被触发
    • 因此,回调函数不会执行。

三、原理深挖:watch 的核心工作法则

从上述题目可以总结出 watch 的三大核心工作法则。

法则一:watch 的两个函数角色

watch 主要由两个函数参数构成:

  • 源函数 (Source): 它的职责是:
    1. 执行并返回一个值:这个值可以是任何类型。
    2. 触发依赖收集:在它执行期间,所有被读取的响应式数据属性都会被记录为依赖。
  • 回调函数 (Callback): 只有在源函数的返回值变化时,它才会被调用,用于执行后续逻辑。

法则二:依赖收集的本质是“读取”

一个响应式数据是否被 watch 追踪,关键在于它是否在源函数的执行过程中被读取 (read) 过。

思考题 1:如果 setTimeout 中修改的是一个未被读取的属性 state.c,会发生什么? 答案:什么都不会发生。因为 state.c 从未在源函数中被读取,所以它不是依赖项,它的改变不会触发源函数重新执行。

法则三:回调的触发条件是“返回值变化”

这是 watch 最容易被误解的地方。触发回调的不是依赖项的变化,而是源函数返回值的变化

思考题 2:如果在源函数中 console.log(state.c),但在 setTimeout 中改变 c,会发生什么?

javascript
// 源函数
() => {
  console.log(`c is ${state.c}`); // 读取了 c,c 成为依赖
  return state.a + state.b;      // 但返回值与 c 无关
}
// setTimeout 中 state.c++

答案:源函数会重新执行(因为依赖项 c 变了),但回调函数依然不会执行(因为返回值 a+b 的结果没变)。


四、进阶面试题:边界情况探测

题目 1:watch 一个对象本身会怎样?

javascript
const state = reactive({ a: 1 });

watch(
  () => state, // 源函数直接返回 state 对象
  () => { console.log("State 变化了!"); }
);

setTimeout(() => { state.a++; }, 1000);

答案:回调函数不会执行。

解析watch 默认进行的是浅比较。源函数每次返回的都是 state 这个 Proxy 对象的引用。虽然 state 内部的属性 a 变了,但 state 对象的引用地址本身没有变。因此 watch 认为返回值没有变化。

如何解决

  1. 深度监听:使用 deep: true 选项。watch(() => state, callback, { deep: true })
  2. 监听具体属性:返回一个包含你想监听的值的新对象或数组。() => ({ ...state })

题目 2:依赖收集能穿透函数调用吗?

javascript
const user = reactive({ name: "Tom" });

function getUserName() {
  return user.name; // 在另一个函数里读取
}

watch(
  () => {
    // 源函数调用了另一个函数来返回值
    return getUserName();
  },
  () => { console.log("User name 变了!"); }
);

setTimeout(() => { user.name = "Jerry"; }, 1000);

答案:回调函数执行。

解析: 依赖收集会追踪源函数同步执行的整个调用栈。无论响应式数据是在源函数内部直接读取,还是在它调用的其他函数中被读取,都会被成功收集为依赖。

总结:面试核心要点

  • 执行时机watch 初始化时,源函数立即执行一次。
  • 依赖收集:只追踪在源函数执行期间被读取过的响应式数据属性。
  • 回调触发:仅当源函数的返回值与上一次相比发生变化时,回调才执行(默认浅比较)。
  • 对象监听:直接监听对象引用默认不生效,需使用 deep: true 或返回新对象来监听内部变化。