响应式系统的两个核心要素

Q: 如果让你从零实现一个响应式系统,最核心的两件事是什么?

A: 要实现一个响应式系统,最为核心的有两个部分:监听数据的读写关联数据与函数。只要把这两个部分完成了,整个响应式系统也就基本成型了。这听起来很简单,但真正动手实现时,你会发现其中藏着大量的细节和边界情况。

第一步:监听数据的读写

在 JS 中,能够拦截读写的方式,要么是 Object.defineProperty,要么是 Proxy。这两个方法针对的目标都是对象,因此我们这里只考虑对对象类型进行监听。

需要注意的是,「读写」虽然只有两个字,但细分下来要监听的行为远不止我们直觉上认为的那么简单:

行为 类型 说明
获取属性 读取 obj.keyobj[key]
设置属性 写入 obj.key = value
新增属性 写入 之前不存在的属性被赋值
删除属性 写入 delete obj.key
是否存在某个属性 读取 key in objhasOwnProperty
遍历属性 读取 for...inObject.keys()

第二步:拦截后的处理

不同的行为被拦截下来后,要做的事情是不一样的。整体来讲分为两大类:

  • 收集器(track):针对读取行为,触发收集器去收集依赖。所谓收集依赖,其实就是建立数据和函数之间的依赖关系。
  • 触发器(trigger):针对写入行为,触发器会工作,触发数据所关联的所有函数,让这些函数重新执行。

具体的映射关系如下:

行为 触发机制
获取属性(get) 收集器(track)
设置属性(set) 触发器(trigger)
新增属性(add) 触发器(trigger)
删除属性(delete) 触发器(trigger)
是否存在属性(has) 收集器(track)
遍历属性(iterate) 收集器(track)

总结为一个简单的口诀:只要涉及到属性的访问(读),就触发收集器;只要涉及到属性的设置(新增、删除都算设置),就触发触发器。

中场插播:图解 Effect —— 理解「函数与数据的关联」

Q: effect 函数是做什么的?它怎么把函数和数据关联起来?

A: effect 方法的作用非常简单——将函数和数据关联起来。回忆一下 watchEffect 的用法:

import { ref, watchEffect } from 'vue';
const state = ref({ a: 1 });
const k = state.value;
const n = k.a;

watchEffect(() => {
  console.log('运行');
  state;
  state.value;       // 依赖 value
  state.value.a;     // 依赖 value 和 a
  n;                 // 无依赖
});

setTimeout(() => {
  state.value = { a: 3 }; // 重新运行,因为修改了 value
}, 500);

现在让我们来实现自己的 effect 函数。假设我们有如下代码:

const data = { a: 1, b: 2, c: 3 };
const state = new Proxy(data, { /* ... */ });

effect(() => {
  console.log(state.a); // 期望 a 和这个回调函数建立关联
});

最关键的问题是:在 Proxy 的 get 拦截器中,我们怎么知道当前正在运行的是哪个函数?答案是使用一个全局变量 activeEffect 来临时记录。

下面是第一版实现:

let activeEffect = null;     // 记录当前正在执行的函数
const depsMap = new Map();   // 保存依赖关系

function track(target, key) {
  // 建立依赖关系
  if (activeEffect) {
    let deps = depsMap.get(key);
    if (!deps) {
      deps = new Set();
      depsMap.set(key, deps);
    }
    deps.add(activeEffect);
  }
  console.log(depsMap);
}

function trigger(target, key) {
  // 运行依赖的函数
  const deps = depsMap.get(key);
  if (deps) {
    deps.forEach((effect) => effect());
  }
}

const data = { a: 1, b: 2, c: 3 };

const state = new Proxy(data, {
  get(target, key) {
    track(target, key);     // 依赖收集
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    trigger(target, key);   // 派发更新
    return true;
  },
});

function effect(fn) {
  activeEffect = fn;
  fn();
  activeEffect = null;
}

// 使用
effect(() => {
  console.log('执行函数');
  console.log(state.a);     // 读取 a,触发 get → track
});
state.a = 10;               // 设置 a,触发 set → trigger

第一版实现的核心数据结构是:每个属性对应一个 Set 集合,集合里面是该属性所依赖的所有函数。所有属性与其对应的依赖函数集合形成一个 Map 结构:

第一版依赖收集的数据结构 - 每个属性对应一个Set集合

activeEffect 起到一个中间变量的作用,临时存储当前正在执行的回调函数,等依赖收集完成后,再将这个临时变量置为 null:

activeEffect 作为中间变量存储当前回调函数

