前端知库

computed 和 watch 的实现原理是什么?computed 为什么有缓存?

computed 和 watch 底层都是响应式 effect。computed 是懒的:依赖变了只把自己标记为“脏”,下次读 .value 才重新计算,没变就直接返回缓存值。watch 把数据源包成 getter,依赖变了由调度器安排任务,任务里重新执行 getter,用 Object.is 比较新旧值,变了才调用回调。Vue 3.4 起 computed 结果没变时不再触发下游,3.5 又用版本号重写了脏检查。

参考答案

简答

购物车里有个 total = computed(() => price.value * count.value),模板里用了三次 total,getter 只会执行一次;price 改了以后,getter 也不会马上执行,要等下次有人读 total.value 时才重新算。这就是 computed 的缓存和懒计算。

原理是 computed 内部有一个“脏”标记。它的 getter 作为一个响应式 effect 运行,读到的 price、count 会把它记为依赖。依赖变了,Vue 不重新计算,只把标记设为脏,并通知读过它的组件或 watch。下次读 .value,看到是脏的才重算,算完清掉标记;不脏就直接返回上次的结果。

watch 也是建在 effect 上的。它把你传的数据源包成一个 getter 执行,收集依赖;依赖变了,交给调度器安排一个任务,任务里重新执行 getter,用 Object.is 比较新旧值,变了才调用回调,并把新值和旧值传进去。deep 就是在 getter 里把对象的属性都读一遍,flush 决定这个任务在组件更新前、更新后还是同步执行。

版本上要注意,Vue 3.4 起 computed 重算后结果没变,就不会再触发下游;3.5 又重构了响应式系统,用版本号判断依赖有没有变。所以“computed 的依赖一变,读它的组件就一定重新渲染”这个说法放到 3.4 之后不准确。

computed 为什么不马上重新计算

先看一个教学用的极简实现,它不是 Vue 源码,只保留了“脏标记 + 懒计算 + 通知下游”这三件事,watch 也只做了最简单的同步版本:

JavaScript
let activeEffect = null;

function ref(value) {
  const subs = new Set();
  return {
    get value() {
      if (activeEffect) subs.add(activeEffect);
      return value;
    },
    set value(next) {
      if (Object.is(next, value)) return;
      value = next;
      [...subs].forEach((sub) => sub.notify());
    },
  };
}

function computed(getter) {
  let cached;
  let dirty = true;
  const subs = new Set();
  const self = {
    // 依赖变了只做标记,不马上重算
    notify() {
      if (dirty) return;
      dirty = true;
      [...subs].forEach((sub) => sub.notify());
    },
    get value() {
      if (activeEffect) subs.add(activeEffect);
      if (dirty) {
        const prev = activeEffect;
        activeEffect = self;
        try {
          cached = getter();
        } finally {
          activeEffect = prev;
        }
        dirty = false;
      }
      return cached;
    },
  };
  return self;
}

function watch(source, callback) {
  let oldValue;
  const runner = {
    notify() {
      const newValue = run();
      if (!Object.is(newValue, oldValue)) {
        callback(newValue, oldValue);
        oldValue = newValue;
      }
    },
  };
  function run() {
    const prev = activeEffect;
    activeEffect = runner;
    try {
      return source();
    } finally {
      activeEffect = prev;
    }
  }
  oldValue = run();
}

const price = ref(10);
const count = ref(2);
let runs = 0;
const total = computed(() => {
  runs += 1;
  return price.value * count.value;
});

console.log(runs); // 输出 0
console.log(total.value, total.value, runs); // 输出 20 20 1
price.value = 30;
console.log(runs); // 输出 1
console.log(total.value, runs); // 输出 60 2

watch(() => total.value, (next, prev) => {
  console.log(`total: ${prev} -> ${next}`); // 输出 total: 60 -> 90
});
count.value = 3;

创建 total 后 runs 还是 0,为什么 getter 一次都没执行?因为 dirty 初始是 true,计算被推迟到第一次读 .value。连续读两次,第二次看到 dirty 已经是 false,直接返回 cached,所以 runs 是 1。

