Vue3源码解析:从effect看watch与computed的设计与调度
Vue 3 的watch和computed这两条 API可能是绝大多数项目里使用率最高的响应式能力但也是误解最多的一对。平时查资料大家见到的多半是用法层面的总结computed 用于同步派生状态、watch 用于执行副作用、能用 computed 就别用 watch……这些结论没错可一旦进入源码层你会发现这两条 API 的骨架其实惊人地一致——都建立在同一个 effect 机制上只是各自加了不同的“外壳”。这篇vue3.0 源码解析二就是把 watch 和 computed 放在一起对照着看从 Reactivity 模块的 effect 开始一路拆到 API 层把“为什么 computed 会缓存”“为什么 watch 能拿到新旧值”这类问题用源码级的答案讲清楚。适合已经写过一些 Vue 3 项目、但又没系统读过源码的开发者。1. 两条 API 的公共地基effect 的两个隐藏开关1.1 从“副作用”到“订阅”effect 是怎么建立依赖的如果没读过响应式内核直接啃apiWatch.ts和computed.ts会很吃力。先把最小闭环画出来一份响应式数据在被读取时会执行track把当前正在运行的 effect 记入依赖集合数据被修改时会执行trigger找到依赖集合里的 effect 并重新调用它。这里的“正在运行的 effect”就是我们常说的副作用对象。Vue 3 里它的实现是ReactiveEffect类我用简写过的骨架来还原class ReactiveEffect { deps: any[] [] constructor( public fn: () void, public scheduler: (() void) | null null, ) {} run() { activeEffect this const result this.fn() activeEffect undefined return result } } function track(target: object, key: unknown) { if (activeEffect) { // 把 activeEffect 记入 target[key] 对应的依赖集合 } } function trigger(target: object, key: unknown) { const effects getDeps(target, key) effects.forEach(effect { if (effect.scheduler) { effect.scheduler() } else { effect.run() } }) }这段代码的价值在于点出一个容易被忽略的事实响应式体系管理的其实不是“数据”而是“读取了数据的那一段函数”。谁读了响应式的obj.count谁就被记到count的账上count一变账上所有的 effect 都被通知到。为什么普通开发者平时感觉不到 effect 的存在因为 Vue 把 effect 藏在了渲染函数、watch、computed底层。你可以把 effect 理解成一张快递派送单发货方是trigger收货方是那些被包进了 effect 的函数中间靠track写入地址。1.2 lazy 和 scheduler普通 Effect 用不到computed 和 watch 天天用ReactiveEffect的构造器里有一个scheduler这个概念在官方文档里很少展开讲却是理解 watch 和 computed 的钥匙。普通用户自建 effect 时scheduler通常是空的。于是trigger走的是默认分支——直接执行effect.run()重新跑一遍函数。这在渲染场景下没问题数据变了页面就该重新渲染。但computed和watch对“重新执行”这件事有额外的要求computed不希望依赖一变就立刻重算它希望“等我下次被读取时再重算”于是需要一个scheduler把自己标记成脏状态。watch不希望数据一变就立刻让用户回调乱跑它希望先重新执行 getter 拿到新值再比较新旧值最后决定要不要调用用户回调于是也需要一个scheduler来做这层“分拣”。这就是ReactiveEffect里两个被普通人忽略的语义lazy控制“初始化时要不要立刻跑一次”scheduler控制“被触发后去哪儿报到”。如果你把 effect 理解成一个电话客服中心普通 effect 是电话一响就接computed的scheduler是顺手记下“有来电”然后挂断等用户主动打回来问时再处理watch的scheduler则是把电话转接到“历史记录仪”先查一下这次来电和上次有什么不同再把结果汇报给用户回调。2. computed一个带锁的计算器2.1 设计意图不是实时求值而是“按需求值 缓存”很多教程会把 computed 说成“依赖变了就重新计算”这句话严格讲不够准确。更准确的说法是computed 是一个“带缓存的计算函数”依赖变化后它先把旧结果作废但不会立刻重算等到有人读取.value时才真正执行 getter。这个设计在源码语境里非常明显。看computed.ts的核心类逻辑class ComputedRefImplT { private _dirty true private _value!: T effect: ReactiveEffectT constructor(getter: () T) { this.effect new ReactiveEffect(getter, () { // 依赖变化时调度器被调用只“标记脏”不立刻重算 if (!this._dirty) { this._dirty true // 通知外层依赖了这个 computed 的 effect triggerRefValue(this) } }) } get value() { // 外层读取 computed先把外层 effect 记到 computed 自己的 dep 集合 trackRefValue(this) if (this._dirty) { this._dirty false this._value this.effect.run() } return this._value } }这里面最关键的是_dirty变量。依赖第一次变化时scheduler 只是把_dirty置为 true并通知外层“这个计算值可能过期了”。真正的 getter 没有跑用户回调没有跑任何重活都没干。只有当某个地方读取computed.value时getter 里的if (this._dirty)才触发重算。如果读之前_dirty是 false说明依赖没动过直接返回上一次的_value整个过程没有一次额外计算。2.2 为什么依赖一变就必须重算而不是等值变才重算很多人会问computed 重算之后如果发现结果和上次一样能不能不再通知外层 effect也就是说能不能做一层“结果级缓存”而不是“依赖级缓存”源码里没有这么做原因有两层。第一层结果比较的成本可能比重算还高。一个 computed 内部可能拿 10 万条数据做reduce你为了判断结果是否变化又得拿旧值和新值做一次深比较遍历一遍刚算出来的对象这不划算。第二层更关键的是“依赖变化但结果碰巧相同”这件事在响应式体系里不能当作没发生过。因为依赖可能是一个对象属性被另一个watch或模板 effect 所依赖哪怕 computed 算出来的值没变只要它的依赖被trigger了外层这些 effect 也需要被唤醒由hasChanged去判断它们自己关心的事情。所以computed的“脏”不是“结果脏”而是“依赖脏了”。这是一种保守但正确的策略宁可多算一次也不能漏掉一次变化通知。2.3 computed 被读取时还做了一件隐藏的事看上面的get value()在判断_dirty之前先调用了trackRefValue(this)。这行代码容易懂但容易被人忽略computed本身也是一个“响应式数据源”。模板里写{{ computedA }}渲染 effect 会读取computedA.value。这一步读取行为会被记录到 computed 自己的依赖集合里。下次当computedA的依赖变化时scheduler 不仅把_dirty置为 true还会通过triggerRefValue把渲染 effect 也唤醒。于是渲染链路变成数据源变化 → 某个属性trigger→ computed 的 scheduler 标记脏并triggerRefValue→ 渲染 effect 被唤醒 → 渲染函数重新读取computedA.value→ 发现_dirty→ 重算并返回新值。这个链条里的每个环节都是“等待读取”而不是“主动推进”这种反向惰性求值正是 computed 与普通函数最大的区别。3. watch在 effect 外面套一层“历史记录仪”3.1 依赖收集阶段getter 里究竟跑了什么watch 的实现分散在packages/runtime-core/src/apiWatch.ts里但它的行为内核还是在ReactiveEffect上。直接看源码中的核心流程function doWatch(source, cb, options {}) { const { deep false, immediate false, flush pre } options let getter: () any if (isRef(source)) { getter () (source as Ref).value } else if (isReactive(source)) { getter () source deep true } else if (isFunction(source)) { getter source } if (deep) { const baseGetter getter getter () traverse(baseGetter()) } let oldValue: any INITIAL_WATCHER_VALUE const job () { if (cb) { const newValue effect.run() if (deep || hasChanged(newValue, oldValue)) { callWithAsyncErrorHandling(cb, [newValue, oldValue INITIAL_WATCHER_VALUE ? undefined : oldValue]) } oldValue newValue } else { effect.run() } } const effect new ReactiveEffect(getter, job) if (cb) { if (immediate) job() else oldValue effect.run() } }先看第一段source被标准化成一个getter。如果监听的是refgetter 读取source.value如果监听的是函数getter 直接就是这个函数如果监听的是reactive对象源码里会把deep强制设为 true因为在getter () source这个表达式中effect 并没有读取对象的任何内部属性不强制深遍历的话这个 watch 等于什么依赖都没收集到永远不触发。3.2 触发阶段scheduler 里的两次 run 与新旧值比较watch 的第一次 effect.run() 发生在创建阶段。这次运行不执行用户回调只做两件事把依赖收集起来然后把返回值记成oldValue。之后每当依赖变化trigger会调用我们传给ReactiveEffect的job。job干的第一件事是再执行一次effect.run()拿到newValue。注意这里没有第三方的状态管理所谓“新旧值”其实就是同一个 getter 跑了两次一次在创建时一次在触发时。然后走到hasChanged(newValue, oldValue)判断。如果值没变用户回调不执行如果变了就把newValue和oldValue一起交给用户的回调并把oldValue更新成这一次的newValue。有一个很容易被绕进去的点监听一个reactive对象时新旧值总是同一个对象引用。比如监听reactive({list: []})对象本身没有被替换每次effect.run()返回的仍是最初那个对象hasChanged比较出来永远是 false。所以源码在if判断里写的是deep || hasChanged(...)只要deep为 true不管引用变没变一律执行用户回调。这个“deep 模式下回调必执行”的行为本质上是给深监听开了另一扇门。3.3 不同数据源的标准化ref、reactive、getter、数组apiWatch.ts在处理 source 时有一个很长的分支判断我在上面只展示了三个主要分支。实际源码还要处理数组if (isArray(source)) { isMultiSource true forceTrigger source.some(s isReactive(s) || isRef(s)) getter () source.map(s { if (isRef(s)) return s.value if (isReactive(s)) return traverse(s) if (isFunction(s)) return s() return NOOP }) }数组 source 意味着同时监听多个数据源getter 返回一个数组。每次 job 触发时newValue是一个数组hasChanged会比较两个数组的引用只要数组长度、顺序一致但某个元素内部变化了引用比较无法发现所以源码里也多了一个forceTrigger变量作为“宽松放行”的条件。这也解释了为什么实际项目里watch([refA, refB], ...)时回调经常比你预计的触发更频繁。它走的是数组级引用比较 forceTrigger两个信号共同决定的路径而不是逐个元素做深比较。4. 回调时机pre、post、sync 是怎么分流的4.1 scheduler 的三条分支同步执行、更新前排队、更新后排队在doWatch的末尾源码根据flush选项构造了不同的schedulerlet scheduler: () void if (flush sync) { scheduler job } else if (flush post) { scheduler () queuePostFlushCb(job) } else { job.pre true scheduler () queuePreFlushCb(job) }这三个分支就是 watch 回调调用时机的全部秘密。flush: syncscheduler 直接等于 job。依赖一变立刻在当前调用栈里执行 getter、比较、回调。没有任何排队完全同步。flush: postscheduler 把 job 丢进queuePostFlushCb队列。这个队列会在组件渲染完成后执行。默认的flush: pre给 job 打上pre true标记再丢进queuePreFlushCb队列。注意pre不是“组件更新前立即执行”而是“组件更新前的批量刷新阶段执行”。这里需要说明一下“批量刷新”。Vue 3 里不管pre还是post最终都会把一个flushJobs函数以微任务的方式排队。同一轮事件循环里即使多次触发triggerscheduler 也只是把 job 加入队列一次真正的执行被合并到微任务里统一处理。这种合并机制保证了一次交互中即使某个状态被改了 5 次watch 回调也只跑一次。4.2 默认 pre 的合理性以及为什么 computed 不需要这层分流很多没有深入源码的开发者会困惑为什么 watch 默认不让 callback 立刻执行呢同步执行不是更“实时”吗第一同步执行会在trigger的遍历过程中插入用户代码如果这段用户代码又修改了同一个响应式数据会导致trigger一边遍历依赖一边新增依赖轻则重复执行重则死循环。第二业务里大部分 watch 回调的目的是“等数据稳定后做一件事”高频数据变化下每次都同步执行会浪费大量计算。而pre阶段发生在组件更新前正好适合在 DOM 更新前修正某些状态。那 computed 为什么不需要flush选项因为 computed 根本没有“用户回调”。它只有 getter 和 setter而 getter 是纯计算逻辑什么时候算、被谁触发完全由_dirty和.value的读取决定。说句直白的话watch 管的是“变化后做什么”computed 管的是“被读取时算什么”两者关注面不同自然不需要同一套调度机制。4.3 实际开发里怎么选 flush我自己的标准很简单默认用pre除非你明确知道要在 DOM 渲染之后操作 DOM。想在组件更新后测量元素尺寸、读取滚动位置、操作第三方图表实例的用post。sync能不用就不用。它最直观但它把所有调度责任都推给了你在复杂项目里几乎一定会踩到“回调里改数据又触发回调”的循环。关于post有一个小坑post阶段的回调虽然等渲染完成但它并不保证能拿到刚刚变化后的 DOM 状态。如果你要在回调里读 DOM建议配合await nextTick()或者直接使用flush: post后再加一层微任务。5. 源码之外那些容易被写错的边界情况5.1 deep 监听的代价以及用 getter 替代 deeptraverse是一个深度递归读取对象所有属性的函数。只要deep为 truewatch 的 getter 就会对数据源执行一次完整遍历把沿途所有响应式属性都注册到 effect 的依赖里。这意味着你监听一个含大数据量的对象树时第一次 effect.run() 就要访问每一个属性。如果对象里还有data这样的大数组遍历成本会直线上升。不少项目的卡顿并非渲染问题而是某个watch(obj, handler, {deep: true})把整个对象树都扫了一遍。更理性的做法是用 getter 精确到字段级// 不推荐 watch(obj, handler, { deep: true }) // 推荐只监听真正关心的路径 watch(() obj.userInfo.phone, handler) watch(() [obj.userInfo.name, obj.userInfo.age], handler)getter 写法不仅能减少依赖数量还让代码自带文档——未来的维护者一眼就知道你在监听哪段数据。唯一的例外是当你需要“某个对象内部任意属性变化都触发”时deep 才是合理的。还有一个隐藏细节用watch(() obj.a.b, handler)时Vue 不会自动深遍历所以obj.a被整个替换时能触发但obj.a.b的深层字段变化不一定都能被捕获到。这种情况需要你在 getter 里显式访问到目标字段或者临时指定 deep。5.2 onCleanup 的相位问题源码里 watch 回调可以接收第三个参数onCleanup它注册的清理函数会在下一次回调触发之前执行。注意我在前面贴的job代码里有一句被我省略的cleanup()真实代码里它位于“比较新旧值”通过之后、调用用户回调之前。这个相位很重要。如果你在清理函数里读取了某个响应式数据而这个数据本身就是触发 watch 的原因那么cleanup()的执行会再次触发依赖收集可能导致意料之外的循环。我见过有人在onCleanup里调用message.success()结果 watch 每次触发时先提示一次上次的清理再提示本次更新界面上一堆重复提示。靠后的onCleanup只适合做“撤销上次异步响应的副作用”比如取消AbortController、清除定时器。不要在清理函数里写任何会读取响应式数据的逻辑保持它纯粹。5.3 用断点看 scheduler胜过读十遍源码如果你想真正吃透今天的源码解析我建议你亲手做一次断点实验创建一个只有几十行的 demo用watch监听一个 ref在浏览器 devtools 里把断点打在ReactiveEffect的run()上。然后修改 ref 的值观察调用栈。你会清楚地看到第一次栈是创建 watch 时的effect.run()第二次栈是scheduler触发后的job调用effect.run()。这两次调用之间多了一个“把 job 排入微任务队列”的过程——如果你用的是默认 flush。断开断点再换成flush: sync对比一下调用栈你瞬间就明白 Vue 的调度器到底做了什么取舍。computed 的断点实验更有意思。把断点打在get value()里的if (this._dirty)分支上连续读取一个依赖未变化的 computed你会发现断点根本不会进入重算分支——缓存效果在调用栈里一目了然。这种“亲眼看到源码分支不执行”的体验比任何教程都管用。最后补充一个我在项目里反复遇到的教训不要在 computed 的 getter 里修改其他响应式状态。源码里computed的 getter 被设计为“仅在读取时运行”但如果你在 getter 里写了xx.value这样的逻辑会形成一个“读取 → 修改 → 触发 → 再读取”的循环最终表现就是控制台报出Maximum call stack size exceeded。computed 应当保持纯函数性质只读数据只返回结果。真要改数据放在 watch 回调里做那是它的主场。说到底watch 和 computed 看似是两个 API骨子里却是同一套响应式内核的两种外壳。把lazy和scheduler这两个开关吃透再看 Vue 3 的reactivity相关源码很多原本觉得抽象的地方都会自然顺畅起来。