第一个问题:依赖关系需要动态更新

第一版有一个严重问题。考虑以下场景:

effect(() => {
  if (state.a === 1) {
    state.b;     // 当 a === 1 时,读取 b
  } else {
    state.c;     // 当 a !== 1 时,读取 c
  }
  console.log('执行了函数');
});

两次运行回调函数,所建立的依赖关系应该是不一样的:

  • 第一次(a = 1):应该依赖 a、b
  • 第二次(a = 100):应该依赖 a、c

第一次运行时的依赖关系是正常的:

Map { 'a' => Set { [Function] }, 'b' => Set { [Function] } }
第一次运行时正确的依赖关系

但当我们执行 state.a = 100 之后,依赖关系并没有按照预期更新为 a 和 c:

Map { 'a' => Set { [Function] }, 'b' => Set { [Function] } }  // 依然是 a 和 b!

为什么呢? 原因在于:第一次建立依赖时,是通过 activeEffect 这个中间变量将函数添加进依赖列表的。函数执行完毕后,activeEffect 被设置为 null。之后 a 的值改变,重新运行的是回调函数 fn,但 activeEffect 依然是 null,导致 track 中的依赖收集代码根本进不去:

function track(target, key) {
  if (activeEffect) {   // ← activeEffect 是 null,直接跳过!
    // ...
  }
}

具体的问题流程如下图所示:

activeEffect 为 null 导致依赖收集失败的流程图

解决方案: 我们收集依赖时,不再直接收集回调函数,而是收集一个包含 activeEffect 赋值的环境函数

function effect(fn) {
  const environment = () => {
    activeEffect = environment;   // ← 重新赋值!
    fn();
    activeEffect = null;
  };
  environment();
}

这里 activeEffect 指向的不再是回调函数 fn,而是一整个 environment 包含环境信息的函数。当数据变化后,重新执行的是 environment 函数,其第一行就是 activeEffect = environment,这样就能正常进入依赖收集流程了:

使用环境函数包装后的正确执行流程

第二个问题:旧的依赖没有被清除

解决了依赖更新问题后,新的问题随之出现——旧的依赖关系没有被删除。当 a 的值从 1 变为 100 后,b 的依赖关系依然残留在 depsMap 中。这会导致未来的派发更新可能产生多余的函数调用。

解决方案: 在执行 fn 之前,先调用 cleanup 方法清除所有旧的依赖。同时,在 track 中不仅要收集依赖,还要将依赖记录到 environment.deps 数组中,方便后续清理:

function cleanup(environment) {
  let deps = environment.deps;
  if (deps.length) {
    deps.forEach((dep) => {
      dep.delete(environment);
      if (dep.size === 0) {
        for (let [key, value] of depsMap) {
          if (value === dep) {
            depsMap.delete(key);
          }
        }
      }
    });
    deps.length = 0;
  }
}

清理前后的数据结构对比:

cleanup 方法清理旧依赖关系的数据结构图

第三个问题:无限循环与嵌套 Effect

当多个依赖函数存在,或者 effect 嵌套时,会遇到新的问题。

无限循环问题: 假设有两个 effect 都依赖了 state.a,当 state.a 被修改时:

  1. 初始执行时,两个 effect 建立了依赖
  2. state.a = 2 时,trigger 遍历 deps 集合执行所有函数
  3. 第二个 effect 执行时又访问了 state.a,track 会将 activeEffect 重新 add 到同一个集合中
  4. 由于是在遍历过程中修改集合,导致无限循环

解决方案: 在 trigger 中复制一份集合再遍历:

function trigger(target, key) {
  const deps = depsMap.get(key);
  if (deps) {
    const effectsToRun = new Set(deps); // 复制一份,防止遍历时修改原集合
    effectsToRun.forEach((effect) => effect());
  }
}

嵌套 Effect 问题: 当 effect 嵌套时:

effect(() => {
  effect(() => {
    state.a;                  // 内部 effect
    console.log('执行了函数2');
  });
  state.b;                    // 外部 effect
  console.log('执行了函数1');
});

执行结果是:

Map { 'a' => Set { [Function: environment] } }
执行了函数2
Map { 'a' => Set { [Function: environment] } }  // b 的依赖丢失了!
执行了函数1

问题原因:当前的函数栈管理有问题。当内部 effect 执行完毕后,activeEffect 被设置为 null,外部 effect 恢复执行时,activeEffect 依然是 null,导致 state.b 的依赖收集失败:

嵌套 effect 导致函数栈问题的示意图