price.value = 30 之后 runs 仍然是 1。price 的 setter 通知了 total,但 total.notify() 只把 dirty 设回 true,没有调用 getter。等到下一行读 total.value,才重算出 60。如果在两次读取之间 price 改了十次,getter 也只会执行一次。

最后的 watch 用 getter 读了 total.value,所以 total 把它记成下游。count 改了,total 变脏并通知 watch,watch 重新执行 getter,读到新值 90,和旧值 60 不同,于是调用回调。

真实的 Vue 多做了什么

教学版里,computed 一变脏就通知下游,下游一定会重新执行。Vue 3.4 改进了这一点:下游被通知后,会先让 computed 重算,只有结果真的变了才继续执行。官方博客举的例子是 isEven = computed(() => count.value % 2 === 0),count 从 0 改成 2,isEven 还是 true,3.4 之前读它的 watchEffect 也会重新执行,3.4 起只有结果真的变了才会执行。

Vue 3.5 又把响应式系统重写了一次,官方称行为不变、内存占用下降 56%。在当前 vuejs/core 的 computed.ts 和 effect.ts 里,脏检查是这样做的:每个依赖(Dep)有一个版本号,每次触发就加一,同时全局还有一个 globalVersion。读 computed.value 时调用 refreshComputed,如果全局版本号自上次计算以来没变,说明没有任何响应式数据改过,直接返回缓存;否则逐个检查依赖的版本号,有变化才重新执行 getter。重算后用 hasChanged(内部是 Object.is)比较新旧结果,变了才给自己的版本号加一,下游据此判断要不要更新。

源码注释还提到一个边界:SSR 时没有渲染 effect 订阅 computed,它不会追踪依赖,所以每次读取都重新计算,只靠全局版本号这条快速路径做缓存。

watch 是怎样建在 effect 上的

在 watch.ts 里,watch 先根据数据源的类型生成 getter:ref 就读 .value,reactive 对象就深度遍历它的属性,数组就逐个处理,函数就直接调用。传了 deep 时,在 getter 外面再包一层 traverse,把返回值的每一层属性都读一遍,这样任何嵌套属性变化都会被追踪到。

然后用这个 getter 创建一个 ReactiveEffect,并给它一个调度器。依赖变化时,effect 不直接执行,而是把一个任务交给调度器。任务里重新执行 getter 拿到新值,如果设置了 deep、immediate,或者新旧值经 hasChanged 比较不同,就先执行上次登记的清理函数,再调用回调,最后把新值存为旧值。

调度器决定任务什么时候跑。官方文档说明,默认情况下回调在父组件更新之后、当前组件 DOM 更新之前执行;flush: 'post' 改为 DOM 更新之后;flush: 'sync' 则在数据变化时同步执行。watchEffect 没有回调,任务里直接重新执行 effect 本身,所以它没有新旧值可比。

为什么 computed 里不能写副作用

官方文档要求 computed 的 getter 只做纯计算,不改别的状态、不发请求、不操作 DOM。从原理上看也说得通:getter 什么时候执行由读取时机和脏标记决定,可能晚执行,可能被缓存跳过。把副作用写进去,执行次数和时机都无法预期。要在数据变化时做事,就用 watch,回调的触发时机是明确的。

常见追问

computed 和 method 都能算出同样的值,区别在哪?

method 每次渲染都会重新调用;computed 按响应式依赖缓存,依赖没变就直接返回上次的结果。官方的说法是,不想要缓存时才用 method。还要注意,像 Date.now() 这种不是响应式依赖的值,computed 读了也不会因为时间变化而更新。

watch 一个 reactive 对象,为什么新值和旧值一样?

因为改的是对象内部的属性,对象本身还是同一个,回调拿到的新值和旧值指向同一个对象。传入 reactive 对象时 watch 会自动深度监听,所以回调能触发,但想比较前后差别,要监听具体的属性,比如 () => state.count,或者自己保存一份拷贝。

怎样选 computed、watch 和 watchEffect?

要从已有数据派生一个值,用 computed;数据变了要做一件事,比如发请求,用 watch 或 watchEffect。使用层面的区别详见《computed、watch 和 watchEffect 应该如何选择?》,依赖收集本身详见《Vue 3 响应式依赖是怎样收集和触发的?》。