前端知库

怎样写一个 mini 响应式系统(reactive、effect、computed)?

用 Proxy 的 get 拦截读取、记录当前正在运行的 effect,用 set 拦截写入、取出依赖它的 effect 重新执行;依赖存成“对象 → 属性 → effect 集合”的结构。完整一点的版本还要处理嵌套对象懒代理、同一对象只代理一次、每次执行前清理旧依赖、effect 嵌套、避免自己触发自己,以及用 lazy 和 scheduler 组合出带缓存的 computed。

参考答案

简答

目标是写出这样的效果:effect(() => console.log(state.count)) 先打印一次,之后只要执行 state.count = 2,它就自动再打印一次。实现分三块:reactive 用 Proxy 包住对象,effect 负责运行函数并登记“当前是谁在运行”,track 和 trigger 负责记账和通知。

读属性时进入 Proxy 的 get,调用 track 把当前 effect 记到这个属性名下;写属性时进入 set,调用 trigger 取出这个属性名下的 effect 逐个重新执行。依赖存成“对象 → 属性名 → effect 集合”三层结构,最外层用 WeakMap,对象没人用了可以被回收。

写到能用还要补几处:读到的值是对象时再包一层代理,实现嵌套响应;同一个对象只代理一次;effect 每次执行前清掉旧依赖,条件分支切换后才不会被多余的属性触发;用栈记录嵌套的 effect;effect 里改自己读的属性时跳过自己,防止死循环。computed 就是一个 lazy 的 effect 加上调度器:依赖变了只标记为脏,读 .value 时才重算。

要说明这是教学模型。Vue 3 的真实实现还处理了数组方法、Map/Set、ref、只读和浅层代理、调度队列,当前源码(3.5 重构后)里依赖记录用双向链表和版本号,结构比这复杂得多。

先把完整代码写出来

下面是一个教学用的完整版本,不是 Vue 源码。后面的四组用法分别验证基本触发、嵌套对象、分支切换和 computed:

JavaScript
const targetMap = new WeakMap(); // 原始对象 -> 属性名 -> 依赖它的 effect 集合
const proxyMap = new WeakMap(); // 原始对象 -> 代理,同一个对象只代理一次
let activeEffect = null;
const effectStack = [];

function track(target, key) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));
  let dep = depsMap.get(key);
  if (!dep) depsMap.set(key, (dep = new Set()));
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect);
    activeEffect.deps.push(dep); // 反向记录,方便下次执行前清理
  }
}

function trigger(target, key) {
  const dep = targetMap.get(target)?.get(key);
  if (!dep) return;
  // 先复制一份再遍历,effect 执行时会重新收集依赖,直接遍历原集合可能死循环
  [...dep].forEach((effectFn) => {
    if (effectFn === activeEffect) return; // 避免 effect 里自增时触发自己
    if (effectFn.scheduler) effectFn.scheduler();
    else effectFn();
  });
}

function reactive(target) {
  if (proxyMap.has(target)) return proxyMap.get(target);
  const proxy = new Proxy(target, {
    get(obj, key, receiver) {
      track(obj, key);
      const value = Reflect.get(obj, key, receiver);
      // 读到对象时才继续代理,叫作懒代理
      return value !== null && typeof value === 'object' ? reactive(value) : value;
    },
    set(obj, key, value, receiver) {
      const oldValue = obj[key];
      const result = Reflect.set(obj, key, value, receiver);
      if (!Object.is(oldValue, value)) trigger(obj, key);
      return result;
    },
  });
  proxyMap.set(target, proxy);
  return proxy;
}

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

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    effectStack.push(effectFn);
    activeEffect = effectFn;
    try {
      return fn();
    } finally {
      effectStack.pop();
      activeEffect = effectStack[effectStack.length - 1] ?? null;
    }
  };
  effectFn.deps = [];
  effectFn.scheduler = options.scheduler;
  if (!options.lazy) effectFn();
  return effectFn;
}

function computed(getter) {
  let value;
  let dirty = true;
  const obj = {};
  const runner = effect(getter, {
    lazy: true,
    scheduler() {
      if (dirty) return;
      dirty = true;
      trigger(obj, 'value'); // 通知读过这个 computed 的 effect
    },
  });
  Object.defineProperty(obj, 'value', {
    get() {
      if (dirty) {
        value = runner();
        dirty = false;
      }
      track(obj, 'value');
      return value;
    },
  });
  return obj;
}

// 1. 基本的依赖收集与触发
const state = reactive({ count: 1, user: { name: 'Lin' }, show: true });
const log = [];
effect(() => log.push(`count=${state.count}`));
state.count = 2;
console.log(log.join(',')); // 输出 count=1,count=2

// 2. 嵌套对象也是响应式的
effect(() => log.push(`name=${state.user.name}`));
state.user.name = 'Chen';
console.log(log.slice(-1)[0]); // 输出 name=Chen

// 3. 分支切换后,不再读的属性不会触发
let branchRuns = 0;
effect(() => {
  branchRuns += 1;
  return state.show ? state.user.name : '隐藏';
});
state.show = false;
state.user.name = 'Wang';
console.log(branchRuns); // 输出 2

// 4. computed 懒计算、有缓存,并能被 effect 依赖
let getterRuns = 0;
const double = computed(() => {
  getterRuns += 1;
  return state.count * 2;
});
console.log(getterRuns); // 输出 0
console.log(double.value, double.value, getterRuns); // 输出 4 4 1
const seen = [];
effect(() => seen.push(double.value));
state.count = 5;
console.log(seen.join(','), getterRuns); // 输出 4,10 2

