ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue3响应式核心:effect调度与依赖清理机制深度解析

Vue3响应式核心:effect调度与依赖清理机制深度解析 Vue3 的响应式系统日常开发里最容易被当成黑盒去用的部分。绝大多数同学知道 reactive 能拦截数据、ref 能包装基本类型、computed 会缓存结果但这些功能背后到底是谁在推动依赖变化之后副作用是怎么被找到的又是怎么被更新掉的很少人能一次说清。这篇文章想聊的正是其中一个核心effect 的调度与清理。不管你是准备 Vue3 面试还是想真正理解 computed、watch、组件渲染更新的底层逻辑又或者干脆想自己用原生 Proxy 手写一个 mini-vueeffect 都是绕不开的枢纽。下面我会先从整个响应式链路讲起再逐步拆解清理和调度机制最后给出一套可以手动复刻的最小实现以及我实际踩过的几个坑。1. 从收集依赖到触发副作用effect在响应式系统里的真实位置很多人刚开始接触 Vue3 响应式时会下意识地把 reactive 当成整个系统的核心。这个认知不算错但不够精确。reactive 解决的问题是“如何拦截对象的读写”它本身不主动推动任何更新。真正让响应式系统“活”起来的引擎是 effect。你可以把响应式系统理解成一座广播站加一群收音机。reactive 是广播站它知道每个频道属性有哪些听众effect 是收音机它通过读取数据的行为把自己注册到某个频道下面。广播站一旦发出信号数据变化订阅了这个频道的收音机就会自动播放。没有收音机广播站只是空转收音机不订阅频道也收不到任何消息。1.1 一条数据变更引发的连锁反应先看一段最基础的代码import { reactive, effect } from vue const state reactive({ count: 0 }) effect(() { console.log(count changed to, state.count) }) state.count如果你在浏览器里跑这段代码控制台会打印两次count changed to 0和count changed to 1。第一次打印来自 effect 创建时的立即执行第二次打印来自state.count触发更新。这个过程中发生了几件事effect 创建时立即执行一次副作用函数。执行到state.count时会触发 Proxy 的 get 拦截器进入track逻辑。track会读取一个全局变量activeEffect也就是“当前正在执行的 effect”。它把state对象、count属性和当前这个 effect 建立映射关系保存在依赖表里。state.count会触发 Proxy 的 set 拦截器进入trigger逻辑。trigger从依赖表中找到所有依赖count的 effect并依次调用。effect 被再次执行重新读取state.count所以打印1。这里最关键的数据结构是一张三级依赖表targetMapWeakMap以原始对象为键值是 depsMap。depsMapMap以属性名为键值是 dep 集合。depSet存放所有依赖该属性的 effect 函数。为什么targetMap要用 WeakMap原因很简单WeakMap 对键是弱引用当原始对象不再被业务代码引用时垃圾回收可以正常回收它避免响应式系统造成内存泄漏。如果换成普通 Map依赖表会一直持有原始对象引用导致对象永远无法被回收。1.2 为什么非要把effect单独拎出来说computed、watch、watchEffect、组件渲染这些 Vue3 里高频使用的能力底层本质上都是 effect 的封装。如果你只停留在 API 调用层面当然也能写业务但一旦遇到响应式更新异常就会陷入“数据改了视图没变”的迷雾里。举个例子。面试里常问“computed 和 watch 的区别”很多人会回答“computed 用于派生状态watch 用于监听副作用”。这个答案没错但没有触及关键区别computed 的底层是一个配置了 lazy 和 scheduler 的 effectwatch 底层也是一个 effect但它的调度时机和清理方式完全不同。能说出这一层才能证明你真正理解 Vue3 响应式。另外排查线上问题时几乎所有的“多余更新”“漏更新”“重复请求”都能归结到 effect 的依赖收集时机和清理时机上。所以接下来这一章我先讲清理机制因为它是理解响应式正确性最重要的地基。2. 清理不掉副作用程序就会悄悄跑偏cleanup机制的完整推演如果你只实现一个最简版本没有清理逻辑表面上大部分场景也能正常工作。但一旦副作用函数内部出现分支切换问题就来了。2.1 场景还原分支切换时残留依赖导致的脏更新看这段代码const state reactive({ ok: true, text: hello }) effect(() { if (state.ok) { console.log(state.text) } }) state.ok false state.text world第一次执行 effect 时state.ok为 true函数读取了state.ok和state.text两个属性都被收集进依赖表。此时 effect 的依赖是{ ok, text }。接着state.ok false触发更新effect 重新执行。第二次执行时判断条件不成立函数体不再读取state.text。按理说现在这个 effect 只关心ok不再关心text。但由于第一次收集的text依赖仍然残留在依赖表中后面执行state.text world时effect 依然会被触发执行第三次。第三次执行时函数体读取state.ok因为已经变成 false所以不打印任何内容。表面看只是多余执行了一次但后果远不止多打印一次如果副作用里有大量计算或 DOM 操作残留依赖会造成严重的性能浪费。如果残留依赖的属性被高频修改副作用会被频繁触发甚至引发难以定位的重复请求。在更复杂的场景里残留依赖可能让一个已经不关心该属性的 effect 继续运行破坏状态的正确性。第一次执行时收集text依赖没错因为当时确实读了第二次执行后不再读text却仍然保留旧依赖这就是 bug 的源头。2.2 清理代码的职责边界什么时候删删什么解决思路并不复杂在每次副作用函数重新执行之前把上一次建立的所有依赖关系全部解除然后等副作用函数执行时再重新收集。这样如果某个属性在新一轮执行中不再被读取它就不会被重新收集自然就从依赖表里消失了。为此每个 effect 函数上需要维护一个数组deps记录它被哪些 dep 集合引用。清理函数长这样function cleanup(effectFn) { for (let i 0; i effectFn.deps.length; i) { const deps effectFn.deps[i] deps.delete(effectFn) } effectFn.deps.length 0 }清理发生在执行之前的理由也很直接依赖是执行副作用函数时产生的只有先清空旧依赖再执行函数收集到的依赖才能准确反映当前函数体真正读取了哪些属性。反过来如果先执行再清理那新收集的依赖会被误删整个依赖表就乱了。配合 track 阶段的收集逻辑完整闭环是这样effect 创建或 trigger 触发时调用effectFn()。effectFn首先执行cleanup(effectFn)把自己从所有旧 dep 集合中移除。然后执行副作用函数。函数中读取任意响应式属性时通过track把当前 effect 加入新的 dep 集合同时把 dep 集合记录到effectFn.deps中。2.3 为什么Vue3选择了activeEffect deps数组的组合有同学可能会问为什么不直接用一个全局变量记录当前 effect非要搞一个 effect 栈这是因为 effect 支持嵌套。比如组件渲染 effect 内部又创建了一个 watchEffect如果只用单个全局变量内层 effect 执行完退出后外层 effect 就找不回来了。effectStack 的存在就是为了解决嵌套退出后的恢复问题。deps数组则是一个反向索引。正向索引是“属性 - effect 集合”通过track建立反向索引是“effect - 它被哪些属性集合引用”通过effectFn.deps维护。清理时只需要遍历自己的 deps 数组不需要去整个 targetMap 里查找效率高得多。这里有一个非常隐蔽的实现细节触发更新时不能直接在原始 dep 集合上遍历执行 effect。因为 effect 执行时可能清理自己并重新收集这会导致 Set 在迭代过程中被修改造成无限循环或漏执行。Vue3 源码里会把 dep 拷贝成一份新 Set 再遍历手写实现时也要注意这一步。3. 调度器如何接管副作用的执行时机scheduler的两张面孔清理机制解决的是“依赖准不准”的问题调度器解决的则是“副作用什么时候执行”的问题。默认情况下数据一变effect 立即同步执行。但同步执行在复杂应用里并不总是最优解。3.1 默认行为与调度行为的差异effect 接受第二个参数 options其中可以传入scheduler函数。当scheduler存在时trigger 不再直接调用 effectFn而是把 effectFn 交给你自定义的 scheduler 去处理。这意味着你完全控制了副作用的执行时机。下面这个例子更直观const state reactive({ count: 1 }) let sum 0 const runner effect( () { sum state.count * 2 }, { scheduler(fn) { console.log(scheduler called, but fn not executed yet) } } ) state.count 2 console.log(sum) // 仍然是 2因为副作用还没有执行state.count 2触发 trigger 后由于传入了 scheduler副作用函数不会立刻执行。sum仍然保持旧值 1 * 2 2。如果你希望在合适时机执行副作用可以手动在 scheduler 里调用传入的fnscheduler(fn) { Promise.resolve().then(() fn()) }这样一来副作用就从同步执行变成了异步微任务执行。3.2 调度的两个典型用途去重与批量更新调度器最典型的应用场景有两个任务去重和批量更新。想象一个组件依赖了 100 个响应式属性。用户在同一个事件循环里改了全部 100 个属性如果没有调度器effect 会执行 100 次渲染 100 次性能直接崩掉。利用调度器可以把 100 次触发合并成一次在微任务里统一执行。一个简单的任务队列实现const jobQueue new Set() let isFlushing false function queueJob(job) { jobQueue.add(job) if (!isFlushing) { isFlushing true Promise.resolve().then(() { const queue new Set(jobQueue) jobQueue.clear() queue.forEach((fn) fn()) isFlushing false }) } }这里的核心逻辑有两层一是 Set 天然去重同一个 effect 即使被触发十次也只会排队一次二是 isFlushing 标志防止 while 循环重复创建微任务。Vue3 的组件更新机制就是沿用这个思路把组件的渲染 effect 作为 job 放入队列在下一轮微任务中统一执行。但要注意调度器并不是只用来做异步。computed 的 scheduler 就是一个反例它不会执行副作用函数只会把dirty标记置为 true等到有人读取 computed 时才重新计算。所以调度器的语义完全由使用方决定核心在于“把控制权接管过来”。3.3 如何结合cleanup保证调度过程中的状态一致性调度只是延后了执行时机并不意味着可以省略清理。副作用函数在真正执行前仍然会先cleanup再执行并重新收集依赖。这样无论延迟多久执行时都是基于最新状态。这里有一个需要留意的地方effect 在等待调度器执行的时候它的依赖集合还停留在上一次执行的状态。如果在这段等待窗口期里数据再次变化并且新读取的属性不在旧依赖集合中那么可能不会触发调度任务。这不算 bug而是“依赖收集总是发生在副作用执行阶段”的必然结果。理解这一点能帮你避免设计出依赖错误的代码。4. Vue3里调度与清理的幕后舞台computed、watch与渲染更新理论讲了这么多接下来把调度和清理放回 Vue3 真实场景里看才能真正理解它们的价值。4.1 computed的轻声耳机不读不跑读了才跑computed 最重要的特征是惰性求值。它内部创建 effect 时传入lazy: true使得副作用函数不会立即执行。首次读取computed.value时才手动调用 effectFn拿到计算结果并缓存。依赖数据变化后scheduler 被触发但不会立刻重新计算只是把dirty标记为 true。下一次读取时发现 dirty 为 true才重新执行 getter 并更新缓存。最小实现可以浓缩成下面这段function computed(getter) { let value let dirty true const effectFn effect(getter, { lazy: true, scheduler() { dirty true trigger(obj, value) } }) const obj { get value() { if (dirty) { value effectFn() dirty false } track(obj, value) return value } } return obj }不读不跑读了才跑读取时如果缓存有效连 getter 都不执行直接返回缓存。这种设计在计算属性开销较大时能省下不少性能。依赖清理在这里依然生效computed 的 effect 每一次重新执行都会先清理旧依赖再按最新依赖关系收集保证计算结果始终对应当前真实读取的属性。4.2 watch中的异步任务与回调清理watch 在底层也是一个 effect不过是懒执行的。它通过 scheduler 把真正的回调调度到特定时机执行。flush: pre会把回调安排在组件渲染前flush: post安排在渲染后flush: sync则是同步执行这些都属于调度策略的范畴。更值得一提的是 watchEffect 的onCleanup机制。它有别于依赖清理负责清理上一次副作用产生的“外部资源”。比如一个搜索请求场景watchEffect((onCleanup) { let active true fetchData(state.query).then((data) { if (active) { result.value data } }) onCleanup(() { active false }) })每次 watchEffect 重新执行前都会先调用上一次注册的 onCleanup 回调把上一次请求标记为过期。哪怕旧请求比新请求晚返回也不会污染结果。这个模式本质上是“当前请求序号”的闭包版本用一个闭包变量标记当前 effect 是否过期过期就丢弃异步结果。真实项目里防抖、取消请求、清理定时器都能用这个机制统一处理。4.3 渲染effect组件更新为什么需要调度组件渲染在 Vue3 中也是一个 effect。创建组件实例时Vue 会把组件更新函数包装成一个 effect并且在读取模板中响应式数据时收集依赖。当数据变化trigger 会触发组件渲染 effect。如果这个 effect 没有调度器每次属性更新都会立即触发一次组件渲染。在同一轮事件循环中修改多个属性组件就会被反复渲染。Vue3 的 scheduler 会把组件更新函数作为 job 放入队列等 microtask 执行时统一 flush。这也是为什么 Vue3 在很多场景下比 Vue2 更快的原因之一更新被合并渲染调用次数大幅减少。在处理组件树更新顺序时effectStack 也发挥了作用。父组件和子组件的 effect 嵌套执行顺序与调度队列协作保证了更新顺序稳定、可预测。虽然日常写业务不需要手动维护这些但在分析复杂页面性能问题时能帮你更快定位瓶颈。5. 把自己当成mini-vue作者用原生Proxy把effect调度与清理复刻一遍只谈理论不算真懂。下面我们用原生 Proxy 把 reactive、effect、cleanup、scheduler 复刻一遍。完整代码不多但每段都有明确职责。5.1 复刻需要的最小数据结构先把核心数据结构列出来变量类型作用targetMapWeakMap以原始对象为键值为 depsMapdepsMapMap以属性名为键值为 dep 集合depSet存放依赖该属性的 effectFnactiveEffect对象当前正在执行的 effectFneffectStack数组保存嵌套 effect 的调用栈5.2 手写实现cleanup与scheduler先实现 effect 工厂函数和清理逻辑let activeEffect const effectStack [] function effect(fn, options {}) { const effectFn () { cleanup(effectFn) activeEffect effectFn effectStack.push(effectFn) const result fn() effectStack.pop() activeEffect effectStack[effectStack.length - 1] return result } effectFn.deps [] effectFn.options options if (!options.lazy) { effectFn() } return effectFn } function cleanup(effectFn) { for (let i 0; i effectFn.deps.length; i) { const deps effectFn.deps[i] deps.delete(effectFn) } effectFn.deps.length 0 }接着写 track 和 triggerfunction 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) } 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((effectFn) { if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn) } else { effectFn() } }) }注意trigger里先拷贝了一份effectsToRun这能避免遍历过程中因为清理和重新收集导致 Set 被修改引发问题。这种细节是保证正确性的关键不是可有可无。最后是 reactivefunction reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { track(target, key) return Reflect.get(target, key, receiver) }, set(target, key, newVal, receiver) { const result Reflect.set(target, key, newVal, receiver) trigger(target, key) return result } }) }这段代码虽然极简但已经具备 Vue3 响应式系统的骨架。后面再扩展 computed、watch、effectScope都是在这个基础上加逻辑。5.3 用实际场景验证我们写出来的effect直接用分支切换场景来验证清理逻辑是否生效const state reactive({ ok: true, text: hello }) let runCount 0 effect(() { if (state.ok) { state.text } runCount }) state.ok false state.text world console.log(runCount) // 期望是 2没有 cleanup 时runCount 会变成 3因为text的旧依赖还残留在集合里。有 cleanup 后第二次执行 effect 时旧依赖被清空重新收集到只有ok一个依赖所以text变更不再触发 effect。再验证调度器const state reactive({ n: 0 }) let syncValue effect( () { syncValue state.n }, { scheduler(fn) { Promise.resolve().then(() fn()) } } ) state.n 1 console.log(syncValue) // 仍是 0因为副作用被调度到微任务中 await Promise.resolve() console.log(syncValue) // 1这套最小实现也足够用来扩展 computed代码可以直接用前面 4.1 节那段 computed 函数配合这里的 effect 运行。6. 实战中容易忽略的坑竞态、死循环与内存泄漏最后聊几个真实业务中容易踩中的坑。每个坑都不是凭空杜撰都是我在代码里实际遇到过、排查过的。6.1 副作用里改依赖小心死循环如果副作用函数内部修改了自身依赖的数据就会陷入无限循环const state reactive({ count: 0 }) effect(() { state.count })执行 effectFn 时读取state.count把自己加入依赖集合赋值state.count又触发 trigger再次把 effectFn 加入执行队列继续执行、继续修改。最终要么栈溢出要么页面卡死。即使加了 scheduler这个问题也只是从同步死循环变成异步无限任务本质上仍然无法结束。遇到这种场景必须调整设计不要在同一个 effect 里既读又写同一个依赖。实在需要可以把计算拆成两步先读取旧值再通过另一个 ref 写入或者使用 computed 作为中间层。排查这类问题时优先看 effect 回调里有没有、等写操作。6.2 异步竞态requestId - currentRequest 模式的本质异步请求的竞态问题在列表筛选、搜索联想、详情切换里特别常见。假设这样一段代码watchEffect(async () { const data await fetchData(state.query) result.value data })第一次搜索vue请求还没返回用户又搜索了vue3。第一次请求可能比第二次晚返回导致最终展示的是旧查询结果。解决方式就是利用 onCleanup 标记过期watchEffect((onCleanup) { let active true fetchData(state.query).then((data) { if (active) { result.value data } }) onCleanup(() { active false }) })这个利模式本质上和“当前请求序号”一样用一个闭包标志判断结果是否过期过期就丢弃。放到 Vue3 的响应式体系里onCleanup 就是清理机制向异步资源的自然延伸。6.3 内存泄漏与模块生命周期管理最后提醒一个容易混淆的概念清理依赖不等于停止 effect。cleanup 只是清空了 deps 关系effectFn 仍然可以被手动调用也仍然可能被后续 track 重新收集。如果要彻底让一个 effect 失效需要 stop 机制。手动实现时可以在 effectFn 上挂一个 active 标志function stop(effectFn) { cleanup(effectFn) effectFn.active false } function effect(fn, options {}) { const effectFn () { if (!effectFn.active) return cleanup(effectFn) // ...执行 fn } effectFn.active true // ... return effectFn }trigger 中也应该判断effectFn.active ! false后才执行。Vue3 提供了effectScope来批量管理一个组件或模块的所有 effect统一调用scope.stop()一次性停止。组件卸载后如果这里没处理好就会出现更新已卸载组件的警告或者长时间持有不必要的事件监听、定时器。在我自己写复杂筛选页的时候就遇到过调度器排队时机导致旧数据闪现的问题。后来把排查思路从“去看数据怎么变”改回“去看 effect 什么时候跑”反而更快找到了问题。Vue3 响应式系统的这些底层机制不需要每次开发都手写但一旦你把 effect 的调度和清理作为分析框架很多疑难杂症基本都能一眼定位。这大概就是深入源码最实在的回报。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进