Vue3 响应式核心 effect 源码详解:依赖收集、触发与清理机制

发布时间:2026/10/6 13:41:50
Vue3 响应式核心 effect 源码详解:依赖收集、触发与清理机制 今天这篇我想认真聊聊 Vue3 响应式系统里最核心的一个函数——effect。如果你写过ref、reactive、computed、watch其实你已经在间接使用effect了。effect就是响应式副作用机制的发动机页面为什么改了一个变量的值就自动刷新computed为什么能做到懒计算watch为什么能监听变化根子上都绕不开这段源码。这个系列做到第五章前面我们已经看了 Vue3 的整个设计理念、Composition API 的形态、虚拟 DOM 的差异。但说实话那些都还是“用框架”而effect这一段属于“造框架”——你能看到 Vue 是怎么把数据和视图联系在一起的。本文会从「为什么需要副作用」讲起再到track、trigger、cleanup的源码拆解最后落到实际开发中常见的无限递归更新报错、依赖追踪丢失、onTrack调试等场景。适合已经熟悉 Composition API 基础语法、想深入原理的前端开发者阅读。1. effect 到底在响应式系统里扮演什么角色1.1 从一个最简单的视图更新说起先看一段再平常不过的 Vue3 代码const count ref(0) // 假设这是组件渲染逻辑 effect(() { document.title 当前计数${count.value} }) count.value 1你写没写过这样的代码不重要重要的是它背后发生了什么effect接收了一个函数这个函数内部读取了count.value然后当我们修改count.value时这个函数又自动重新执行了一遍。这个“自动重新执行”就是副作用side effect的意思。副作用一词听起来高深本质上就是“函数执行过程中除了返回值之外还对外部世界造成了影响”。上面这段代码的副作用是把标题给改了。把副作用和响应式数据绑定到一起让数据一变、副作用自动重跑——这就是响应式系统的全部意义也是 Vue3 内部effect的核心职责。有人会问组件渲染不就是一个最大的effect吗对源码里边组件更新确实会走ReactiveEffect的一套机制。当你在模板里读了count.value组件就会被注册为count的依赖等count变化组件重新渲染。理解effect就是理解 Vue 的“神经通路”。1.2 单独把 effect 拎出来看的原因我在读 Vue3 源码时第一次尝试直接看packages/reactivity/src/effects.ts说实话有点晕——文件里全是细节createReactiveEffect、ReactiveEffect类、track、trigger、cleanup、调度器逻辑……每一个都是对的但没有一根主线把它们串起来。真正帮助我的方式是先把effect当成一个独立积木去拆只问三个问题依赖怎么被记住effect 执行时读到了哪些数据Vue 怎么把这些数据记录成依赖什么时候触发数据变了Vue 怎么找到之前记录的依赖找出来之后干什么怎么避免脏依赖effect 每次重新执行如果依赖集合变了比如条件分支导致不再读某个数据了旧的依赖关系怎么清理这三个问题分别对应 Vue3 源码中的三驾马车track依赖收集、trigger依赖触发、cleanup依赖清理。effect本身要看懂最有效的路径其实是从track、trigger这两个更小的函数切入而不是直接从ReactiveEffect类开始啃。我之前带新人看这段源码也是按这个顺序带的先在控制台手写一个 50 行的 Mini 实现理解数据结构和循环逻辑然后再回到真实源码。你会发现真实源码只是多绕了两道弯比如处理多层对象、数组方法重写、调度器、缓存但骨架完全一致。2. 手写最小实现再回头读源码2.1 先搭积木一个 60 行的 MiniReactive先别去开源码来手写一个最小可用的响应式系统。这段代码不追求覆盖所有边界只展示数据结构和基本逻辑但它的骨架和 Vue3 源码几乎一一对应。// 存储所有 target - key - effects 的对应关系 const targetMap new WeakMap() let activeEffect: (() void) | null null // 依赖收集在 effect 执行时记录当前正在运行的 effect 到对应 key 的集合 function track(target: object, key: string | symbol) { 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) } dep.add(activeEffect) } // 触发更新数据变化时取出与该 key 相关的所有 effect 并依次执行 function trigger(target: object, key: string | symbol) { const depsMap targetMap.get(target) if (!depsMap) { return } const dep depsMap.get(key) if (!dep) { return } // 注意这里需要拷贝一份再执行避免执行期间 effect 修改自身依赖导致死循环 ;[...dep].forEach((effect) effect()) } // 响应式对象包装只做 get / set 的拦截 function reactiveT extends object(target: T): T { return new Proxy(target, { get(target, key, receiver) { const result Reflect.get(target, key, receiver) track(target, key) return result }, set(target, key, newValue, receiver) { const result Reflect.set(target, key, newValue, receiver) trigger(target, key) return result }, }) } // 副作用注册立即执行一次 fn并在执行过程中自动收集依赖 function effect(fn: () void) { const wrapped () { const prevEffect activeEffect activeEffect wrapped fn() activeEffect prevEffect } wrapped() return wrapped }这段代码在功能上已经能跑通一个最基础场景const state reactive({ count: 0 }) effect(() { console.log(副作用执行当前 count , state.count) }) state.count 1 // 控制台会打印两次初始一次更新一次这个迷你版虽然简陋但骨架跟 Vue3 源码是一致的。注意几个细节我用了WeakMap来存targetMap这意味着对象被垃圾回收后与之相关的依赖映射也会自然被回收。dep这个 Set 存的是副作用函数本身而不是一个“发布订阅主题”之类的抽象概念。Vue3 中依赖的最小单位就是effect我在这没引入类只是存函数当然真实源码是一个ReactiveEffect实例。track里面第一行就问“当前有正在执行的 effect 吗”没有就直接返回。这说明副作用函数内部的数据读取才会被记录外部随手读一下响应式对象不会造成依赖泄漏。2.2 回到真实源码为什么要引入 dep、depsMap 三层嵌套真实源码中track的逻辑大体是这样packages/reactivity/src/dep.ts和effect.ts里export function track(target: object, type: TrackOpTypes, key: unknown) { // 没有正在执行的 effect 就不收集 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 createDep())) } trackEffects(dep, ...) }结合我们前面手写的版本你再看这段源码就不会晕了。整个依赖关系其实是三层嵌套一个响应式对象target 一个 key属性名 一个依赖集合SetReactiveEffect再加一个WeakMaptarget, Mapkey, Seteffect把它们串起来。为什么这么设计因为一个项目里有无数响应式对象一个对象也有多个属性而一个属性往往被多个副作用依赖三层结构是存储这些关系的最小建模。targetMap用WeakMap不是随手写的它最大的好处是如果业务代码里不再持有某个响应式对象了targetMap中那个对象对应的条目可以被垃圾回收不会造成内存泄漏。这一点在长列表、动态路由、临时弹窗等场景里非常重要——如果你用了 Map这些被销毁的页面数据会一直挂在内存里时间久了页面会越来越卡。真实dep是一个Set之外Vue3 还在dep.ts里做了性能优化设置了一个内部属性w已经被收集的标记和n新收集的标记配合cleanupEffect来做高效清理。第一次看源码的时候这组w/n可能绕但它实际是优化cleanup的标记算法本质就是“我们依赖的集合”和“新一次运行后应该依赖的集合”之间的差集。2.3 为什么全局必须有 activeEffect手写版本里出现了activeEffect这是整个依赖收集机制的灵魂。它的作用其实一句话就能说完记录当前正在执行的 effect。为什么需要这个全局变量因为 JavaScript 是单线程的。在任何时刻最多只有一个副作用函数在“运行过程中”。当模板渲染或者计算属性求值时访问state.count的读操作被Proxy拦截这时候拦截函数track只需要问一句现在谁正在运行是它把state.count当成依赖的。如果你没有这样一个“当前正在执行的 effect”标记那么track就不知道该把依赖注册到谁身上。而有了activeEffect读取操作就像学生在课堂上被点名track可以顺手把他记录到签到本上。这里有两点容易忽视activeEffect是模块级全局变量一次只能有一个。这正好对应 JavaScript 单线程的执行模型。但嵌套场景呢比如effect内部又创建了子effect子effect执行完父effect还能继续收集依赖吗这就引出了prevEffect的栈式恢复。真实源码里写的是const prevEffect activeEffect activeEffect effect try { return fn() } finally { activeEffect prevEffect }finally保证即使函数内部抛错activeEffect也能恢复成上一个状态的引用不会串场。这个小细节我记了很久因为我自己写响应式练习时忘了finally结果一个 effect 抛异常后整个页面的依赖收集全乱了。如果我们稍微手滑在模板外直接读ref.value或者在setTimeout里读响应式对象此时没有正在运行的 effecttrack直接返回。这保证了“不是副作用执行上下文中的读取不产生依赖记录”。所以调试依赖丢失时第一反应应该是是不是我的读取动作没发生在 effect/computed/watch/组件渲染上下文中3. 源码级拆解 effect 的核心实现3.1 effect 函数本体lazy、scheduler 与 runnerVue3 源码里暴露出去的effect函数是经过包装的核心实现在ReactiveEffect类上。先看一个简化版的effect入口export function effectT extends () any(fn: () T, options?: { lazy?: boolean scheduler?: (job: () void) void scope?: EffectScope allowRecurses?: boolean }) { const _effect new ReactiveEffect(fn, options?.scheduler) if (!options || !options.lazy) { _effect.run() } const runner _effect.run.bind(_effect) as ReactiveEffectRunner runner.effect _effect return runner }稍微捋一捋这个入口函数的设计。ReactiveEffect是一个类构造函数里存了用户传入的fn和一个可选的scheduler。然后默认情况下不传lazy: trueeffect 创建后立即执行一次_effect.run()。这个立即执行就是副作用注册的标准动作第一次运行同时完成了两件事——让fn体内的读取操作能收集到当前 effect以及把初始的副作用结果先跑一遍。每次调用runner也就是_effect.run都会执行fn并返回fn的返回值。如果你在effect(() { count.value })(处理一些数据)中需要拿到返回值这一类需求尤其常见于把 effect 当做一个“可持续手动触发”的函数时。对了runner还有一个属性runner.effect很多内部工具、vue/test-utils、组件测试都依赖这个引用来操作这个响应式任务。再看一下ReactiveEffect的run方法核心逻辑run() { if (!this.active) { return this.fn() } let lastEffect activeEffect let lastShouldTrack shouldTrack activeEffect this shouldTrack true try { this.parent lastEffect return this.fn() } finally { activeEffect lastEffect shouldTrack lastShouldTrack } }源码里这段比我手写的多了两个东西shouldTrack和this.parent。shouldTrack是“是否允许收集依赖”的开关比如在计算属性内部副作用执行上下文里有时需要临时禁止深度收集避免多余的嵌套依赖——它体现的是对性能的精细拿捏。this.parent则是为了支持嵌套 effect 的层级调试追踪父子关系。需要留意的是if (!this.active)这里的状态。当一个 effect 被stop()停止之后再调run()它还是会把fn()执行一遍但不会再收集依赖了。很多初学者会误以为“停止”就是完全不再执行函数其实真相是stop 只是断开了自动触发关系手动调用依然会执行。3.2 track 与 trackEffects依赖收集的两个阶段有了ReactiveEffect接下来看track。前面我们已经写了track的结构真实源码里track执行两步找到targetMap里对应的depsMap和dep然后调用trackEffects。第二步是真正把 effect 放入集合export function trackEffects(dep: Dep, debuggerEventExtraInfo?: DebuggerEventExtraInfo) { if (!shouldTrack) return // 省略若干调试钩子逻辑 let shouldTrackEffect false if (dep.get(activeEffect) undefined) { dep.set(activeEffect, activeEffect._trackId) shouldTrackEffect true } if (shouldTrackEffect) { dep.add(activeEffect) activeEffect.deps.push(dep) } }这里跟“手写版”最大的差别浮现了真实源码里的dep不是Seteffect而是Mapeffect, number。map 的 value 存的是_trackId——每次 effect 执行时都会自增的追踪编号。这样设计是为了支持cleanup阶段的差集计算通过_trackId能快速判断一个依赖是旧依赖还是新依赖。activeEffect.deps.push(dep)是反向记录——每个 effect 实例都维护一个数组记录它依赖了哪些 dep 集合。这个反向指针是为了后续清理当 effect 要重新执行或停止时要从它依赖过的所有 dep 里把自己删掉。这一段是源码中信息密度最高的地方之一。我建议读源码的朋友把dep、deps、trackId、_trackId这几个名字的解释贴在旁边反复走一遍执行流程比硬记定义要有效得多。3.3 trigger 与 triggerEffects触发更新的完整路线trigger的工作是当响应式对象的属性被修改时找到所有依赖当前修改的 effect并调度它们执行。画一个简化版伪代码function trigger(target, key) { const depsMap targetMap.get(target) if (!depsMap) return // 拿到对应 key 的 dep map以及通过迭代器构造的 effects 集合 const dep depsMap.get(key) triggerEffects(dep) }而triggerEffects是这段路线的终点。大致逻辑function triggerEffects(dep) { // 拷贝一份 effects 列表避免在循环中因执行 effect 而修改原集合 const effects [...dep.values()] for (const effect of effects) { if (effect ! activeEffect || effect.allowRecurses) { if (effect.scheduler) { effect.scheduler() } else { effect.run() } } } }这里面有几个重要设计。第一判断effect ! activeEffect如果一个 effect 的副作用函数内部修改了它自己正在读取的数据比如effect内部count.value这个条件会挡住“自己触发自己”避免无限循环。但注意effect.allowRecurses的存在如果你确实需要允许 effect 递归执行比如手写递归算法Vue 也留了后门。第二scheduler的分流。不带scheduler的 effect触发后直接同步调用effect.run()重新执行闪存参数。如果我们传入了scheduler触发时就只调用调度器而不是重新执行 effect——computed和watch之所以在行为上跟effect有区别正是因为它们传了不同的scheduler。我们后面再展开。第三这里的[...dep.values()]拷贝非常关键。如果你直接for (const effect of dep)遍历原集合并执行执行过程中 effect 又触发cleanup会同时修改dep本身导致“遍历中修改集合”的不一致。源码里通过拷贝一份再循环来规避这个问题。手写版本里我用的也是同样的思路。3.4 cleanup 机制为什么每次触发前要先删掉旧依赖讲到这里必须把cleanupEffect拎出来。在 Vue3 核心依赖收集机制里一个 effect 首次运行后会把当时所有读到的依赖记录下来但副作用函数体内可能有条件分支第二次运行时读取的数据可能变少甚至完全变成另一批。如果不去掉旧依赖最直接的后果就是一个 effect 明明不再关心某属性了但该属性一变effect 还是会被触发多跑了很多无用的函数。源码中effect 每次执行前会先清空this.deps列表里的所有旧依赖function cleanupEffect(effect: ReactiveEffect) { const { deps } effect if (deps.length) { for (let i 0; i deps.length; i) { deps[i].delete(effect) } deps.length 0 } }这个函数极其简单但意义很大。依赖就像贴纸旧的不撕掉新的贴上去就会越贴越多。Vue3 在每次 effect 执行开始时调用cleanupEffect带着w/n标记优化保证依赖集合是我在每次重新执行后的真实需要而不是“历史上曾经需要过”的数据。这个设计也意味着如果你在一个effect里用if控制是否读某个属性当条件为假时一整个分支不再执行那些属性相关的依赖会自然被清掉。我实际调试过不少“多跑”问题最后原因都是依赖没清干净比如老朋友在effect外先读了一次响应式值这个读取在首次 effect 执行时进入了deps但后续取值并不在 effect 体内旧依赖却一直残留。cleanupEffect解决了这些问题让你写的条件逻辑和响应式依赖完全同步。4. 从 effect 看 ref、computed、watch 的运行逻辑4.1 ref 的 value 是怎么联动 effect 的聊到ref很多人以为ref仅仅是reactive({ value: xxx })的语法糖。其实ref的实现独立且高效它用了RefImpl类class RefImplT { private _value: T private _rawValue: T public dep: Dep constructor(value: T, public readonly __v_isRef true) { this._rawValue toRaw(value) this._value toReactive(value) this.dep createDep() } get value() { trackRefValue(this) return this._value } set value(newVal) { // 对比新旧值无变化则不触发 if (hasChanged(this._rawValue, newVal)) { this._value toReactive(newVal) triggerRefValue(this) } } }注意ref里没有三层嵌套依赖结构它直接用this.dep这一个Mapeffect, number收集所有依赖value读取的 effect。因为 ref 只有一个value属性需要收集不需要depsMap这一层。想想看如果一个对象有 10 个属性自然用targetMap分属性管理更合理而 ref 本质上只有一个对外出口所以一层 dep 就够了。trackRefValue内部调用的还是trackEffects那套逻辑set value里hasChanged比较的是_rawValue原始值这个比较能确保“改完的值等于旧值”时不触发更新。很多同学遇到过“我明明给 ref 赋了新对象页面竟然没更新”之类的困惑多半就是出了原对象和一次性操作的问题所以源码里特别保留_rawValue来对比最原初对象。这就是响应式联动的实际形态你写state.value 1底层调triggerRefValue把this.dep里的所有 effect 调度执行组件更新和页面刷新就这么串起来了。4.2 computed 为什么是“懒计算”computed在 Vue3 源码里本质是一个ComputedRefImpl类它内部也创建了一个 effect但是这个 effect 是lazy 的并且带schedulerclass ComputedRefImplT { constructor(getter: () T) { // 初始化一个 lazy 的 effect this._effect new ReactiveEffect(getter, () { // 调度器只标记 dirty不立即重新计算 this._dirty true triggerRefValue(this) }) } get value() { if (this._dirty) { this._value this._effect.run() this._dirty false } trackRefValue(this) return this._value } }关键在_dirty这个脏值标记。第一次读取computed.value时_dirty为 true执行run计算真正的结果此后只要依项目没有任何变化再次读值直接返回缓存结果不再重算。当依赖的数据变化时调度器会触发把_dirty置回 true同时triggerRefValue告知上层依赖 computed 的 effect“这个值变了你要的话内部会重新算。”但真正重算发生在下一次读取时这就是“懒计算”。这个机制带来一个非常直观的性能优势页面里有 5 个 computed绑定了复杂的数组筛选但只要没变重复渲染时计算一次就能复用如果某个 computed 在模板里根本没渲染到它可能永远都不会执行。这种“一次读一次算”的缓存策略是直接基于 effect 的确定性保证才敢做的。如果你用computed里的 effect 去触发外层副作用既然 computed 的调度器只标记_dirty那么副作用执行时机完全依赖外层 effect 的读取位置。如果外层 effect 是flush: sync或默认同步模式的值变化取值时计算能立刻反映如果外层是异步渲染的组件computed往往在渲染阶段才真正计算。4.3 watch 的 scheduler 模式和 flush: postwatch和computed类似也创建了 effect但它的 scheduler 决定回调在什么阶段执行。源码的中枢逻辑大致是这样的function doWatch(source, cb, options) { const scheduler (job: () void) { if (!cb) return if (!flush) { // 同步模式直接执行 job() } else if (flush pre || flush post) { // 放入队列等待组件更新前/后执行 queueJob(job) } } const effect new ReactiveEffect(getter, scheduler) effect.run() }这里有两个重点。其一watch 默认也是 lazy 的——effect.run()不是为了先执行一次回调而是为了跑一遍 getter 收集依赖回调的初次执行由immediate: true单独控制。其二flush: post的实现其实是把任务放入queueJob等待组件渲染完成后再执行回调。这也是为什么在watch回调里能拿到最新 DOM 的原因执行时机被调度到了渲染之后。从这就能理解 Vue3 响应式系统中一个重要特性effect 的调度分为同步执行和异步队列执行两类。默认effect是同步的数据变了马上重跑但组件的更新逻辑被设计成了异步队列watch的post也是异步的。这种“分区调度”避免了一个响应式变量变化时所有相关函数在同一轮同步执行中互相牵连导致性能崩坏。5. 常见问题与排查技巧5.1 全网都在问的 Maximum recursive updates exceeded如果你的控制台出现过类似这样的警告Maximum recursive updates exceeded. This means you have a reactive effect that is mutating its own dependencies and thus recreating itself forever. Fix by avoiding writes to the reactive value inside the effect.这是 Vue 的一种保护性报错。核心原因就是你在副作用函数内部修改了该副作用自己正在依赖的数据造成“触发了自己 → 自己重跑 → 又触发自己 → 无限递归”。最典型的触发代码长这样const count ref(0) effect(() { count.value })effect内部读取count.value完成依赖收集然后又执行count.value。这个过程触发了 trigger而trigger里要执行的 effect 正是当前这个activeEffect于是陷入无限循环直到超限。解决思路非常明确不要在 effect 体内写它读过的值。如果确实需要基于现有值产生新值然后更新先把值算出去再赋值非要在副作用里更新响应式值务必加条件判断让循环可以退出或者在手动赋值时短暂利用allowRecurses这样的钩子。这种情况我在实际业务里碰到过的最常见形态是手写了一个“保存后自动同步”的代码在watch回调里又修改watch监听源数组内容结果数组一变触发 watcher又改一遍。强烈建议遇到这个报错时从“谁在 effect 里写了自己依赖的 key”这个角度排查比断点调试效率高得多。5.2 用 onTrack / onTrigger 把依赖收集过程调出来Vue3 的响应式系统给调试者留了两个探针onTrack和onTrigger。它们是effect的可选options依赖收集时触发onTrack依赖触发时触发onTrigger。它不改变任何运行逻辑只是让你观察依赖的收集和触发过程。import { effect, reactive } from vue const user reactive({ name: 张三, age: 18 }) effect( () { console.log(effect 执行读到, user.name) }, { onTrack({ target, type, key }) { console.log(依赖收集, type, key, target) }, onTrigger({ target, type, key, newValue }) { console.log(依赖触发, type, key, newValue) }, } )运行后控制台能清楚看到get事件被记录、set事件之后 effect 重新执行。如果你在排查“为什么某个值改了页面没刷新”“为什么改成相同值也会触发”通过这两个钩子可以观察到trigger到底有没有发生以及track是在哪个上下文里完成的。这是内建、零成本、不需要装 DevTools 的方案非常适合组件内部逻辑排查。更骚的操作是在onTrigger里打印调用栈一眼就能看到是谁改了这个值。我曾经定位到一个第三方组件偷偷修改了内部响应式状态导致页面连锁更新的问题就是靠这个钩子抓到的。5.3 依赖丢失的排查方向和小技巧响应式丢了是很麻烦的 bug。排查了几类高发来源之后我认为依赖丢失的根因离不开以下几种场景。一是解构赋值。const { count } reactive({ count: 0 })解构出来的是一个普通数字根本不具备响应式能力。正确做法是用toRefs。二是读取时机不对。在setTimeout、异步回调、事件回调里读取state.value由于当时不在 effect 执行上下文中track不会收集。三是直接替换整个对象。比如const data reactive({ list: [] })然后data { list: [新数组] }整个data引用变了原来的 target 就被抛弃新对象上并不会有相关的依赖映射。四是Map/Set/数组方法未触发拦截比如arr[0] x 对于初始索引不存在的场景有时会绕过 setter实际上 Vue3 已经设计了很多数组方法重写但依然要留意边界。排查依赖丢失我的固定套路是三步先在数据读取处临时加onTrack或 DevTools 的追踪面板看有没有track记录再在数据修改处加onTrigger看有没有触发记录最后对照这两个记录的时间线就能定位断点在收集还是触发。这个方法十次里有八次能直接定位问题。6. 几个实际开发中值得留意的副作用场景6.1 不要在 effect 里做无限循环类操作effect的核心是一条同步递归链路触发 → 运行 → 可能再触发 → 再运行。如果在effect内部写了while(true)或者不加保护的递归页面基本上当场就卡死了。我之前见过有人在computed里循环修改另一个响应式数组结果computed的调度器不断置_dirty渲染时又触发读取导致整个渲染阶段无数遍重算页面看起来像是“慢”实际上已经进入死循环。对这些情况我建议一个原则副作用函数体内只做“读取和计算”以及触发非响应式类型的 IO例如请求、日志、DOM 更新不要把可能被自身触发的响应式写操作放在里面。大多数无限递归问题的源头都是违反了这一条。6.2 区分 effect、watchEffect、watch 的执行时机很多初学者分不清effect与watchEffect的关系。简单点说watchEffect暴露出来的就是一个自带默认配置的effect它同步执行、自动依赖收集没有immediate的概念因为它本来就立刻执行。而watch则是我们上面说的doWatch——基于 effect 进一步封装但默认 lazy、可以拿到新旧值、可以配置flush。当你需要“数据变化后做一些额外事情比如发请求、写日志、操作 DOM”时优先用watchEffect或者watch而不是裸effect。裸effect更底层缺少很多边界处理一般用来做库封装或调试业务代码层面直接用官方暴露的 API 更稳。源码读多了你会发现一个有意思的事官方文档里几乎不鼓励在业务中直接调用effect()这个函数是给框架和二次封装用的原材料而不是给最终用户手搓的业务工具。6.3 EffectScope组件的统一依赖垃圾桶从 Vue3.2 开始响应式系统里多了一个EffectScope概念核心是给所有 effect 一个统一的“容器”。effect源码里有一个activeEffectScope的全局变量配合scope参数你可以把一个组件内的所有 effect 都挂到同一个作用域对象上。组件卸载时调用scope.stop()EffectScope会遍历它管理的所有ReactiveEffect对每个调用stop()一次性清理。这比手动管理effect的停止时机优雅得多。实际上Vue 组件内部会自动帮每个组件创建 effectScope这也是为什么组件卸载后响应式依赖不泄漏的关键。如果你自己封装功能要用到多个独立的effect强烈建议用effectScope组合。7. 附录读这一段源码的实用小建议最后给想继续深入读源码的朋友留一份我的个人路径。第一步先准备一个干净的最小项目能力有限的话直接用一个 Vite 项目也没问题然后打开node_modules/vue/dist/vue.esm-bundler.js搜索function track和function trigger。真实的源码编译产物中函数名基本保留搜索定位很准。第二步把每条函数打印出来手动执行一个小到不能再小的 demo把activeEffect、depsMap、dep、_trackId这些状态变化逐行贴上去。第三步看packages/reactivity/src/effect.ts的开头部分并对照官方仓库的测试用例里面effect.spec.ts。测试用例简直是手把手的“源码说明书”比博客更权威也更全。第四步遇到computed/watch时反复横跳对比各自内部的scheduler代码就知道它们只是effect在不同调度策略下的变体。理清了这层关系整个响应式系统基本就算入门了。我还愿意让不太熟源码阅读的朋友尝试一个“自我设问”的配置在onTrack/onTrigger里打印类型名和 key然后去改数据观察输出。这个习惯帮我省了大量“读代码却陷入细节”的时间。源码阅读的本事不在“背代码”而在于能用自己的话说出“当某一瞬间数据变化程序究竟会跳过哪些逻辑、去执行哪些逻辑”。响应式副作用听起来抽象但一旦你抓住了track、trigger、cleanup这三个动作的本质再回头看ref、computed、watch它们就不再是黑盒了。effect这条线索串起了整个 Vue3 响应式系统的神经脉络——理解它对调试响应式问题、封装复杂组合式函数、甚至面试深挖原理都好用得很。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询