effect 怎么知道函数读了哪些属性

effect 里的函数没有声明任何依赖,track 却能把它记到 count 名下,靠的是 activeEffect。effectFn 执行 fn 之前,先把自己设为 activeEffect。fn 里读 state.count 时进入 Proxy 的 get,track 看到当前有 effect 在运行,就把它放进 targetMap 里 state 原始对象的 count 集合。之后 state.count = 2 进入 set,trigger 取出这个集合,重新执行里面的 effect,于是 log 里多了 count=2。

set 里先比较新旧值,Object.is 判断相同就不触发。这样重复赋同一个值不会白白执行 effect,NaN 赋给 NaN 也会被认为没变。

嵌套对象和重复代理

第二组用法改的是 state.user.name,为什么也能触发?reactive 一开始只代理了最外层。effect 读 state.user 时,get 发现拿到的是对象,就当场调用 reactive(value) 再包一层返回,后面读 .name 走的是这层代理的 get,所以 name 也被追踪了。只有真正读到的嵌套对象才会被代理,这叫懒代理,没用到的深层数据不会提前遍历。

每次读 state.user 都调用 reactive,会不会每次生成一个新代理?proxyMap 记住了原始对象和代理的对应关系,同一个对象第二次进来直接返回已有的代理。否则 state.user === state.user 都会是 false。

为什么每次执行前要清理依赖

第三组用法里,effect 第一次执行时 show 是 true,读了 show 和 user.name。把 show 改成 false 后,effect 重跑一次,branchRuns 变成 2,这次只读了 show。接着改 user.name,branchRuns 仍然是 2,没有再触发。

做到这一点靠的是 cleanup。track 登记依赖时,顺便把集合本身存进 effectFn.deps,这样 effect 知道自己在哪些集合里。每次执行前,先从这些集合里把自己删掉,再在执行过程中重新收集。分支切到 false 后不再读 name,name 的集合里也就没有它了。

清理之后又要重新加入,trigger 如果直接遍历原来的 Set,就会出现一边删一边加、遍历永远结束不了的情况,所以先用 [...dep] 复制一份再遍历。

effect 嵌套和自己触发自己

组件渲染时,父组件的渲染 effect 里会执行子组件的渲染 effect,这就是 effect 嵌套。如果只有一个 activeEffect 变量,内层执行完把它设成 null,外层后面再读属性就记不上了。所以用 effectStack 保存,内层结束后恢复成栈顶的外层 effect。

另一个问题是 effect(() => { state.count++ })。++ 先读后写,读的时候把自己登记进去,写的时候 trigger 又要执行自己,无限递归。trigger 里跳过 activeEffect 就能避免。

computed 是怎样拼出来的

computed 没有新机制,只是给 effect 加了两个选项。lazy: true 让 effect 创建时不执行,所以第四组用法里 getterRuns 一开始是 0。scheduler 改变了依赖变化时的行为:trigger 看到 effect 有调度器,就调用调度器而不是直接执行 effect,调度器只把 dirty 设成 true。

读 .value 时,dirty 为 true 才执行 runner 重新计算,所以连续读两次 getter 只跑一次。computed 本身也要能被别的 effect 依赖:value 的 getter 里调用 track(obj, 'value'),调度器里调用 trigger(obj, 'value'),于是 count 改成 5 时,依赖 double 的 effect 被通知,重新读 double.value,拿到 10。缓存为什么能成立、Vue 真实实现怎样做脏检查,详见《computed 和 watch 的实现原理是什么?computed 为什么有缓存?》。

和 Vue 真实实现差在哪

这个版本缺了很多东西。没有拦截 has、deleteProperty、ownKeys,所以 in、delete 和 for...in 不会被追踪;数组的 push 会同时读写 length,需要特别处理;Map、Set 要用单独的拦截方式;还没有 ref、readonly、shallowReactive。

调度上,这里的 effect 是同步重新执行的。Vue 的组件渲染 effect 被触发后会放进队列,同一轮里同一个组件只更新一次。依赖结构方面,当前 vuejs/core 源码(3.5 重构后)不再用简单的 Set,而是用双向链表把依赖和订阅者连起来,再配合版本号判断是否需要重算。

常见追问

为什么最外层用 WeakMap,里面用 Map?

最外层的键是被代理的原始对象。用 WeakMap 时,对象在别处没有引用了,这条记录不会阻止它被垃圾回收。里面一层的键是属性名,可能是字符串或 Symbol,WeakMap 的键必须是对象或未注册的 Symbol,不能用字符串,所以用 Map。

为什么在 get 里用 Reflect.get,还要传 receiver?

如果属性是 getter,getter 里的 this 要指向代理而不是原始对象,这样 getter 里读的其他属性也能被追踪。Reflect.get(obj, key, receiver) 会把 receiver,也就是代理本身,作为 getter 的 this。直接写 obj[key] 的话,this 是原始对象,就漏掉了这些依赖。

Vue 2 能用这套写法吗?

Vue 2 用的是 Object.defineProperty,要在初始化时遍历对象,给已有属性逐个定义 getter/setter,所以新增属性、用下标改数组都检测不到,需要 Vue.set。Proxy 拦截的是整个对象的操作,新增属性也能捕获。依赖收集的思路两者一样,详见《Vue 3 响应式依赖是怎样收集和触发的?》。