mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1357 字
4 分钟
Vue 3 响应式系统深潜:WeakMap 依赖拓扑、ReactiveEffect 副作用调度与微任务批量更新

在前面的系列手记中,我们探讨了 Vue 3 的组件生命周期、Composition API 以及各种指令的最佳实践。

然而,Vue 最令人着迷且最具魅力的核心灵魂,始终是它的 响应式系统(Reactivity System)。

在日常业务开发中,我们只要写下一句 const count = ref(0),然后在某个按钮点击时执行 count.value++,视图就会以极高的精度自动响应并刷新。

  • 这背后究竟经历了怎样的数据捕获与依赖收集?
  • 为什么在一个同步函数里连续执行 1000 次 count.value++,页面视图却只刷新了一次?
  • 为什么必须使用 WeakMap 而不是普通 Map 来管理依赖树?

今天我们就剥开外层的所有语法糖,纯手写还原一套具备工业级鲁棒性的 Vue 3 响应式微内核。


1. 拦截基石:Proxy 为什么必须携手 Reflect?#

Vue 3 彻底放弃了 Vue 2 的 Object.defineProperty,转而拥抱现代 ES6 Proxy。除了能够天然感知属性的新增、删除与数组索引变化外,其拦截机制中有一个极易被忽视但至关重要的细节——Reflect 的反射调用。

先看一段存在致命缺陷的“朴素 Proxy”代码:

const user = {
firstName: 'Linus',
lastName: 'Torvalds',
get fullName() {
// 这里的 this 会指向谁?
return `${this.firstName} ${this.lastName}`;
}
};
const proxy = new Proxy(user, {
get(target, key) {
track(target, key); // 依赖收集
return target[key]; // ❌ 致命错误!
}
});

缺陷剖析:#

当我们在一个副作用函数中读取 proxy.fullName 时:

  1. 拦截器的 get 确实被触发了一次,收集到了 fullName 的依赖;
  2. 但在执行 fullName 内部的 getter 时,由于采用 target[key],此时函数内部的 this 直接指向了原始的 target(即原生普通对象 user),而不再是 proxy 代理对象!
  3. 这导致后续对 this.firstName 和 this.lastName 的读取完全绕过了 Proxy 的 get 拦截!
  4. 后果:firstName 发生变化时,由于从未收集到依赖,依赖 fullName 的视图将永远不会重新更新!

正确做法:必须通过 Reflect.get(target, key, receiver) 维持上下文:#

const proxy = new Proxy(user, {
get(target, key, receiver) {
track(target, key);
// receiver 保证了 getter 内部的 this 永远稳固地指向当前的 Proxy 实例
return Reflect.get(target, key, receiver);
}
});

2. 全局拓扑设计:WeakMap ➔ Map ➔ Set#

在 Vue 3 内部,维护着一张全系统的响应式依赖映射大网——targetMap。其数据结构为严谨的三层嵌套:

全局 targetMap (WeakMap)
│
├── 键: target (被代理的原始对象,弱引用,GC 自动回收)
└── 值: depsMap (Map)
│
├── 键: key (对象的具体属性名,如 'count' 或 'title')
└── 值: dep (Set<ReactiveEffect>)
│
└── 包含所有依赖此属性的副作用函数列表
// 全局依赖大网定义
const targetMap = new WeakMap();
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);
}
}

为什么顶层必须使用 WeakMap?#

  • WeakMap 的键只能是对象,且持有的引用是弱引用(Weak Reference)。
  • 当某个组件被销毁,或者某个业务数据对象在业务代码中失去了所有外部强引用时,V8 垃圾回收器(Garbage Collector)可以毫不留情地将其彻底回收,而不会因为 targetMap 的存在导致内存常驻。如果使用普通 Map,将引发灾难性的全局内存泄漏。

3. ReactiveEffect 封装与分支切换防漏#

一个纯粹的副作用函数往往涉及动态的分支判断(Branch Switching):

effect(() => {
// 当 state.ok 为 false 时,后续改变 state.text 根本不应该触发该函数重新运行!
document.body.innerText = state.ok ? state.text : 'Default Text';
});

如果在首次运行(state.ok = true)时收集了 text 的依赖,当后续 state.ok 变为 false 后,如果不对旧依赖进行清理,那么即使界面上已经显示了 'Default Text',只要外部修改 state.text,该副作用仍然会被无效地调用。

