
看到“effect 与 track 依赖收集”这个话题很多从 Vue 2 时代过来的前端第一反应是这不就是底层源码吗跟我写业务组件有什么关系但等你真正被一个“数据变了视图不更新”的 bug 折腾上半天或者同样一个列表渲染写两个组件、一个卡成 PPT 另一个行云流水时就会意识到不懂响应式的底层机制你写不出真正稳定的逻辑也排不掉那些间歇性出现的问题。这篇东西我计划用最直接的篇幅把 effect、track 这套依赖收集体系讲透配合手写代码和排坑记录尽量让拿到手的人能直接用、能看懂原理而不是看完只记了一堆源码文件名。1. 依赖收集到底在解决什么问题1.1 响应式系统的诉求数据变了视图自己更新我们从最常见的场景说起页面上有个搜索框输入关键词下方列表自动过滤。用命令式的写法你必须在每个可能修改关键词的地方都手动调用一遍 filter 函数去更新 DOM一旦逻辑分支多了漏掉某一个调用路径bug 就来了“数据变了视图不更新”几乎成了业务运维的万能标题。响应式系统要解决的第一个问题就是把这个“手动触发更新”换成“自动触发更新”。你要做的只是声明数据读取数据系统自己记录哪些代码在读取它等到数据被修改系统自动把依赖它的代码重新执行一遍。这套机制在 Vue 3 里有一个非常明确的称谓副作用函数effect。所谓“副作用”就是指一个函数在执行时会影响到函数外部的一种状态——比如修改了 DOM、写入了一个缓存、跳转了页面。一个组件渲染函数就是一个副作用函数一个 watch 回调也是副作用函数。这里必须强调一个关键点系统要自动更新具备两个前提第一是数据能感知自己被读写了第二是系统能知道“谁在读、谁在写”。前者靠 Proxy 拦截 get 和 set 操作实现后者就是 effect 与 track 的核心职责。不理解这个分工后面所有源码看着都像天书。1.2 别把“依赖”看成数据它其实是反向映射表很多人一听到“依赖收集”脑子里自动代入“某个数据依赖了某个东西”这个方向反了。依赖收集的意思是在副作用函数读取了响应式数据的那一刻系统把“这个副作用函数”登记到“这个响应式数据项”的名下。注意主语注册的是“谁的读取”收集的是“哪些函数在读取”。最终在内存里形成的数据结构是一张以数据项为 key、以副作用函数集合为 value 的 Map。举个朴素例子你有个 count 变量页面里有三个组件分别显示 count、count 乘以二、engine 变量。那么 count 这个 key 下面应该挂两个函数的引用显示 count 的和显示 count 乘以二的engine 没有副作用函数读它所以 engine 名下什么都不用挂。当 count 变化时系统只需要把 count 名下的两个函数拿出来重新执行engine 没人管自然不用触发任何更新。这就叫精准更新。这张映射表用代码形容就是type ReactiveEffect () void; const depsMap new Mapobject, Mapstring, SetReactiveEffect();第一层 Map 用数据对象本身做 key第二层 Map 用属性名做 key最里层的 Set 存放所有读取过该属性的副作用函数。这个三层结构不是框架设计者拍脑袋定的它是为了性能做出的极致选择定位任何一个属性的依赖集合只需要三次哈希查找平均时间复杂度 O(1)。你项目里可能有上千个响应式属性但每一次读取数据的依赖收集成本就是三次查 Map。这是经过严谨性能考虑的不是简单的数据结构堆砌。2. effect副作用函数是怎么被登记注册的2.1 effect 的执行流程其实就是“先跑一遍再捡回依赖”在 Vue 3 中用得比较多的是watchEffect你要真去看源码它底层调用的就是 effect 函数。effect 本身做了三件事创建一个 ReactiveEffect 实例、设置一些选项比如 lazy、scheduler、执行一次该副作用函数。关键就在“执行一次”这一步。为什么 effect 第一次就要把函数体完整执行一遍因为不去执行一遍系统就不知道这个函数到底读了哪些数据。读操作发生在函数运行过程中get 拦截器被触发track 才有机会把当前正在运行的副作用函数注册到对应数据项的依赖列表里。这段执行过程可以理解为你写了一段代码这段代码会在运行中“触碰”到一堆响应式数据只要你读了它后续你就会被它“绑定”住。用写代码的方式演示一个最简 effect 实现type EffectFn () void; let activeEffect: EffectFn | null null; function effect(fn: EffectFn) { activeEffect fn; fn(); // 执行后fn 内部读取的数据就会通过 track 收集到 activeEffect activeEffect null; }这样写有个隐藏问题如果 effect 嵌套执行比如外层 effect A 执行到一半内部调用了 effect BB 执行完又把 activeEffect 置成了 null那 A 的注册就断了。Vue 的原始实现用一个 effectStack 来维护现场执行嵌套效应时会先把当前 activeEffect 压栈内层执行完再弹栈恢复。这个细节在面试里是高频考点但更关键的是它提示了依赖收集必须是栈式管理才能保证任意深度的读取都能找到正确的归主函数。2.2 依赖收集的入口在 get而不是 set这几个函数名称如果你是细心的人其实已经能分清角色effect 负责跑函数、track 负责登记依赖、trigger 负责通知更新。但有一个更底层的核心原则值得展开说依赖收集的入口是“读取”也就是在 get 拦截器里面调 track。Proxy 的 get 拦截器会在各种意想不到的地方触发举几个具体的例子函数调用时this关键字类型检查时访问Symbol.toStringTag打印日志时访问属性这些“读取”如果没有过滤会把一些根本不相关的副作用函数混入依赖集合从而造成无意义的更新调用。所以 Vue 的 track 内部有一个关键的 filter只会对 targetMap 中真实存在的目标对象执行依赖收集只会收集 activeEffect 真实存在时的依赖同时对于只读的浅层对象直接跳过。写一个带过滤条件的 get 拦截器长这样new Proxy(obj, { get(target, key, receiver) { // 只关心读操作且当前确有一个副作用函数在运行 if (activeEffect) { track(target, key); } return Reflect.get(target, key, receiver); }, set(target, key, newValue, receiver) { const result Reflect.set(target, key, newValue, receiver); if (hasChanged) { trigger(target, key); // 只有值真的变了才触发 } return result; }, });还有一层过滤牵扯到性能如果你的副作用函数在无限循环里反复读取同一个数据依赖收集 Set 能保证同一个函数永远只注册一次。这意味着不管一个函数体里面有多少处读取同一个属性最终在依赖 Set 里都只有一份引用。这是 Set 数据结构的天然优势也是依赖收集性能优化的一个重要基石。3. track 的实现离不开这三个数据结构3.1 WeakMap、Map、Set 三者分工背后尽是细节写过头文件或者观察过 Vue 源码的人应该都记得这句注释type Dep SetReactiveEffect; // 一个副作用函数集合 type keyMap Mapstring, Dep; // 属性名到依赖集合的映射 type targetMap WeakMapobject, keyMap; // 对象到属性映射表的弱引用映射三层的存在各有目的WeakMap研究过它的人都知道WeakMap 的键是弱引用的也就是说只要原对象在业务代码里没有其他引用它就能被垃圾回收。这是为了防止响应式系统出现内存泄漏。Map同一个对象上的多个属性各自有各自的依赖集合用属性名做 key 来切分。Set用于保证同一函数不重复记录并且在触发时能以迭代顺序执行。一个完整的 track 函数核心逻辑大约是function track(target: object, key: string) { if (!activeEffect) return; let keyMap targetMap.get(target); if (!keyMap) { keyMap new Map(); targetMap.set(target, keyMap); } let deps keyMap.get(key); if (!deps) { deps new Set(); keyMap.set(key, deps); } deps.add(activeEffect); }有没有发现 track 里面没有一个确定的清理旧依赖的步骤这里面的“删”做得非常巧妙Vue 在每次副作用函数重新执行前会把它从旧的依赖集合里先删除掉然后重新执行函数、重新建立新依赖。也就是说每次更新依赖关系都会经历一次重建。这带来了一个容易被老手视为“代价”的特性如果一个副作用函数第一次执行读取了 A 和 B第二次执行时由于某个条件判断不再读取 B那 B 的名下就不再挂这个函数了。这是动态依赖收集的体现也是为什么 remove 操作如此重要。3.2 track 的时机数据还未改变前依赖就已经建立这里有一个很重要的概念边界track 本身收集的是“读取时正在运行的副作用函数”发生时机在 get 拦截器里面。它是纯读操作不会造成数据变化。很多人对 Vue 的响应式有一个联想以为依赖是在数据改变后才建立的这是错误的。真实的顺序是渲染 → 读取数据 → 注册依赖 → 后续数据变化 → 触发更新。// 一个典型副作用组件 effect(() { app.textContent state.count; }); state.count; // 这里改为2第一行的 effect 执行时state.count 才刚被读取track 就把整个副作用函数放到了 state.count 的依赖集合里了。state.count 这行触发的不是 track它触发的是 trigger也就是把集合里已经存在的函数拿出来跑一遍。这种设计最直观的受益点在于你永远不会提前执行更新函数只有在数据确实被读取过、依赖确实存在时才会发生触达。如果设计成 set 时收集依赖就必须在每次修改时扫描所有副作用函数判断它们是否读取了这个属性效率低得可怕。所以 track 的定位是“提前建立联系”trigger 的定位才是“劫后算账”。3.3 从零实现一个可实现的最小响应式库为了验证上述机制我建议动手写一个完整的、只有几十行的响应式实现。如果你只是看文档会一直有“懂了但不会写”的感觉但只要你认真把这段代码敲一遍再跑几个测试用例你对依赖收集的理解就会上一个台阶。type EffectFn () void; let activeEffect: EffectFn | undefined; const targetMap new WeakMapobject, Mapstring, SetEffectFn(); function track(target: object, key: string) { if (!activeEffect) return; let keyMap targetMap.get(target); if (!keyMap) { keyMap new Map(); targetMap.set(target, keyMap); } let deps keyMap.get(key); if (!deps) { deps new Set(); keyMap.set(key, deps); } deps.add(activeEffect); } function trigger(target: object, key: string) { const keyMap targetMap.get(target); if (!keyMap) return; const deps keyMap.get(key); if (!deps) return; deps.forEach((fn) fn()); } function effect(fn: EffectFn) { activeEffect fn; fn(); activeEffect undefined; } function reactiveT extends object(obj: T): T { return new Proxy(obj, { get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, newValue, receiver) { const result Reflect.set(target, key, newValue, receiver); trigger(target, key); return result; }, }); }这段实现刻意省略了很多边界条件对象新增属性、删除属性、批量更新调度、清理失效依赖但它把依赖收集的三要素——读取时 track、修改时 trigger、执行函数时登记 activeEffect——完整呈现了出来。我建议你跑一个测试让同一个副作用函数读取两个不同的属性然后修改其中一个属性看看另一个属性的修改是否触发更新再试试三个不同副作用函数同时读取同一个属性修改该属性时它们是否都执行了。4. trigger 与调度更新不是无脑调用函数4.1 依赖触发的机制要真正做到“按需求量触发”上一节写的 trigger 干的事情太简单把依赖集合里的函数全跑一遍。真实框架里 trigger 要考虑很多细节最典型的是避免无意义的更新。核心规则是只有当被修改的属性值确实发生变化用 Object.is 比较时才触发依赖更新。比如state.count 5原本就是 5那么这次的 set 是一个“假修改”触发更新没有必要。但这其实只是第一步真正的性能点在 Vue 3 的组件级更新里组件副作用函数执行时读取了很多响应式数据但整个组件是否真的需要重新渲染取决于响应式数据的变化是否影响到了实际渲染结果。这里可以采用一个很实际的例子父组件传了一个属性给子组件但子组件的模板里并没有用这个 prop。那父组件数据变化时子组件的渲染函数还是会被触发吗答案是子组件的渲染 effect 在初始化时只读取了它真正使用到的属性所以如果父组件修改的属性恰好不在子组件的依赖集合里子组件的 effect 根本不会被调用这是依赖收集带来的精准更新保障也印证了 track 的“按需”特性。4.2 批处理与调度器为什么更新会合并成一轮有人可能会问一个 effect 里同时修改了 10 个依赖过的响应式属性那 trigger 是不是要被调用 10 次effect 也要重复跑 10 次Vue 的设计给予的回答是会触发 10 次依赖触发但副作用函数不一定跑 10 次。在响应式系统内部有一个调度器scheduler机制当 effect 被触发时系统不会立即执行函数体而是把这次更新任务交给 scheduler 决定。Vue 默认的 scheduler 会把任务放到一个微任务队列里并且使用 Set 去重。这样即使同一轮同步代码中修改了 10 次同一个属性甚至修改了 10 个不同属性最终对应副作用函数只会执行一次。// 调度器逻辑的伪代码示意 const queue new SetEffectFn(); let isFlushing false; function queueJob(fn: EffectFn) { queue.add(fn); if (!isFlushing) { isFlushing true; Promise.resolve().then(() { isFlushing false; queue.forEach((item) item()); queue.clear(); }); } }这就是为什么你在页面中连续执行多次state.count后DOM 内容只会更新一次的场景。依赖收集负责“收集到正确的人”trigger 负责“按正确时机喊人”scheduler 负责“减少跑腿的次数”。这三层叠加起来才能保障响应式系统在复杂业务场景下的性能表现。5. 常见问题与实战排坑从无限递归到跨平台调试5.1 maximum recursive updates exceeded无限循环的场景与判断报错消息里那句 “maximum recursive updates exceeded” 相信很多开发者在 Console 里见过它出现时意味着什么你能说清楚吗这句话翻译成人话就是某个副作用函数在一次更新中被连续触发了超过一定次数默认是100系统判断你陷入无限循环了于是主动终止防止页面卡死。出现这种问题的原因普遍是在副作用函数内部又修改了它自己依赖的那个数据effect(() { state.count state.count 1; // 读取 count又修改 count });这个 effect 的执行流程是这样的读取 count → 收集本人 → 执行 count count 1 → 修改 count → 触发本人 → 本人再次执行 → 再次读取、再次修改……无限循环。排查这类问题最有效的手段是在报错栈的上层定位到“更新触发来源”的属性然后查看该 effect 的函数体里对应属性的“读”和“写”是否出现了双向依赖。需要注意还有一种递归模式藏在更深的地方比如 A 属性依赖 B 属性B 属性又依赖 A 属性两者在两个不同的 effect 中相互读取和修改这是最容易让人困惑的。遇到这种情况推荐先用注释法把每条 effect 内的读写全部标注出来逐一对比依赖关系。5.2 在 Windows 环境下调试依赖收集工具与思路我经常被问到“能不能在 Windows 上顺手调试 Vue 的依赖收集”这里的“顺手”实际指的是能不能用可视化工具追踪某个响应式数据的依赖图。这个问题其实比想象中简单。第一你不需要安装任何特殊软件。第二比较大的框架都提供了调试 API。以 Vue 3 为例vue/reactivity延伸出的vue/devtools插件支持在浏览器或桌面客户端中直接查看组件更新的触发来源。在 Chrome DevTools 中你可以开启 Vue 的 component render 追踪每次状态变更时DevTools 会自动标记哪一个组件被更新再结合 performance 面板的调用栈就能快速定位到是哪条 track 链路触发的更新。如果需要在 Node 环境里写纯逻辑调试Windows 上的 PowerShell 与 CMD 对 Node 的兼容性都很好只是要注意 node_modules 路径中的符号链接问题。个人经验是用 VS Code 搭配vue/tsconfig预设是最低成本的调试组合。你可以在effect函数的内部打一个条件断点设定为打印当前 activeEffect 的名称和正在访问的属性 key这样每次数据读取都会在 Debug 面板中留下记录一眼就能看出来依赖收集的执行流有没有意外漏掉或重复。5.3 依赖遗漏数据变了但视图不更新的背后原因还有一种高频问题在业务里极难排查明明数据变了视图却像没反应。多数人不清楚这大概率不是响应式系统的问题而是依赖收集漏了。最常见的原因是在 effect 外部读取了数据然后把结果以普通变量形式保存在一个非响应式的地方const raw state.count; // 在 effect 外部读取没有依赖收集 effect(() { app.textContent raw; // 后续 state.count 变化这个 effect 不会知道 });这里 raw 是普通变量它的变化框架无法感知。另一个典型场景是函数参数传递在 effect 内部调用了一个外部函数该函数内部去读取了响应式属性。这个读取发生在 effect 的执行上下文里理论上会被收集但如果你在外部函数中把属性值“提取”出来再返回就会丢失响应式依赖。排查依赖遗漏的顺序建议先确认写入时改的是同一个代理对象再确认读取时所有函数都在 effect 的同步执行栈内最后检查是否有条件判断短路了读取操作。按这三个步骤排查一般十分钟内能定位。5.4 依赖残留为啥删了旧依赖函数还会再跑一次依赖残留与依赖遗漏正好是镜像问题旧依赖没有清理干净导致副作用函数被“多余的触发”调用。Vue 的解决方案是每次执行 effect 前都会对依赖集合进行一次“清除”function cleanup() { deps.forEach((dep) dep.delete(activeEffect)); }原理不复杂副作用函数会在每个属性名下都记录一次自己的引用当数据更新导致它重新执行时这些旧的引用都应该作废——因为新的依赖关系要重新建立。如果你发现在条件分支中“不再读取某个属性后该属性变化仍然触发更新”很大概率问题出在你手动添加依赖的第三方代码里或者引用了响应式系统的非公开 API。这里有一个很重要的自我提醒尽量少用一些“隐藏依赖”的黑魔法。我看到不少人在 watch 回调中手动 targetMap 操作结果导致依赖清理失效最后排查起来异常痛苦。如果你确实需要动态依赖优先用watch([getter1, getter2], ...)这种显式的声明式写法不要在 effect 内部自由发挥。6. 补充进阶依赖收集在现代框架中的演化与扩展6.1 从数组依赖到 Map 的演化数据量越大越能看出优势早期 Vue 2 的响应式系统实现里依赖收集依赖的是对象属性 defineProperty并对数组做了大量特殊处理例如给数组的 7 个方法做重写。Vue 3 切换到 Proxy 后依赖收集在数组和动态 key 上的支持才得以完整。这里值得说明的是现代依赖结构不只包含单一的属性到依赖集合的映射还要处理 computed 和 watch 的重叠逻辑。Computed 本身就是依赖收集的一个典型扩展computed 的 getter 会创建一个 computed 副作用 effect它读取的响应式属性会被计入 computed 的依赖集合中当属性变化时computed effect 会被触发并标记为“脏值”下次读取 computed 的 getter 时重新计算。而组件读取 computed 时又会把组件的渲染 effect 挂在 computed 的依赖集合上。这一层嵌套依赖的实现复杂度比核心 track 高不少但它遵循的依然是同一套“读取即收集、修改即触发”的逻辑。建议做源码阅读时先把 track/trigger 弄熟再去看 computed你会觉得豁然开朗。6.2 按需 tree-shaking 带来的收益依赖收集的工程价值很多聊过 Vue 3 性能的人只会说“Proxy 比 defineProperty 快”这个说法片面。Proxy 相比 defineProperty 在每次属性访问上的性能其实没有绝对优势真正的优势在于两点一是动态新增属性的响应式支持不再需要特殊的 Vue.set二是依赖收集的数据结构可以做到精确到属性级别的最小更新粒度。结合 tree-shaking框架在构建时可以移除未使用的响应式主流程代码打包体积下降明显。这个演进过程跟我们写业务代码是呼应的如果你能理解“依赖收集”这个机制本身就是一种状态计算的最小化实施方案那你就不会在项目里大批量使用深层拷贝 全量刷新视图这种暴力方案。用一句话总结依赖收集不是在“降低”系统复杂度它是在“将复杂度打包后集中管理”而你只要遵守读写双端一致这个原则就能白嫖它的精准与高效。我的体会是当你真正经历了从“看源码看不懂”到“遇到 bug 能猜出是哪一层 track 的问题”你对前端状态管理整个知识体系的把握会迈进一大步。依赖收集不是藏在框架深处遥不可及的黑科技它就是我们在日常组件、computed、watch 中反复使用的那套规律的底层证明什么读什么就绑定什么什么写什么就通知什么。把这条规律刻在脑子里很多疑难问题的答案会自然浮现出来。