Vue 3 响应式内核剖析:Proxy 依赖收集与副作用调度的工程实现
2026/7/22 6:45:49 网站建设 项目流程

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 ────────────────────────────────────────┐ │ │ │ │ │ Set<ReactiveEffect> │ │ │ │ │ │ { 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/Set
ref读取 .value写入 .value单一 Set
computed首次求值 + 依赖变更标记依赖变更 + 被读取时缓存值 + dirty

track 与 trigger 的闭环里,有一个关键细节叫 cleanup。effect 执行前,先把它从所有旧依赖 Set 中移除。执行过程中重新收集依赖。这解决了条件分支切换的问题。假设 effect 内部有条件分支:show 为真读 a,为假读 b。show 为真时依赖 show 和 a。切到假时本应依赖 show 和 b。没有 cleanup,a 的依赖会残留,导致无谓触发。

调度器是另一层关键。默认 effect 同步执行。但 Vue 的组件更新走异步队列。组件 effect 带 scheduler,把更新推入微任务队列。一次 tick 内多次 trigger,只执行一次真实更新。这就是批量更新的底层支撑。

三、生产级响应式内核:嵌套、竞态与调度的工程实现

下面这套实现,覆盖了嵌套 effect、依赖 cleanup、调度器与异常隔离。代码刻意保留工程细节,而非精简到玩具程度。

// 全局 effect 栈,解决嵌套 effect 的归属问题 let activeEffect = null; const effectStack = []; // 三层依赖存储:WeakMap<target, Map<key, Set<effect>>> 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 的实现,会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询