Vue 3 响应式核心:从零理解 effect 与 track 的依赖收集机制

发布时间:2026/10/5 3:47:38
Vue 3 响应式核心:从零理解 effect 与 track 的依赖收集机制 1. 先搞清楚 effect 和 track 到底在折腾什么1.1 从一个改个数据页面自动变的场景说起如果你写过 Vue 3或者读过响应式相关的源码解析对下面这样的代码一定不陌生import { reactive, effect } from vue const state reactive({ count: 0 }) effect(() { console.log(state.count) })虽然在真实业务代码里直接调用effect的机会不多——大多数时候是模板渲染、computed、watch在背后默默使用它——但这三行代码基本就是 Vue 3 响应式系统的最小缩影。第 1 行创建响应式对象第 2 行往响应式系统里注册一个副作用函数第 3 行让这个函数在state.count变化时自动重新执行。你不需要在数据变化后手动调用它它自己就像上了发条一样跟着数据走。这背后真正干活的就是effect和track。很多人一看到track就头疼觉得这是源码才会碰的东西。其实把它想成一个自动重跑机制就行effect接收一个函数负责追踪这个函数执行时依赖了哪些响应式数据并在数据变化时重新执行这个函数。而track是这一切的入口——当一个响应式属性被读取时track负责把当前正在执行的effect记到该属性的订阅名单里。两者一个定义谁在跑一个记录跑的人需要什么。所以这篇就顺着一条线聊清楚track是怎么把依赖收集起来的effect在其中扮演什么角色以及为什么搞懂了这套机制之后网上随手一搜一大把的Maximum recursive updates exceeded报错你一眼就能看出病根。1.2 依赖收集的本质给数据找关注者依赖收集这四个字听起来很玄乎本质其实就一句话为每个响应式属性维护一个谁在用我的名单。你可以把它类比成一个内容平台的关注机制。数据是博主effect是读者。读者关注博主之后博主一发新内容数据变化平台就把内容推送给所有关注者触发更新。track就是关注这个动作当读者读取某个属性时平台在这个博主的粉丝列表里记上一笔。trigger则是发文的动作内容变了挨个通知粉丝列表里的人。为什么需要这么麻烦因为前端框架要解决的核心问题是数据变了视图要跟着变。但视图有千千万万个节点总不能每次数据一变就把整个页面重新渲染一遍吧那性能就崩了。所以必须知道当前这个数据到底影响了哪些地方。依赖收集就是为了精确回答这个问题。粒度越细更新的范围越小性能就越好。Vue 3 把粒度做到了属性级一个响应式对象里的不同属性各自维护独立的订阅名单。比如state里有count和msg两个属性A 组件只读了countB 组件只读了msg那么count变化时只更新 A和 B 一点关系都没有。这份精确就是track一点点攒出来的。1.3 三个角色一句话总结effect、track、trigger先建立一个全局视角后面所有代码和原理都围绕这三个角色转effect副作用函数定义一个要自动重跑的任务。它执行时去读取响应式属性从而与数据建立联系。组件渲染函数、computed 的 getter、watch 的监听源本质上都是被包装过的 effect。track依赖收集在读取响应式属性的那一刻把当前正在执行的 effect登记到该属性对应的依赖集合里。它是订阅动作本身。trigger派发更新在修改响应式属性的那一刻取出该属性的依赖集合把里面的 effect 逐个重新执行。它是发布动作。一句话概括全链路effect 负责产生订阅者track 负责完成订阅登记trigger 负责推送消息。三者缺一不可而track是整个链路的起点——没有收集触发的时候根本不知道该通知谁。2. 依赖收集的内存结构三层映射关系2.1 从 targetMap 到 dep 的完整链路Vue 3 的依赖收集不是拿一个普通数组随便记一记而是用了一套三层嵌套的数据结构targetMap (WeakMap) └── state (target) ──→ depsMap (Map) ├── count ──→ dep (Set) │ ├── effect1 │ └── effect2 └── msg ──→ dep (Set) └── effect1从外到内拆开看targetMap一个WeakMap键是响应式对象的原始对象target值是depsMap。它负责回答这个对象身上有哪些属性被依赖了。depsMap一个Map键是属性名key值是dep。它负责回答这个对象的这个属性被哪些 effect 依赖了。dep一个Set里面装的是ReactiveEffect实例。它负责回答当前生效的 effect 是谁。有点像一个图书馆的索引系统targetMap是图书馆总目录书→书架depsMap是书架上的标签书架→书dep是同一本书的所有副本登记书→借阅人。2.2 为什么偏偏是 WeakMap Map Set很多人会问为什么不直接用一个数组或者一个普通对象这里面有三个非常实际的设计考量。第一最外层用 WeakMap 是为了防内存泄漏。targetMap是全局唯一的对象它会一直活着。如果它的键是普通对象引用那么即使业务代码里已经不再使用某个响应式对象了它在targetMap里的依赖信息依然被强引用着垃圾回收无法释放它久而久之就是内存泄漏。WeakMap的键是弱引用当 target 对象本身不再被外部引用时WeakMap里对应的整条记录也会跟着被回收。这是使用 WeakMap 的最核心原因。第二中间层用 Map 是为了按属性名快速定位。一个对象可能有几十个属性每次触发更新时都要根据属性名找到对应的依赖集合。Map 的键可以是字符串、数字甚至 Symbol查询效率是 O(1)而且天然支持 symbol 类型的属性名普通对象做不到。第三最内层用 Set 是为了天然去重。track可能在一次 effect 执行过程中被同一个属性触发多次比如effect(() { console.log(state.count) console.log(state.count 1) })同一个 effect 被收集了两次。如果 dep 是数组trigger时effect会被执行两遍既浪费又可能引发诡异 bug。换成Set重复add同一个 effect 只会保留一份错误直接被数据结构抹平了。2.3 手动重现一次 track 的完整流程我们用伪代码走一遍track的执行路径。假设当前正在执行一个 effect它读取了state.count进入track(state, count)。从targetMap里查state对应的depsMap查不到就新建一个Map并写入。从depsMap里查count对应的dep查不到就新建一个Set并写入。把当前的activeEffect加入dep同时在activeEffect.deps里反向记录这个dep。第 4 步最后这个反向记录容易被忽略但它特别重要——它是后面分支切换清理和销毁 effect的基础。没有反向记录你在清理依赖时根本不知道这个 effect 被哪些 dep 收藏了。3. 从零手写一个 mini 响应式系统3.1 目标与整体设计只看不练容易飘我建议你亲手把核心链路写一遍。我们不用 Vue 源码而是自己实现一个最小可用的响应式系统功能要求就两条reactive(obj)能把普通对象变成响应式对象。effect(fn)能让fn在依赖的响应式数据变化后自动重跑。为了控制篇幅我做了两个简化只处理get和set不处理delete、has、ownKeys等其他代理陷阱也不考虑ref、computed、watch等上层封装。但核心的track/trigger/effect逻辑会完全保留看懂这一份Vue 源码里的packages/reactivity/src/effect.ts就能扔掉注释读了。3.2 全局状态与 Effect 类先定义全局的activeEffect和targetMap。activeEffect是当前正在运行的 effect的指针track收集的就是它let activeEffect null const targetMap new WeakMap()然后实现ReactiveEffect类。这个类的核心是run()方法执行前把自己的旧依赖全部清掉把自己设为activeEffect执行fn跑完再还原。为什么要清旧依赖这里先留个悬念第 4 节单独展开讲class ReactiveEffect { constructor(fn) { this.fn fn this.deps [] } run() { cleanup(this) activeEffect this try { this.fn() } finally { activeEffect null } } } function cleanup(effect) { const { deps } effect for (let i 0; i deps.length; i) { deps[i].delete(effect) } effect.deps.length 0 }3.3 实现 track 与 trigger接着是重头戏track逻辑就是第 2 节走的那四步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())) } dep.add(activeEffect) activeEffect.deps.push(dep) }注意第一行if (!activeEffect) return。这是一个很容易踩的坑如果你在普通代码里读取响应式属性没有effect包裹activeEffect就是null这时候track什么都不做。这符合预期——没人订阅的数据自然不需要为它记名单。trigger的逻辑刚好相反它是发布function trigger(target, key) { const depsMap targetMap.get(target) if (!depsMap) return const dep depsMap.get(key) if (!dep) return const effects new Set(dep) effects.forEach(effect { if (effect ! activeEffect) { effect.run() } }) }这里把dep拷贝成新的Set再遍历是个细节优化。因为在effect.run()的过程中track会往原来的dep里新增数据如果直接遍历原Set很可能在遍历过程中被修改导致异常或漏执行。拷贝一份遍历期间原集合随便改互不影响。3.4 实现 reactive 代理有了track和triggerreactive就很简单了——用 Proxy 拦截属性读取和设置function reactive(obj) { return new Proxy(obj, { get(target, key) { track(target, key) return target[key] }, set(target, key, value) { target[key] value trigger(target, key) return true }, }) }完整的effect注册函数也就几行function effect(fn) { const reactiveEffect new ReactiveEffect(fn) reactiveEffect.run() return reactiveEffect }到这里一个能跑的最小响应式系统就完成了。把上面所有代码拼在一起你就拥有了自己的反应堆。3.5 跑两个真实场景验证先验证最基础的更新链路const state reactive({ count: 0 }) effect(() { console.log(count is:, state.count) }) // 立刻输出count is: 0 state.count 1 // 输出count is: 1 state.count 2 // 输出count is: 2再验证依赖收集的精确性——只更新count不碰msgconst state reactive({ count: 0, msg: hello }) let effectFn effect(() { console.log(依赖的是 count:, state.count) }) state.msg world // 什么都不输出因为 effect 没有读取 msg你会发现effect没有因为msg变化而重跑。这说明track成功地把这个 effect绑在了count属性上而trigger(msg)找不到对应的订阅者自然什么都不做。这就是依赖收集带来的精准打击。4. 深入分支切换为什么需要 cleanup4.1 一个三元表达式引发的脏依赖前面run()里那句清掉旧依赖就是cleanup机制。它解决的是一类非常典型的问题叫分支切换。看这个例子const state reactive({ ok: true, msg: hello }) effect(() { console.log(state.ok ? state.msg : 状态关闭) })第一次执行时state.ok为trueeffect 读到了state.msg于是msg的依赖列表里记了这个 effect。接着执行state.ok falseeffect 自动重跑这次走else分支只读state.ok不再读state.msg。但是如果run()开头没有清理旧依赖msg的列表里还躺着这个 effect。于是后续执行state.msg worldeffect 会莫名其妙地重跑——尽管它当前根本不关心msg。这就叫脏依赖订阅关系已经变了但订阅名单没更新还在给已取关的读者推消息。4.2 cleanup 的执行时机与原理cleanup的本质是先清后加在 effect 每次执行前把它从所有旧 dep 里删除执行fn的过程中track会把它重新加进它实际读取的那些属性对应的 dep 里。这样一轮下来旧依赖被清干净新依赖按这次真正读了什么重新建立。effect 的订阅名单永远和它的真实读取行为保持一致。这也是为什么ReactiveEffect类里要维护一个deps数组——它反向记录了 effect 被哪些 dep 收藏过这样清理时才知道该去哪些地方退订。如果把响应式系统比作一份通讯录cleanup就是每次通话结束后更新联系人分组以前常打的不打了就删掉新认识的客户赶紧存进去。没有这一步通讯录迟早变成一堆僵尸联系人。4.3 还有哪些场景依赖 cleanup分支切换只是最经典的场景实际开发里这类情况到处都是模板里的v-if/v-for条件从为真变为为假分支里读取的响应式数据就不再是组件的依赖了。函数的条件返回值state.type a ? state.aList : state.bList。动态访问属性state.obj[state.key]key一变实际依赖的属性就变了。如果你看过 Vue 2 的源码就知道Vue 2 的Watcher也有类似逻辑但 Vue 3 用deps数组加cleanup这套更简洁的机制解决得干干净净。这也是我在第 3 节写ReactiveEffect时坚持实现cleanup的原因——少了它你手写的响应式系统在真实场景里马上会暴露问题。5. effect 在 Vue 源码里的真实身份5.1 组件渲染整个组件就是一个大的 effect我们平时写 Vue 3 组件很少直接见到effect但这并不妨碍它是 Vue 3 组件的发动机。组件在挂载时会把渲染组件这个任务包装成一个ReactiveEffect类似这样const componentUpdateFn () { // 执行 render 函数生成 vnode // 然后 patch 到真实 DOM } const effect new ReactiveEffect(componentUpdateFn, scheduler)componentUpdateFn里执行render时模板中读取的所有响应式数据都会被track于是组件自身就被注册成了这些数据的订阅者。数据一变trigger触发组件更新。换句话说你写的模板里用了哪些响应式变量组件就和哪些变量绑定。这也是为什么 Vue 3 不需要像 Vue 2 那样靠Object.defineProperty遍历所有属性也不需要在组件上声明data的依赖版本号——依赖收集自动帮你完成了所有登记。5.2 computed惰性计算的背后是一个带 scheduler 的 effectcomputed的实现可以在packages/reactivity/src/computed.ts里看到核心是一个ComputedRefImpl类内部包了一个ReactiveEffect并且传入了一个scheduler回调。我把逻辑简化一下class ComputedRefImpl { constructor(getter) { this._value undefined this._dirty true this.effect new ReactiveEffect(getter, () { if (!this._dirty) { this._dirty true trigger(this, value) } }) } get value() { if (this._dirty) { this._value this.effect.run() this._dirty false } track(this, value) return this._value } }看到没有这里同时用了track和trigger只是作用对象不是普通对象而是computed实例本身。_dirty标志保证依赖没变就不重新计算——这就是惰性缓存。只有当它依赖的响应式数据真的变了scheduler才把_dirty置回true下一次读取value时才重算。如果你对着源码断点走一遍会发现computed的更新链路里全是track和trigger的影子。5.3 watch在 effect 外面又包了一层回调watch就更好懂了它就是把监听源source包装成ReactiveEffect并在变化时调用回调function watch(source, cb) { let oldValue const effect new ReactiveEffect(source, () { const newValue effect.run() cb(newValue, oldValue) oldValue newValue }) oldValue effect.run() return effect }source作为 effect 的fn被执行时它读取的响应式数据会被track收集数据一变trigger触发scheduler也就是这个回调函数。oldValue和newValue自然就有了watch的新旧值对比就是这么来的。理解了这套你就明白为什么watch的source必须是一个会读取响应式数据的函数或 ref——不读数据就没有依赖可收集回调永远不触发。5.4 scheduler 有什么用控制什么时候跑ReactiveEffect的第二个参数scheduler是整个系统的调度开关。默认情况下trigger会直接调用effect.run()同步执行。但如果传入了schedulertrigger不再是直接跑run()而是调用这个schedulertriggerEffects(dep) { for (const effect of dep) { if (effect ! activeEffect || effect.allowRecurses) { if (effect.scheduler) { effect.scheduler() } else { effect.run() } } } }组件渲染的 effect 在scheduler里做的事是把更新任务塞进任务队列等微任务时机再批量执行。这就是为什么你连续修改一个响应式数据组件不会同步更新多次而是合并在下一帧渲染一次。这套调度直接成就了 Vue 3 的性能优势。所以scheduler和track/trigger一样都是响应式系统里一等公民。6. 常见报错与排查实录Maximum recursive updates exceeded6.1 报错出现的原因在 Vue 3 的报错提示里有一段非常经典的话Maximum recursive updates exceeded. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.翻译过来就是更新递归次数超限了。你有一个 reactive effect 在修改它自己的依赖导致它自己不断触发自己。常见来源包括组件模板、渲染函数、updated 钩子和 watcher 的源函数。为什么会这样因为trigger把 dep 里的 effect 拿出来重新执行effect 执行时又修改了自己的依赖于是再次触发trigger又一次执行 effect……如此循环Vue 在更新队列里加入了递归次数上限默认 100 次超过就抛出这个错误。它本质上是在说你的代码走进了死循环。6.2 用我们手写的系统复现一次我用第 3 节写的迷你系统做一个准确复现const state reactive({ count: 0 }) effect(() { state.count state.count 1 })执行流程是这样的effect 第一次执行读取state.count此时是 0然后把它写成 1。写操作触发了trigger(count)dep 里的这个 effect 被再次执行。第二次执行读取到 1写成 2又触发trigger(count)……如果我们的迷你系统不加递归次数限制它就会无限循环直到浏览器卡死。Vue 在真实实现里加了一个计数器超过 100 次直接抛错这就是你看到的Maximum recursive updates exceeded。实际开发里踩这个坑最常见的场景是在watch监听一个数据时回调里又修改了同一个数据源或者在render函数里改了模板依赖的响应式数据。比如watch(() state.count, () { state.count // 回调修改了自己监听的源 })第一次回调把count加 1立刻触发监听回调再次执行再加 1……必然爆掉。6.3 排查思路与解决手段遇到这个报错我一般按下面三步排查第一步找出谁在自写自读。看错误堆栈定位到是哪个组件、哪个 watcher、哪段逻辑。核心判据是这个 effect 读取了某个响应式属性同时又在自己的执行过程里修改了同一个或间接导致其变化的属性。第二步打断同步回流。如果确实需要在 effect 里修改依赖源至少别让它同步触发。最朴素的做法是异步改写watch(() state.count, () { setTimeout(() { state.count }, 0) })异步操作打断了下一次更新的同步触发trigger触发时上一次 effect 已经执行完毕就不再是自己触发自己。第三步用临时变量或中间状态解耦。很多场景根本不需要直接改同一个属性。比如state.count要加 1可以读取后存到局部变量在另一个 effect 里统一提交或者借助原本就存在的事件流 / action 来更新而不是在派发更新的回调里反向修改源数据。注意这个报错不是 bug是保护。Vue 不阻止你写递归逻辑但它通过这个错误防止页面被无线循环卡死。出现这个报错时先反思自己的数据流设计是不是有环而不是急着去改配置。6.4 其他常见问题速查表除了递归报错掌握track/effect之后还会遇到一些高频问题整理成一张速查表分享给你现象可能原因解决方案数据改了effect 不重跑该 effect 执行时没有读取这个响应式属性track没收集到确认读取发生在 effect 内部检查是否在lazyeffect 上手动调用run()effect 意外重跑多次分支切换导致脏依赖没清理干净确认实现了cleanup检查是否同一个数据被重复读取computed 不更新_dirty标志没有被正确置回确认 scheduler 里置_dirty并trigger检查缓存逻辑是否健壮内存疑似泄漏全局targetMap存在强引用确认最外层使用WeakMap销毁组件时调用effectScope.stop()track收集不到任何依赖activeEffect为null在非 effect 环境读取属性把读取行为放进effect、computed、watch或 render context 中watch 回调不执行source函数没有读取任何响应式数据让source真正访问响应式属性而不是返回普通值最后补充一个实操心得排查响应式问题最有效的办法不是看文档而是在track和trigger里打断点。你自己手写的迷你系统完全可以加上两个日志function track(target, key) { console.log(收集依赖: ${target} 的 ${key}当前 effect:, activeEffect) // ... } function trigger(target, key) { console.log(触发更新: ${target} 的 ${key}订阅者数量:, dep?.size) // ... }跑一遍你的业务场景看日志就能立刻定位数据变化时到底有没有人订阅它订阅者又是不是你以为的那个 effect。这个思路在 Vue 3 正式项目里同样适用把__VUE_PROD_DEVTOOLS__打开或直接用 devtools 的依赖追踪面板效果是一样的。我个人在实际操作中的体会是把effect和track搞透收获的远不只是应付面试的知识点。你调试组件更新、优化性能、排查改动一个数据结果一堆地方一起刷新的问题时会从玄学猜谜变成有据可查。拿我自己的经验来说真正确认这套机制可以正常工作的小技巧是故意写一个读 A 写 B、读 B 写 A的交叉依赖场景观察它们会不会像多米诺骨牌一样稳定收敛——只要不报递归错误说明你的清理和调度逻辑是健全的。响应式系统不是一个高不可攀的黑盒从track到trigger这条链路值得每个前端花一个下午亲手推一遍。