文章

手写简易 Vue3 响应式系统深度解析

手写 Vue3 响应式的 reactive、effect、computed、ref、watch 五个核心模块加起来不到一百行,却是理解框架的终极钥匙。 面试手写不考记忆力,考的是否理解 WeakMap 三层数据结构的设计意图与 cleanup 清理依赖的必要性。

手写简易 Vue3 响应式系统深度解析

一句话概括

手写 Vue 3 响应式的五个核心模块——reactive、effect、computed、ref、watch——加起来不到 100 行,却是理解整个框架的终极钥匙。面试手写不考你的记忆力,考的是你是否理解三层数据结构的设计意图和 cleanup 的必要性。

核心知识点

1. reactive——代理 + track + trigger(20 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const targetMap = new WeakMap();
let activeEffect = null;

function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      track(target, key);
      return (res && typeof res === 'object') ? reactive(res) : res;  // 懒递归
    },
    set(target, key, value, receiver) {
      const old = target[key];
      const ok = Reflect.set(target, key, value, receiver);
      if (old !== value) trigger(target, key);  // NaN 需额外处理
      return ok;
    },
    deleteProperty(target, key) {  // ← 面试加分:别漏了这个
      const had = Object.prototype.hasOwnProperty.call(target, key);
      const ok = Reflect.deleteProperty(target, key);
      if (had) trigger(target, key);
      return ok;
    }
  });
}

function track(target, key) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));
  let deps = depsMap.get(key);
  if (!deps) depsMap.set(key, (deps = new Set()));
  deps.add(activeEffect);
  activeEffect.deps.push(deps);  // 反向引用
}

function trigger(target, key) {
  targetMap.get(target)?.get(key)?.forEach(e => {
    if (e !== activeEffect) e.scheduler ? e.scheduler() : e();  // 防自己触发自己
  });
}

2. effect——带 cleanup 的自动追踪(15 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function effect(fn, options = {}) {
  const _effect = () => {
    cleanup(_effect);         // ① 清理旧依赖
    activeEffect = _effect;   // ② 上位
    fn();                     // ③ 执行 → get 时自动 track
    activeEffect = null;      // ④ 退位
  };
  _effect.deps = [];           // 存反向引用数组
  _effect.scheduler = options.scheduler;
  _effect();
  return _effect;
}

function cleanup(effect) {
  effect.deps.forEach(dep => dep.delete(effect));
  effect.deps.length = 0;
}

3. computed——dirty 惰性求值(10 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function computed(getter) {
  let value, dirty = true;

  const _effect = effect(getter, {
    scheduler() {
      if (!dirty) { dirty = true; trigger(_result, 'value'); }  // 只设脏 + 通知订阅者
    }
  });

  const _result = {
    get value() {
      track(_result, 'value');       // computed 自身也是响应式的
      if (dirty) { dirty = false; value = _effect(); }  // 脏了才算
      return value;
    }
  };
  return _result;
}

4. ref——原始值的 Proxy 壳(8 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function ref(value) {
  const r = {
    get value() {
      track(r, 'value');
      return value;
    },
    set value(newVal) {
      if (newVal !== value) {
        value = newVal;
        trigger(r, 'value');
      }
    }
  };
  return r;
}

为什么需要 ref? Proxy 只能代理对象,ref(0) 本质上创造了一个 { value: 0 } 的 getter/setter 包装。源码里 ref 传对象时内部调用 reactive。

5. watch——带新旧值的调度 effect(10 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function watch(source, cb) {
  let getter = typeof source === 'function' ? source : () => source.value;
  let oldValue;

  const runner = effect(getter, {
    scheduler() {
      const newValue = runner();   // 主动跑 effect 拿最新值
      cb(newValue, oldValue);      // 新旧值一起给回调
      oldValue = newValue;         // 更新 oldValue 为下一轮准备
    }
  });

  oldValue = runner();  // 首次执行收集依赖 + 初始化 oldValue
}

其实你每天都在用

  • 模板渲染: <div>{ { count } }</div> → render effect 自动收集 count → count++ → trigger → patch
  • v-model 双向绑定: 输入 → set 拦截 → trigger → render effect 重跑 → DOM 更新,全链路
  • computed 派生: const full = computed(() => first + last) → 任一依赖不变就不重算
  • watch 自动请求: watch(route.params.id, fetchData) → id 变了立即发请求
  • 组件卸载: onUnmounted 中 stop(effect) → cleanup 清空所有 dep 引用 → 闭包回收

常见误解(FAQ)

  • ❌ 误区:「手写版不用 cleanup」 不写 cleanup 的 demo 在分支切换场景下是有 bug 的——effect(() => flag ? a : b) 里 flag 从 true 切 false 后,改 a 仍会触发这个 effect。面试官等的就是你会不会指出这个。

  • ❌ 误区:「computed 依赖变了自己会重算」 不会!依赖变只设 dirty = true。真正计算发生在下次 .value 被读取时。如果 computed 没人用,依赖再变它也永远不会跑——这就是”惰性求值”。

  • ❌ 误区:「reactive 拦截 get/set 就行,deleteProperty 不用管」 delete obj.key 不触发 set。Vue 2 完全拦截不了,Vue 3 的 deleteProperty trap 解决了这个——面试时主动提到是加分项。

  • ❌ 误区:「ref 和 reactive 能互相替换」 ref 解决的是 Proxy 只能代理对象的限制。ref(0) = { value: 0 } 的 getter/setter,reactive({ count: 0 }) 才是 Proxy。对象用 reactive,原始值用 ref——不是可不可替,是能不能用。

一句话总结

100 行写出 Vue 3 响应式:Proxy 接管访问、track 记账、trigger 催账、cleanup 清旧账、scheduler 调度——computed/watch/组件渲染都是这套流水线上不同工位的产品。

本文由作者按照 CC BY 4.0 进行授权