从 Effect 到 Computed:一次自然的延伸

Q: 有了上一篇手写的 effect、track、trigger 之后,computed 和 watch 是不是就很容易实现了?

A: 是的。你会发现,只要前面的响应式系统骨架搭建好了,computed 和 watch 的实现就是这些基础设施的自然延伸——本质上都是对 effect 的不同封装方式。computed 的关键在于缓存和脏值检测,watch 的关键在于旧值保存和调度时机控制

回顾 computed 的两种用法

在动手实现之前,先回顾 computed 的两种调用方式:

// 方式一:传入 getter 函数
const state = reactive({ a: 1, b: 2 });
const sum = computed(() => {
  return state.a + state.b;
});

// 方式二:传入包含 get/set 的对象
const firstName = ref('John');
const lastName = ref('Doe');

const fullName = computed({
  get() {
    return firstName.value + ' ' + lastName.value;
  },
  set(newValue) {
    [firstName.value, lastName.value] = newValue.split(' ');
  },
});

手写 computed:逐步实现

第一步:参数归一化

由于 computed 支持函数和对象两种参数形式,第一步是将它们统一处理:

function normalizeParameter(getterOrOptions) {
  let getter, setter;
  if (typeof getterOrOptions === 'function') {
    getter = getterOrOptions;
    setter = () => {
      console.warn(`Computed property was assigned to but it has no setter.`);
    };
  } else {
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }
  return { getter, setter };
}

无论用户传入的是函数还是对象,统一转换为 { getter, setter } 格式。如果只传入 getter 函数,setter 默认发出警告,这和 Vue3 原生行为一致。

第二步:建立依赖关系 + 缓存

computed 的核心特性是缓存计算结果——只有当依赖的响应式数据变化时,才重新计算。我们用 value 变量存储缓存值,用 dirty 标记标识数据是否「过期」:

let value,
  dirty = true; // 初始为 true,确保第一次访问时执行计算

// 将 getter 传入 effect,getter 内部的响应式数据与 getter 建立依赖
const effetcFn = effect(getter, {
  lazy: true,  // 不立即执行
});

这里 lazy: true 至关重要——计算属性只有在被访问时才进行计算,而非在创建时立即执行。

第三步:返回带 value 访问器的对象

const obj = {
  get value() {
    if (dirty) {
      value = effetcFn(); // 执行 getter,重新计算
      dirty = false;      // 标记为非脏,下次直接返回缓存
    }
    return value;
  },
  set value(newValue) {
    setter(newValue);     // 调用用户的 setter
  },
};
return obj;

此时基本功能已经可用,但还有一个关键问题需要解决。

第四步:让外部函数感知计算属性的变化

考虑这个场景——一个渲染函数(或其他 effect)依赖了计算属性的值:

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

const sum = computed(() => {
  console.log('computed');
  return state.a + state.b;
});

effect(() => {
  // 模拟渲染函数,依赖 sum
  console.log('render', sum.value);
});

state.a++; // 期望:computed 重新计算 + render 重新执行

实际执行结果:

computed
render 3
computed        // computed 重新执行了
                // 但 render 没有重新执行!

虽然 state.a++ 导致 computed 内部的 getter 重新执行了(dirty 重新标记为 true),但渲染函数并不知道计算属性的值变了。原因是渲染函数和计算属性之间没有建立依赖关系。

解决方案分两步:

Step 1 — 在获取 computed.value 时,手动建立依赖:

const obj = {
  get value() {
    track(obj, TrackOpTypes.GET, 'value'); // ← 手动收集依赖
    if (dirty) {
      value = effetcFn();
      dirty = false;
    }
    return value;
  },
  // ...
};

这样,当渲染函数(effect)中访问 sum.value 时,会触发 track(obj, 'value'),从而建立**「计算属性 value ↔ 渲染函数」**的依赖关系。

Step 2 — 当依赖数据变化时,触发计算属性 value 的更新:

const effetcFn = effect(getter, {
  lazy: true,
  scheduler() {
    dirty = true;                             // 标记为脏
    trigger(obj, TriggerOpTypes.SET, 'value'); // 通知依赖 value 的函数(渲染函数)
  },
});

scheduler 在这里起到了中间桥梁的作用:

  1. state.a 变化 → 触发 computed 内部 getter 对应的 effect
  2. 由于配置了 scheduler,不会直接执行 getter,而是执行 scheduler
  3. scheduler 中:标记 dirty = true + 派发更新通知渲染函数
  4. 渲染函数重新执行 → 再次访问 sum.value → 由于 dirty 为 true → 重新计算

