怎样写一个 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:
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 响应式依赖是怎样收集和触发的?》。