因此,每次执行副作用前,必须将自身从所有订阅的 dep 集合中清除:

let activeEffect = null;
class ReactiveEffect {
constructor(fn, scheduler = null) {
this.fn = fn;
this.scheduler = scheduler;
this.deps = []; // 记录包含了该 effect 的所有 dep 集合
this.active = true;
}
run() {
if (!this.active) return this.fn();
// 运行前清除旧依赖,防止分支切换导致无效触发
cleanupEffect(this);
try {
activeEffect = this;
return this.fn(); // 执行用户传入的函数,内部触发 getter 重新精准收集
} finally {
activeEffect = null;
}
}
}
function cleanupEffect(effect) {
for (const dep of effect.deps) {
dep.delete(effect);
}
effect.deps.length = 0;
}

4. 调度器(Scheduler)与微任务批量异步更新#

现在回到最初的问题:如果在一个同步方法中连续修改状态:

for (let i = 0; i < 1000; i++) {
state.count++;
}

如果每次 trigger 都直接调用 effect.run(),浏览器将发生 1000 次无意义的中间重渲染。

Vue 3 的破局之道是引入了 调度器(Scheduler) 与 基于 Promise 的微任务队列(Job Queue):

// 任务去重队列与微任务冲刷标志
const queue = new Set();
let isFlushing = false;
const p = Promise.resolve();
function queueJob(job) {
queue.add(job); // 利用 Set 天然去重
if (!isFlushing) {
isFlushing = true;
// 将整个任务队列的刷新推迟到微任务阶段 (Microtask)
p.then(flushJobs);
}
}
function flushJobs() {
try {
for (const job of queue) {
job(); // 依次执行最终去重后的更新任务
}
} finally {
queue.clear();
isFlushing = false;
}
}

当创建更新副作用时,传入自定义调度器:

function effect(fn, options = {}) {
const _effect = new ReactiveEffect(fn, options.scheduler);
_effect.run(); // 首次同步执行建立依赖
return _effect;
}
// 派发更新
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (!dep) return;
// 构造副本遍历,防止边删除边添加导致死循环
const effectsToRun = new Set(dep);
effectsToRun.forEach(effect => {
// 避免在同一个 effect 执行期间触发自身死递归
if (effect !== activeEffect) {
if (effect.scheduler) {
// 如果注册了调度器,优先走调度逻辑!
effect.scheduler();
} else {
effect.run();
}
}
});
}

当 state.count++ 循环 1000 次时:

  1. 每次递增触发 trigger,都会调用 scheduler;
  2. scheduler 执行 queueJob(_effect.run);
  3. queue 是一个 Set,重复放进去 1000 次,里面始终只有唯一一个任务;
  4. 同步代码执行完毕后,主线程进入微任务队列执行 flushJobs,该任务仅执行 1 次!

这就是为什么 Vue 无论你在单次事件循环中同步修改多少次状态,视图都只会在下一个微任务时钟周期精准合并渲染一次的底层秘诀! 这也正是 nextTick(() => { ... }) 能准确捕获到 DOM 渲染更新完毕状态的根源所在。


5. 总结#

Vue 3 的响应式体系之所以兼具极佳的开发体验与工业级的执行性能,是因为它在底层实现上做到了数项精妙权衡:

  1. Proxy + Reflect:从根本上保证了对象任何深层属性与 getter 访问期间上下文的绝对保真;
  2. WeakMap 依赖树:在构建高保真拓扑关联的同时,完全交出生命周期主控权,杜绝内存泄漏;
  3. ReactiveEffect 与 Cleanup:消除分支切换造成的无效常驻依赖;
  4. Scheduler 调度管线:借助异步微任务去重队列,实现了零额外心智负担的自动化批量更新。

理解了这套微内核的运作图谱,你在面对复杂的响应式状态流、开发高性能自定义渲染器(Custom Renderer)或排查神秘的死循环渲染 Bug 时,就能游刃有余、洞若观火。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Vue 3 响应式系统深潜:WeakMap 依赖拓扑、ReactiveEffect 副作用调度与微任务批量更新
https://blog.luozili.work/posts/vue3-reactive-effect-batch-scheduler/
作者
llbzow
发布于
2026-10-20
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录

💬
🎀