整个数据流如下:

state.a 变化
  → scheduler 执行
    → dirty = true
    → trigger(obj, SET, 'value')
      → 渲染函数重新执行
        → 访问 sum.value
          → track(obj, GET, 'value')
          → dirty 为 true,执行 getter 重新计算
          → 返回新值

完整实现

import { effect } from './effect/effect.js';
import track from './effect/track.js';
import trigger from './effect/trigger.js';
import { TriggerOpTypes, TrackOpTypes } from './utils.js';

function normalizeParameter(getterOrOptions) {
  let getter, setter;
  if (typeof getterOrOptions === 'function') {
    getter = getterOrOptions;
    setter = () => {
      console.warn(`Computed property was assigned to but it has no setter.`);
    };
  } else {
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }
  return { getter, setter };
}

export function computed(getterOrOptions) {
  // 1. 参数归一化
  const { getter, setter } = normalizeParameter(getterOrOptions);

  // 2. 缓存值和脏标记
  let value,
    dirty = true;

  // 3. 将 getter 包装为 effect,建立响应式依赖
  const effetcFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true;
      trigger(obj, TriggerOpTypes.SET, 'value');
    },
  });

  // 4. 返回带 getter/setter 的对象
  const obj = {
    get value() {
      track(obj, TrackOpTypes.GET, 'value');
      if (dirty) {
        value = effetcFn();
        dirty = false;
      }
      return value;
    },
    set value(newValue) {
      setter(newValue);
    },
  };
  return obj;
}

手写 watch

Q: watch 和 watchEffect 的实现有什么不同?watch 是怎么做到获取旧值的?

A: watch 的核心区别在于它可以指定侦听的数据源获取新旧值对比、以及控制回调的执行时机

回顾 watch 的用法

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

// 侦听单个响应式对象
watch(x, (newX) => {
  console.log(`x is ${newX}`);
});

// 侦听 getter 函数
watch(
  () => x.a + x.b,
  (sum) => {
    console.log(`sum is: ${sum}`);
  }
);

watch 接收的参数:

  • 第一个参数(source):响应式数据或 getter 函数
  • 第二个参数(cb):回调函数,接收 (newValue, oldValue)
  • 第三个参数(options):配置项
    • immediate:立即执行一次回调
    • once:只执行一次
    • flush:控制回调时机(post / sync

watch 返回一个函数,调用它可以停止侦听:

const unwatch = watch(() => {});
unwatch(); // 停止侦听

第一步:参数归一化 + traverse 深度追踪

首先需要一个 traverse 工具方法,用于递归遍历对象的所有嵌套属性,从而触发深层依赖收集:

function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value)) {
    return value;
  }

  seen.add(value); // 防止循环引用

  for (const key in value) {
    traverse(value[key], seen); // 递归访问每个属性
  }

  return value;
}

假设有一个深层嵌套的对象:

const obj = {
  a: 1,
  b: {
    c: 2,
    d: { e: 3 },
  },
};

traverse 的遍历过程如下:

  • 访问 obj → 加入 seen 集合
  • 遍历属性:
    • 访问 obj.a(数字,直接返回)
    • 访问 obj.b(对象)→ 递归进入
      • 访问 obj.b.c(数字,直接返回)
      • 访问 obj.b.d(对象)→ 递归进入
        • 访问 obj.b.d.e(数字,直接返回)

在这个过程中,每次访问一个属性都会触发其 getter 拦截,从而将当前活动的 effect 函数收集为该属性的依赖。这样就能确保:无论多深层的属性变化,watch 都能感知到。

接下来进行参数归一化:

export function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  // ...
}

无论 source 是什么类型:

  • 如果是函数:直接作为 getter 使用,函数内部的响应式数据会被收集
  • 如果是响应式对象:包装为 () => traverse(source),深度访问所有属性来收集依赖

第二步:使用 effect + scheduler 实现核心逻辑

let oldValue, newValue;

const job = () => {
  newValue = effectFn();   // 重新执行 getter,获取新值
  cb(newValue, oldValue);  // 调用用户回调,传入新旧值
  oldValue = newValue;     // 更新旧值,为下次变化做准备
};

const effectFn = effect(() => getter(), {
  lazy: true,
  scheduler: job,
});

