Vue3 的响应式系统,日常开发里最容易被当成黑盒去用的部分。绝大多数同学知道 reactive 能拦截数据、ref 能包装基本类型、computed 会缓存结果,但这些功能背后到底是谁在推动,依赖变化之后副作用是怎么被找到的,又是怎么被更新掉的,很少人能一次说清。这篇文章想聊的,正是其中一个核心:effect 的调度与清理。不管你是准备 Vue3 面试,还是想真正理解 computed、watch、组件渲染更新的底层逻辑,又或者干脆想自己用原生 Proxy 手写一个 mini-vue,effect 都是绕不开的枢纽。下面我会先从整个响应式链路讲起,再逐步拆解清理和调度机制,最后给出一套可以手动复刻的最小实现,以及我实际踩过的几个坑。
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。
这里最关键的数据结构是一张三级依赖表:
targetMap:WeakMap,以原始对象为键,值是 depsMap。depsMap:Map,以属性名为键,值是 dep 集合。dep:Set,存放所有依赖该属性的 effect 函数。
为什么targetMap要用 WeakMap?原因很简单:WeakMap 对键是弱引用,当原始对象不再被业务代码引用时,垃圾回收可以正常回收它,避免响应式系统造成内存泄漏。如果换成普通 Map,依赖表会一直持有原始对象引用,导致对象永远无法被回收。
1.2 为什么非要把effect单独拎出来说
computed、watch、watchEffect、组件渲染,这些 Vue3 里高频使用的能力,底层本质上都是 effect 的封装。如果你只停留在 API 调用层面,当然也能写业务,但一旦遇到响应式更新异常,就会陷入“数据改了,视图没变”的迷雾里。
举个例子。面试里常问“computed 和 watch 的区别”,很多人会回答“computed 用于派生状态,watch 用于监听副作用”。这个答案没错,但没有触及关键区别:computed 的底层是一个配置了 lazy 和 scheduler 的 effect,watch 底层也是一个 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 里调用传入的fn:
scheduler(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 复刻需要的最小数据结构
先把核心数据结构列出来:
| 变量 | 类型 | 作用 |
|---|---|---|
| targetMap | WeakMap | 以原始对象为键,值为 depsMap |
| depsMap | Map | 以属性名为键,值为 dep 集合 |
| dep | Set | 存放依赖该属性的 effectFn |
| activeEffect | 对象 | 当前正在执行的 effectFn |
| effectStack | 数组 | 保存嵌套 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 和 trigger:
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) } 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 被修改引发问题。这种细节是保证正确性的关键,不是可有可无。
最后是 reactive:
function 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 的调度和清理作为分析框架,很多疑难杂症基本都能一眼定位。这大概就是深入源码最实在的回报。