Vue 3 响应式内核剖析:Proxy 依赖收集与副作用调度的工程实现
Vue 3 响应式内核剖析Proxy 依赖收集与副作用调度的工程实现一、数据驱动视图的暗礁从 defineProperty 到 Proxy 的响应式重构数据驱动是 Vue 的核心招牌。响应式系统撑起了这套机制。它让开发者只关心状态变更无需手动操作 DOM。但响应式并非声明即生效的魔法。Vue 2 时代响应式基于 Object.defineProperty 实现。这套方案在生产环境留下了几个硬伤。第一新增属性不触发更新。开发者不得不调用 $set 打补丁。第二数组下标直接赋值无响应。第三深层对象初始化时全量遍历。大对象的初始化开销不可忽视。Vue 3 把内核换成了 Proxy。Proxy 能拦截整对象的读写天然支持新增属性。但这只是入口。真正的复杂度藏在依赖收集与副作用调度里。Proxy 只负责拦截谁来收集依赖、何时触发更新、如何避免竞态才是工程难点。生产中踩过的坑不少。watcher 依赖残留导致内存泄漏。computed 在循环引用下死循环。批量更新未做合并时单次 tick 触发数十次重渲染。这些问题的根都在响应式内核的调度细节。本文从源码级机制切入再给出一套生产可用的精简实现。二、effect 与 track/trigger依赖收集的三层闭环响应式的本质是一条契约。读取状态时记录谁在用我。写入状态时通知用过我的重新执行。前者叫 track后者叫 trigger。中间的存储结构决定了这套契约的效率。Vue 3 用三层结构存依赖。最外层是 WeakMap以原始对象为键。中间层是 Map以属性名为键。最内层是 Set存所有依赖该属性的 effect。┌──────────────────────────────────────────────────────────┐ │ 响应式系统三层依赖存储 │ ├──────────────────────────────────────────────────────────┤ │ WeakMap │ │ ┌─ target (raw object) ──────────────────────────────┐ │ │ │ Map │ │ │ │ ┌─ key ────────────────────────────────────────┐ │ │ │ │ │ SetReactiveEffect │ │ │ │ │ │ { effectA, effectB, effectC, ... } │ │ │ │ │ └─────────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────┘ 读取阶段 track 写入阶段 trigger ───────────── ───────────── state.x ──► 把 activeEffect state.x 2 ──► 遍历 Set 存入对应 Set 逐个执行 effect最外层用 WeakMap 而非 Map是刻意的取舍。WeakMap 的键是弱引用。原始对象被销毁时对应依赖自动被 GC 回收。避免了对象已不用依赖还挂着的内存泄漏。中间层 Map 以属性为键。这保证了只触发真正依赖该属性的 effect。没有这一层任何属性变更都会触发全量 effect。依赖收集依赖一个全局变量 activeEffect。effect 执行前把自己压入栈顶执行完弹出。读取响应式属性时track 把栈顶的 effect 存进对应 Set。这种栈式设计解决了嵌套 effect 的归属问题。维度track 时机trigger 时机存储载体reactive读取属性 (get 拦截)写入属性 (set 拦截)三层 Map/Setref读取 .value写入 .value单一 Setcomputed首次求值 依赖变更标记依赖变更 被读取时缓存值 dirtytrack 与 trigger 的闭环里有一个关键细节叫 cleanup。effect 执行前先把它从所有旧依赖 Set 中移除。执行过程中重新收集依赖。这解决了条件分支切换的问题。假设 effect 内部有条件分支show 为真读 a为假读 b。show 为真时依赖 show 和 a。切到假时本应依赖 show 和 b。没有 cleanupa 的依赖会残留导致无谓触发。调度器是另一层关键。默认 effect 同步执行。但 Vue 的组件更新走异步队列。组件 effect 带 scheduler把更新推入微任务队列。一次 tick 内多次 trigger只执行一次真实更新。这就是批量更新的底层支撑。三、生产级响应式内核嵌套、竞态与调度的工程实现下面这套实现覆盖了嵌套 effect、依赖 cleanup、调度器与异常隔离。代码刻意保留工程细节而非精简到玩具程度。// 全局 effect 栈解决嵌套 effect 的归属问题 let activeEffect null; const effectStack []; // 三层依赖存储WeakMaptarget, Mapkey, Seteffect const targetMap new WeakMap(); // 入口把函数包装为可追踪的 ReactiveEffect function effect(fn, options {}) { const _effect function () { if (activeEffect _effect) { // 防止 effect 内部直接递归调用自身导致栈溢出 return; } try { cleanup(_effect); activeEffect _effect; effectStack.push(_effect); return fn(); } catch (err) { // 异常不上抛给 trigger避免单个 effect 崩溃阻塞后续更新 // 生产环境应接入上报通道 console.error([reactive] effect 执行异常:, err); } finally { effectStack.pop(); activeEffect effectStack[effectStack.length - 1] || null; } }; _effect.deps []; // 记录该 effect 被哪些 Set 收集cleanup 时反查 _effect.scheduler options.scheduler; // 自定义调度默认同步 // 惰性选项lazy 时不立即执行由调用方手动触发 if (!options.lazy) { _effect(); } return _effect; } // 依赖清理从所有引用该 effect 的 Set 中移除 // 必须做否则条件分支切换后旧依赖残留造成幽灵触发 function cleanup(effect) { const { deps } effect; for (let i 0; i deps.length; i) { deps[i].delete(effect); } deps.length 0; } // 读取拦截把当前 effect 收入对应 Set function track(target, key) { if (!activeEffect) return; // 非响应式上下文下读取不收集 let depsMap targetMap.get(target); if (!depsMap) { depsMap new Map(); targetMap.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); // 反向记录供 cleanup 使用 } } // 写入拦截触发所有依赖该属性的 effect function trigger(target, key) { const depsMap targetMap.get(target); if (!depsMap) return; // 没有依赖直接返回避免空触发 const dep depsMap.get(key); if (!dep) return; // 复制一份再遍历。cleanup 会边遍历边删除原 Set直接遍历会跳过元素 const effects [...dep]; for (const _effect of effects) { if (_effect activeEffect) { // 防止 effect 内部直接写入自身依赖的属性引发无限递归 continue; } if (_effect.scheduler) { _effect.scheduler(); // 走异步调度 } else { _effect(); // 同步执行 } } } // 响应式对象工厂Proxy 拦截 get/set深层数据惰性代理 function reactive(target) { const proxy new Proxy(target, { get(obj, key, receiver) { if (key __isReactive) return true; // 类型标记供 unref/解包判断 const result Reflect.get(obj, key, receiver); track(obj, key); // 惰性代理访问到才代理避免初始化全量遍历 if (result typeof result object) { return reactive(result); } return result; }, set(obj, key, value, receiver) { const oldValue obj[key]; const hadKey Array.isArray(obj) ? Number(key) obj.length : Object.prototype.hasOwnProperty.call(obj, key); const result Reflect.set(obj, key, value, receiver); // 仅在真正变化时触发避免无意义更新 if (!hadKey || oldValue ! value) { trigger(obj, key); if (Array.isArray(obj) key length) { // length 变更会牵连所有超界索引需补触发 trigger(obj, length); } } return result; }, }); return proxy; }几个工程细节值得展开。第一trigger 中遍历前复制 Set。cleanup 会在遍历过程中删除原 Set 元素直接遍历会跳项。这是 Vue 源码中真实存在的坑。第二effect 内自写入自身依赖属性时跳过。否则 effect 执行期间 trigger 自身又重新执行无限递归。第三惰性代理。深层对象只在被访问时递归代理。初始化不遍历整个对象树大型状态的首屏开销显著降低。第四数组 length 变更补触发。超界索引的旧依赖需要被通知否则视图残留旧值。四、响应式内核的代价内存开销与依赖爆炸的边界这套机制并不免费。它有几条明确的代价与禁用边界。第一effect 闭包持有的大对象不会被 GC。WeakMap 只保证原始对象可回收。但 effect 函数闭包内的引用是强引用。如果 effect 内读取了大型 reactive 对象的子树该子树在 effect 存活期间无法回收。生产中遇到过仪表盘组件卸载后状态树仍驻留内存。根因是 effect 没有随组件卸载而停止。Vue 用 onScopeDispose 解决手写内核需对应实现 stop。第二大型数组的索引操作触发全量更新。state.list[1000] x 会 trigger 该索引。但 push、splice 等方法会修改 length连带触发所有依赖 length 的 effect。万级数组频繁 push 时每次都全量重渲染。这种场景应改用 shallowRef 持有数组手动触发更新。第三cleanup 频繁切换的性能损耗。effect 每次执行前清空所有依赖再重新收集。依赖项越多cleanup 成本越高。高频切换的条件分支会让 cleanup 反复搬运大 Set。实测在依赖项超过 500 时cleanup 占用可观测。第四深层 reactive 的代理穿透开销。每次访问嵌套属性都经过一层 Proxy 拦截。十层嵌套的访问路径触发十次 get 拦截。深度遍历密集的算法场景原生对象比 reactive 对象快数倍。禁用场景如下。纯展示、不参与响应的静态数据用 markRaw 标记跳过代理。高频写入的计数器、传感器数据流用 shallowRef 持有避免深层追踪。超大列表项用 shallowReactive 或直接操作原始数组。这些场景强行套深层 reactive性能会显著劣化。场景推荐方案理由表单/中等规模状态reactive自动深层追踪开发体验最佳高频写入计数器shallowRef 手动触发跳过深层代理避免无谓 trigger万级数组列表shallowReactive避免 length 全量触发不参与响应的第三方实例markRaw跳过代理零开销计算密集的派生值computed 缓存dirty 标记避免重复求值五、总结Vue 3 响应式内核由三部分构成。Proxy 负责拦截读写。track 与 trigger 借助三层 WeakMap-Map-Set 结构建立属性与副作用的映射。effect 栈与 cleanup 共同维护正确的依赖归属。落地时把握三个关键。第一依赖存储用 WeakMap让对象销毁时依赖自动回收但 effect 闭包内的强引用需用 stop 主动清理。第二trigger 遍历前复制 Set避免 cleanup 边删边遍历的跳项。第三组件更新走异步 scheduler一次 tick 内多次写入只触发一次真实更新。性能边界要前置评估。深层 reactive 适合中等规模、低频写入的状态。高频写入、超大数组、深度遍历密集场景应降级为 shallowRef 或 shallowReactive。不参与响应的第三方实例用 markRaw 标记。工程实践上把这套精简内核对照 Vue 源码阅读。重点关注 reactive.ts、effect.ts、baseHandlers.ts 三个文件。理解 cleanup、嵌套栈、scheduler 的协作后再回头看组件渲染、computed、watch 的实现会顺畅很多。