手写 Vue reactive 和 watchEffect 的最小实现
用 Proxy、WeakMap 和 effect 栈实现最小可用版 reactive 与 watchEffect,并说明它与真实 Vue 的差距。
#tech / dev / frame
#resource / vue3
#type / howto
#status / growing
[!info] related notes
- 所属 MOC: Vue MOC
- 上位主题: Vue 响应式系统
- 相关概念: Vue中的ref和reactive, Vue中的watch和watchEffect, Map, WeakMap, Set, WeakSet, Map vs WeakMap, Set vs WeakSet, ECMAScript 中的 Proxy 和 Reflect
- 相关题单: 拼多多前端春招准备
手写 Vue reactive 和 watchEffect 的最小实现
一句话定义
这道手写题的核心不是把 Vue 全部抄出来,而是证明你理解三件事:依赖收集、触发更新、以及副作用执行上下文。
1. 面试官真正想看什么
通常不是要你写出完整源码,而是看你能不能说清:
Proxy怎么拦截get/set- 访问属性时怎么收集依赖
- 修改属性时怎么找到相关副作用重新执行
watchEffect为什么能“自动知道自己依赖了什么”
2. 最小实现需要哪些数据结构
最经典的一组是:
WeakMap:按目标对象分桶Map:按属性名分组Set:保存依赖这个属性的副作用函数
也就是:
WeakMap<target, Map<key, Set<effectFn>>>
除此之外还需要:
activeEffect:当前正在收集依赖的副作用effectStack:处理 effect 嵌套cleanup:每次重新执行前先清旧依赖,避免脏订阅
3. 最小可用代码
const bucket = new WeakMap();
const reactiveMap = new WeakMap();
let activeEffect = null;
const effectStack = [];
function cleanup(effectFn) {
for (const dep of effectFn.deps) {
dep.delete(effectFn);
}
effectFn.deps.length = 0;
}
function track(target, key) {
if (!activeEffect) return;
let depsMap = bucket.get(target);
if (!depsMap) {
depsMap = new Map();
bucket.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
if (!dep.has(activeEffect)) {
dep.add(activeEffect);
activeEffect.deps.push(dep);
}
}
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
if (!effects) return;
const effectsToRun = new Set();
effects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
effectsToRun.forEach((effectFn) => effectFn());
}
function watchEffect(fn) {
const effectFn = () => {
if (!effectFn.active) return;
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
try {
fn();
} finally {
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1] ?? null;
}
};
effectFn.deps = [];
effectFn.active = true;
effectFn();
return () => {
effectFn.active = false;
cleanup(effectFn);
};
}
function reactive(target) {
if (target === null || typeof target !== 'object') {
return target;
}
const existingProxy = reactiveMap.get(target);
if (existingProxy) {
return existingProxy;
}
const proxy = new Proxy(target, {
get(target, key, receiver) {
track(target, key);
const value = Reflect.get(target, key, receiver);
if (value !== null && typeof value === 'object') {
return reactive(value);
}
return value;
},
set(target, key, newValue, receiver) {
const oldValue = Reflect.get(target, key, receiver);
const result = Reflect.set(target, key, newValue, receiver);
if (!Object.is(oldValue, newValue)) {
trigger(target, key);
}
return result;
},
});
reactiveMap.set(target, proxy);
return proxy;
}
4. 这段代码怎么运行起来
const state = reactive({
count: 0,
user: { name: 'Tom' },
});
const stop = watchEffect(() => {
console.log(state.count, state.user.name);
});
state.count += 1;
state.user.name = 'Jerry';
stop();
执行流程是:
watchEffect先立刻执行一次回调- 回调里读取
state.count和state.user.name get被拦截,track收集依赖- 以后这些属性变化,
trigger找到相关 effect 重新执行
5. 为什么 watchEffect 能自动收集依赖
因为它在执行回调前,把当前副作用函数挂到 activeEffect 上。
于是回调里每次访问响应式属性时,track 都知道:
- “现在是谁在读我”
- “那我就把它记下来”
这就是“自动依赖收集”的本质。
6. 为什么要 cleanup
如果没有清理旧依赖,会出现两个问题:
- 分支切换后,effect 还保留着已经不再访问的旧依赖
- 同一个 effect 多次执行后,订阅集合越来越脏
所以每次重新执行 effect 前,都要先把它从旧依赖集合里删掉,再重新收集一遍。
7. 为什么要用 effectStack
因为 effect 可能嵌套。
如果只靠一个全局 activeEffect,内层 effect 执行完后,外层上下文就丢了。
栈的作用是:
- 进入 effect 时压栈
- 退出 effect 时出栈
- 恢复上一个激活的 effect
8. 这和真实 Vue 还差什么
这只是“面试最小实现”,离真实 Vue 还有一段距离。
真实工程里通常还要考虑:
refcomputed- 调度器
scheduler - 批量更新与去重
- 数组方法、
Map、Set deleteProperty、has、ownKeys- 避免无限递归触发
- effect 的暂停、恢复、作用域回收
所以面试里如果你写到这里,最好主动补一句:
这版只是最小可用模型,真实 Vue 还会做更多边界处理和调度优化。
9. 面试时最稳的表达顺序
可以按这个顺序讲:
reactive用Proxy拦截对象访问get时做依赖收集,set时触发依赖- 依赖桶结构是
WeakMap -> Map -> Set watchEffect执行回调时把当前 effect 暴露给track- 每次重跑前先
cleanup
10. 常见追问
watch 和 watchEffect 差在哪
watchEffect:自动依赖收集watch:显式指定监听源
为什么不用 Object.defineProperty
Proxy 更适合拦截整对象访问,也更容易处理新增属性、删除属性和数组场景。
为什么用 WeakMap
因为 key 是对象,而且希望目标对象被回收后依赖桶也能随之释放,减少内存泄漏风险。
11. 最短记忆方式
reactive:拦截get/setwatchEffect:执行时暴露当前 effecttrack:谁读了我,我记住谁trigger:我变了,把依赖我的都重新跑一遍