响应式系统的两个核心要素
Q: 如果让你从零实现一个响应式系统,最核心的两件事是什么?
A: 要实现一个响应式系统,最为核心的有两个部分:监听数据的读写和关联数据与函数。只要把这两个部分完成了,整个响应式系统也就基本成型了。这听起来很简单,但真正动手实现时,你会发现其中藏着大量的细节和边界情况。
第一步:监听数据的读写
在 JS 中,能够拦截读写的方式,要么是 Object.defineProperty,要么是 Proxy。这两个方法针对的目标都是对象,因此我们这里只考虑对对象类型进行监听。
需要注意的是,「读写」虽然只有两个字,但细分下来要监听的行为远不止我们直觉上认为的那么简单:
| 行为 | 类型 | 说明 |
|---|---|---|
| 获取属性 | 读取 | obj.key 或 obj[key] |
| 设置属性 | 写入 | obj.key = value |
| 新增属性 | 写入 | 之前不存在的属性被赋值 |
| 删除属性 | 写入 | delete obj.key |
| 是否存在某个属性 | 读取 | key in obj 或 hasOwnProperty |
| 遍历属性 | 读取 | for...in、Object.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 结构:
activeEffect 起到一个中间变量的作用,临时存储当前正在执行的回调函数,等依赖收集完成后,再将这个临时变量置为 null:
第一个问题:依赖关系需要动态更新
第一版有一个严重问题。考虑以下场景:
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 赋值的环境函数:
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;
}
}
清理前后的数据结构对比:
第三个问题:无限循环与嵌套 Effect
当多个依赖函数存在,或者 effect 嵌套时,会遇到新的问题。
无限循环问题: 假设有两个 effect 都依赖了 state.a,当 state.a 被修改时:
- 初始执行时,两个 effect 建立了依赖
state.a = 2时,trigger 遍历 deps 集合执行所有函数- 第二个 effect 执行时又访问了
state.a,track 会将 activeEffect 重新 add 到同一个集合中 - 由于是在遍历过程中修改集合,导致无限循环
解决方案: 在 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 实现如下:
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 响应式系统的上半部分,我们完成了:
- Proxy 拦截层:区分读取和写入行为,分别触发 track 和 trigger
- Effect 函数:使用
activeEffect+ 环境函数包装,解决依赖动态更新问题 - Cleanup 机制:在每次执行前清理旧依赖,保证依赖关系始终是最新的
- 函数调用栈:使用
effectStack处理嵌套 effect,正确处理多层作用域 - 三层数据结构:
WeakMap → Map → Set,以对象和操作为 key 精准存储依赖 - 无限循环防护:在 trigger 中复制集合再遍历
- 懒执行与 Scheduler:为 computed 和 watch 打下基础
掌握这些概念之后,你会发现 computed 和 watch 的实现不过是这些基础设施的自然延伸。我们将在下一篇文章中继续深入。