解决方案: 模拟函数调用栈,使用数组来管理 effect 的嵌套关系。

完整的 Effect 实现

经过上述逐步优化,最终完整的 effect 实现如下:

完整的依赖收集数据结构设计图
export let activeEffect = undefined;
export const targetMap = new WeakMap();  // 存储对象和其属性的依赖关系
const effectStack = [];                  // effect 函数栈

export function effect(fn) {
  const environment = () => {
    try {
      activeEffect = environment;
      effectStack.push(environment);
      cleanup(environment);
      return fn();
    } finally {
      effectStack.pop();
      activeEffect = effectStack[effectStack.length - 1];
    }
  };
  environment.deps = [];
  environment();
}

export function cleanup(environment) {
  let deps = environment.deps;
  if (deps.length) {
    deps.forEach((dep) => {
      dep.delete(environment);
    });
    deps.length = 0;
  }
}

这里的设计要点:

设计点 说明
targetMap: WeakMap 以对象为 key,可以自动垃圾回收未被引用的对象
effectStack: [] 模拟函数调用栈,解决嵌套 effect 的 activeEffect 管理
try...finally 确保无论 fn 执行成功还是失败,都能正确恢复 effect 栈
environment.deps 记录当前 effect 依赖了哪些 dep 集合,方便 cleanup

改造 track 和 trigger

有了完整的 effect 之后,track 需要按照设计的数据结构一层一层建立依赖关系:

// 建立设置行为和读取行为之间的映射关系
const triggerTypeMap = {
  [TriggerOpTypes.SET]: [TrackOpTypes.GET],
  [TriggerOpTypes.ADD]: [
    TrackOpTypes.GET,
    TrackOpTypes.ITERATE,
    TrackOpTypes.HAS,
  ],
  [TriggerOpTypes.DELETE]: [
    TrackOpTypes.GET,
    TrackOpTypes.ITERATE,
    TrackOpTypes.HAS,
  ],
};

之前 track 仅仅只是简单的 Set 操作,现在需要按照 targetMap(WeakMap) → depsMap(Map) → dep(Set) 这样三层结构来进行具体的依赖收集。

trigger 要做的也很清晰:从三层数据结构中一层一层找到对应的依赖函数集合,然后全部执行一次。

这个映射关系的设计非常精妙:我们收集依赖时,是根据具体获取信息的行为(GET、HAS、ITERATE)来建立映射的。因此在派发更新时,需要思考当前设置操作会涉及哪些获取行为,然后找到对应的依赖函数。例如:

  • SET 操作只会影响 GET 行为相关的依赖
  • ADD/DELETE 操作会影响 GET、ITERATE、HAS 三种行为相关的依赖(因为新增或删除会影响遍历结果和成员检查)

高级特性:懒执行与回调

在完整的实现中,还支持两个重要特性:

懒执行: 通过 lazy 配置项,可以让 effect 的回调函数不在创建时立即执行:

export function effect(fn, options = {}) {
  const environment = () => { /* ... */ };
  environment.deps = [];
  if (!options.lazy) {
    environment();  // 默认立即执行
  }
  return environment; // 返回环境函数,让调用方决定何时执行
}

自定义回调(scheduler): 允许用户在派发更新时传入自定义回调,由用户来决定如何处理:

function trigger(target, key) {
  const deps = depsMap.get(key);
  if (deps) {
    const effectsToRun = new Set(deps);
    effectsToRun.forEach((effect) => {
      if (effect.options.scheduler) {
        effect.options.scheduler(effect); // 用户自定义处理
      } else {
        effect(); // 默认直接执行
      }
    });
  }
}

这个 scheduler 机制是后续实现 computed 和 watch 的基础,非常重要。

总结

从零手写 Vue3 响应式系统的上半部分,我们完成了:

  1. Proxy 拦截层:区分读取和写入行为,分别触发 track 和 trigger
  2. Effect 函数:使用 activeEffect + 环境函数包装,解决依赖动态更新问题
  3. Cleanup 机制:在每次执行前清理旧依赖,保证依赖关系始终是最新的
  4. 函数调用栈:使用 effectStack 处理嵌套 effect,正确处理多层作用域
  5. 三层数据结构WeakMap → Map → Set,以对象和操作为 key 精准存储依赖
  6. 无限循环防护:在 trigger 中复制集合再遍历
  7. 懒执行与 Scheduler:为 computed 和 watch 打下基础

掌握这些概念之后,你会发现 computed 和 watch 的实现不过是这些基础设施的自然延伸。我们将在下一篇文章中继续深入。