手写 Vue reactive 和 watchEffect 的最小实现

用 Proxy、WeakMap 和 effect 栈实现最小可用版 reactive 与 watchEffect,并说明它与真实 Vue 的差距。

#tech / dev / frame #resource / vue3 #type / howto #status / growing

[!info] related notes

手写 Vue reactivewatchEffect 的最小实现

一句话定义

这道手写题的核心不是把 Vue 全部抄出来,而是证明你理解三件事:依赖收集、触发更新、以及副作用执行上下文。

1. 面试官真正想看什么

通常不是要你写出完整源码,而是看你能不能说清:

  1. Proxy 怎么拦截 get / set
  2. 访问属性时怎么收集依赖
  3. 修改属性时怎么找到相关副作用重新执行
  4. watchEffect 为什么能“自动知道自己依赖了什么”

2. 最小实现需要哪些数据结构

最经典的一组是:

  • WeakMap:按目标对象分桶
  • Map:按属性名分组
  • Set:保存依赖这个属性的副作用函数

也就是:

WeakMap<target, Map<key, Set<effectFn>>>

除此之外还需要:

  • activeEffect:当前正在收集依赖的副作用
  • effectStack:处理 effect 嵌套
  • cleanup:每次重新执行前先清旧依赖,避免脏订阅

3. 最小可用代码

const bucket = new WeakMap();
const reactiveMap = new WeakMap();

let activeEffect = null;
const effectStack = [];

function cleanup(effectFn) {
  for (const dep of effectFn.deps) {
    dep.delete(effectFn);
  }
  effectFn.deps.length = 0;
}

function track(target, key) {
  if (!activeEffect) return;

  let depsMap = bucket.get(target);
  if (!depsMap) {
    depsMap = new Map();
    bucket.set(target, depsMap);
  }

  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }

  if (!dep.has(activeEffect)) {
    dep.add(activeEffect);
    activeEffect.deps.push(dep);
  }
}

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;

  const effects = depsMap.get(key);
  if (!effects) return;

  const effectsToRun = new Set();

  effects.forEach((effectFn) => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn);
    }
  });

  effectsToRun.forEach((effectFn) => effectFn());
}

function watchEffect(fn) {
  const effectFn = () => {
    if (!effectFn.active) return;

    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);

    try {
      fn();
    } finally {
      effectStack.pop();
      activeEffect = effectStack[effectStack.length - 1] ?? null;
    }
  };

  effectFn.deps = [];
  effectFn.active = true;
  effectFn();

  return () => {
    effectFn.active = false;
    cleanup(effectFn);
  };
}

function reactive(target) {
  if (target === null || typeof target !== 'object') {
    return target;
  }

  const existingProxy = reactiveMap.get(target);
  if (existingProxy) {
    return existingProxy;
  }

  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      track(target, key);
      const value = Reflect.get(target, key, receiver);

      if (value !== null && typeof value === 'object') {
        return reactive(value);
      }

      return value;
    },

    set(target, key, newValue, receiver) {
      const oldValue = Reflect.get(target, key, receiver);
      const result = Reflect.set(target, key, newValue, receiver);

      if (!Object.is(oldValue, newValue)) {
        trigger(target, key);
      }

      return result;
    },
  });

  reactiveMap.set(target, proxy);
  return proxy;
}

4. 这段代码怎么运行起来

const state = reactive({
  count: 0,
  user: { name: 'Tom' },
});

const stop = watchEffect(() => {
  console.log(state.count, state.user.name);
});

state.count += 1;
state.user.name = 'Jerry';

stop();

执行流程是:

  1. watchEffect 先立刻执行一次回调
  2. 回调里读取 state.countstate.user.name
  3. get 被拦截,track 收集依赖
  4. 以后这些属性变化,trigger 找到相关 effect 重新执行

5. 为什么 watchEffect 能自动收集依赖

因为它在执行回调前,把当前副作用函数挂到 activeEffect 上。

于是回调里每次访问响应式属性时,track 都知道:

  • “现在是谁在读我”
  • “那我就把它记下来”

这就是“自动依赖收集”的本质。

6. 为什么要 cleanup

如果没有清理旧依赖,会出现两个问题:

  1. 分支切换后,effect 还保留着已经不再访问的旧依赖
  2. 同一个 effect 多次执行后,订阅集合越来越脏

所以每次重新执行 effect 前,都要先把它从旧依赖集合里删掉,再重新收集一遍。

7. 为什么要用 effectStack

因为 effect 可能嵌套。

如果只靠一个全局 activeEffect,内层 effect 执行完后,外层上下文就丢了。

栈的作用是:

  • 进入 effect 时压栈
  • 退出 effect 时出栈
  • 恢复上一个激活的 effect

8. 这和真实 Vue 还差什么

这只是“面试最小实现”,离真实 Vue 还有一段距离。

真实工程里通常还要考虑:

  • ref
  • computed
  • 调度器 scheduler
  • 批量更新与去重
  • 数组方法、MapSet
  • deletePropertyhasownKeys
  • 避免无限递归触发
  • effect 的暂停、恢复、作用域回收

所以面试里如果你写到这里,最好主动补一句:

这版只是最小可用模型,真实 Vue 还会做更多边界处理和调度优化。

9. 面试时最稳的表达顺序

可以按这个顺序讲:

  1. reactiveProxy 拦截对象访问
  2. get 时做依赖收集,set 时触发依赖
  3. 依赖桶结构是 WeakMap -> Map -> Set
  4. watchEffect 执行回调时把当前 effect 暴露给 track
  5. 每次重跑前先 cleanup

10. 常见追问

watchwatchEffect 差在哪

  • watchEffect:自动依赖收集
  • watch:显式指定监听源

为什么不用 Object.defineProperty

Proxy 更适合拦截整对象访问,也更容易处理新增属性、删除属性和数组场景。

为什么用 WeakMap

因为 key 是对象,而且希望目标对象被回收后依赖桶也能随之释放,减少内存泄漏风险。

11. 最短记忆方式

  • reactive:拦截 get / set
  • watchEffect:执行时暴露当前 effect
  • track:谁读了我,我记住谁
  • trigger:我变了,把依赖我的都重新跑一遍
创建于 2026/3/29 更新于 2026/5/27