在前面的系列手记中,我们探讨了 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 时:
- 拦截器的
get确实被触发了一次,收集到了fullName的依赖; - 但在执行
fullName内部的 getter 时,由于采用target[key],此时函数内部的this直接指向了原始的target(即原生普通对象user),而不再是proxy代理对象! - 这导致后续对
this.firstName和this.lastName的读取完全绕过了 Proxy 的get拦截! - 后果:
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 次时:
- 每次递增触发
trigger,都会调用scheduler; scheduler执行queueJob(_effect.run);queue是一个Set,重复放进去 1000 次,里面始终只有唯一一个任务;- 同步代码执行完毕后,主线程进入微任务队列执行
flushJobs,该任务仅执行 1 次!
这就是为什么 Vue 无论你在单次事件循环中同步修改多少次状态,视图都只会在下一个微任务时钟周期精准合并渲染一次的底层秘诀! 这也正是 nextTick(() => { ... }) 能准确捕获到 DOM 渲染更新完毕状态的根源所在。
5. 总结
Vue 3 的响应式体系之所以兼具极佳的开发体验与工业级的执行性能,是因为它在底层实现上做到了数项精妙权衡:
- Proxy + Reflect:从根本上保证了对象任何深层属性与 getter 访问期间上下文的绝对保真;
- WeakMap 依赖树:在构建高保真拓扑关联的同时,完全交出生命周期主控权,杜绝内存泄漏;
- ReactiveEffect 与 Cleanup:消除分支切换造成的无效常驻依赖;
- Scheduler 调度管线:借助异步微任务去重队列,实现了零额外心智负担的自动化批量更新。
理解了这套微内核的运作图谱,你在面对复杂的响应式状态流、开发高性能自定义渲染器(Custom Renderer)或排查神秘的死循环渲染 Bug 时,就能游刃有余、洞若观火。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时