核心流程分析:

  1. getter 内部的响应式数据和 effectFn 建立依赖关系
  2. 响应式数据变化 → 进入 scheduler(即 job 函数)
  3. job 函数:
    • 重新执行 getter() 获取新值
    • 调用用户回调 cb(newValue, oldValue)
    • oldValue 更新为当前值

这里巧妙地利用 scheduler 拦截了默认的函数重新执行,改为由我们自己控制何时获取新值以及如何处理新旧对比。

第三步:实现 immediate

if (options.immediate) {
  job();              // 立即执行一次回调
} else {
  oldValue = effectFn(); // 首次执行 getter,结果作为 oldValue
}
  • immediate:立即调用 job(),此时 newValue 是首次计算结果,oldValueundefined
  • immediate:执行 effectFn() 获取初始值,赋给 oldValue,等待数据变化

第四步:实现取消侦听

return () => {
  cleanup(effectFn);
};

返回一个函数,调用时执行 cleanup,将 effectFn 从所有依赖集合中移除。此后数据变化就不会再触发回调了。

第五步:flush 配置——控制回调的执行时机

flush 的本质是指定调度函数的执行时机。当 flushpost 时,需要将回调延迟到 DOM 更新之后再执行:

const effectFn = effect(() => getter(), {
  lazy: true,
  scheduler: () => {
    if (options.flush === 'post') {
      Promise.resolve().then(job); // 放入微任务队列
    } else {
      job();                       // 同步执行
    }
  },
});

使用 Promise.resolve().then(job) 将 job 放入微任务队列,确保它在当前同步代码和 DOM 更新周期完成之后才执行。这样回调函数中就能安全地访问到更新后的 DOM。

完整实现

import { effect, cleanup } from './effect/effect.js';

export function watch(source, cb, options = {}) {
  // 1. 参数归一化
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source);
  }

  // 2. 保存新旧值
  let oldValue, newValue;

  // 3. 要执行的更新函数
  const job = () => {
    newValue = effectFn();
    cb(newValue, oldValue);
    oldValue = newValue;
  };

  // 4. 使用 effect 建立依赖,scheduler 控制执行
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      if (options.flush === 'post') {
        Promise.resolve().then(job);
      } else {
        job();
      }
    },
  });

  // 5. immediate 逻辑
  if (options.immediate) {
    job();
  } else {
    oldValue = effectFn();
  }

  // 6. 返回取消侦听函数
  return () => {
    cleanup(effectFn);
  };
}

function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value)) {
    return value;
  }
  seen.add(value);
  for (const key in value) {
    traverse(value[key], seen);
  }
  return value;
}

computed vs watch:设计思路对比

将 computed 和 watch 放在一起对比,能更清晰地看到它们的设计差异:

维度 computed watch
用途 派生一个计算值,依赖变化时自动更新 观察数据变化,执行副作用
返回值 一个带 .value 的引用对象(Ref) 一个取消侦听的函数
缓存 有,通过 dirty 标记实现 无缓存,每次都执行回调
执行时机 访问时计算(lazy) 依赖变化时立即执行(或按 flush 配置)
与 effect 的关系 effect(getter, { lazy, scheduler }) effect(getter, { lazy, scheduler })
scheduler 的作用 标记脏 + 触发外部依赖 获取新值 + 调用回调

有意思的是,两者的底层签名一模一样:effect(getter, { lazy: true, scheduler })。区别仅在于 scheduler 中做了什么

  • computed 的 scheduler:dirty = true; trigger(obj, SET, 'value')
  • watch 的 scheduler:newValue = effectFn(); cb(newValue, oldValue); oldValue = newValue

这就是响应式基础设施的强大之处——核心机制统一,上层用法多样。

总结

通过上下两篇文章,我们从零手写了 Vue3 响应式系统的四个核心部分:

  1. Proxy 拦截层 —— 区分读取与写入,连接 track 与 trigger
  2. Effect 依赖管理 —— activeEffect、环境函数、调用栈、cleanup
  3. Computed 计算属性 —— 参数归一化、缓存/dirty 标记、scheduler 串联外部依赖
  4. Watch 侦听器 —— traverse 深度追踪、新旧值对比、flush 调度控制、取消侦听

你会发现,computed 和 watch 本质上都是 effect + scheduler 的不同封装。这种「统一核心、差异化上层」的设计,正是 Vue3 响应式系统优雅之处的集中体现。掌握了这些原理,你就真正拥有了「看穿」Vue3 响应式魔法本质的能力。