恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Vue 3 源码解读:computed 与 watch 的响应式原理与排错指南
首页
资讯中心
/
Vue 3 源码解读:computed 与 watch 的响应式原理与排错指南
Vue 3 源码解读:computed 与 watch 的响应式原理与排错指南
发布时间:2026/10/15 15:21:20
先说点实在的。我最早啃 Vue 3 源码的时候也是一页页 read with no idea最后是在业务里连续踩了几个computed 不更新watch 不触发的坑才逼着自己把computed.ts和apiWatch.ts从头读了两遍。这一篇就是把我读源码的完整路线和落地经验整理出来核心就两件事watch和computed到底做了什么、为什么这样做。这篇内容适合两类人。第一类是已经在用 Vue 3 写业务但遇到响应式相关的诡异问题只能靠猜的第二类是准备系统看源码但是不知道从哪里切入的。我尽量用点菜、催菜、排队叫号这类生活语言把链路讲明白再给一些可以直接带进日常排错的手段。你不用先把 Vue 3 源码全读完只需要跟我一起把track、trigger、scheduler这几个关键词盘清楚后续再读源码就有抓手了。1. 先补地基响应式系统里的 track 和 trigger 到底在干嘛要说 watch 和 computed绕不开 Vue 3 的响应式系统。Vue 3 用 Proxy 替代 Vue 2 的Object.defineProperty这个大家都知道。但真正支撑整个响应式系统的其实是effect.ts里的两个函数track和trigger。所有响应式特性——包括 ref、reactive、computed、watch——全都是围着这两个函数转的。所以我不建议直接跳到 computed 或 watch 的源码先把这两个地基函数看清楚后面都是顺水推舟的事。1.1 track依赖是怎么被记下来的track做的事说白了就是记账。当某个副作用函数effect在运行过程中读取了state.counttrack就会把当前正在运行的 effect和count 这个 key的依赖关系记录下来存进一张巨大的依赖表里。这张依赖表的数据结构是一个三层嵌套的 Map第一层targetMap键是响应式目标对象本身第二层depsMap键是对象的属性名 key第三层dep是一个 Set里面存的是所有依赖了这个 key 的 effect。源码简化之后是下面这段重点看我标出来的那几行type Dep SetReactiveEffect type KeyToDepMap Mapany, Dep const targetMap new WeakMapany, KeyToDepMap() export function track(target: object, type: TrackOpTypes, key: unknown) { 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) }关键就在if (!activeEffect) return。如果没有正在执行的 effect读取任何响应式属性都不会留下依赖记录。这一点在后面理解 computed、watch 的各种不生效问题时非常关键。打个比方track就像餐厅里点菜。厨房间里挂着一张单子点了什么菜就记上去。但如果当前根本没有任何客人在点菜你读再多的菜单也不会产生单据。放在源码里就是不在 effect 作用域内读取属性只是拿值不建立任何引用关系。1.2 trigger依赖是怎么被唤醒的trigger和track正好是逆操作。当某个 key 的值发生变化时trigger会从targetMap找到这个对象对应的depsMap再从depsMap里取出这个 key 对应的dep把 Set 里的所有 effect 逐个取出来执行。export function trigger(target: object, type: TriggerOpTypes, key?: unknown) { const depsMap targetMap.get(target) if (!depsMap) return let deps: Dep[] [] if (key ! undefined) { deps.push(depsMap.get(key)!) } // 数组、新增属性等分支这里先省略 const effects: ReactiveEffect[] [] for (const dep of deps) { if (dep) { for (const effect of dep) { if (effect ! activeEffect) { effects.push(effect) } } } } for (const effect of effects) { effect() } }注意if (effect ! activeEffect)这一行。它防止的是如果在一个 effect 内部既读取又修改同一个响应式属性就会造成我触发我自己的无限递归。这个细节在 watch 回调里手动改源数据时会经常遇到后面第四部分我会专门讲它是怎么保护我们的。当你把track和trigger串起来理解的时候computed 和 watch 本质上都是在合适的时机调用这两个函数的壳子。理解了这两个函数接下来读源码的进入速度会快很多。2. computed 源码全链路懒汉模式的缓存是怎么实现的computed 的实现集中在reactivity/src/computed.ts核心是一个ComputedRefImpl类。整个类的代码并不多但设计得非常精巧。我把最核心的部分拆出来分析。2.1 ComputedRefImpl 的三个关键字段直接看简化过后的核心代码export class ComputedRefImplT { private _value: T | undefined private _dirty true public readonly effect: ReactiveEffect constructor(getter: ComputedGetterT, private readonly _setter: ComputedSetterT) { this.effect new ReactiveEffect(getter, () { if (!this._dirty) { this._dirty true triggerRefValue(this) } }) this.effect.computed this } get value() { if (this._dirty) { this._value this.effect.run() this._dirty false } trackRefValue(this) return this._value } }三个关键字段各司其职_value用来缓存上一次计算的结果_dirty脏值标记它是整个缓存机制的开关effect一个包装了 getter 的ReactiveEffect负责依赖收集。当你访问computedRef.value时触发的是get value()。第一次访问时_dirty是 true所以会执行this.effect.run()也就是真的把 getter 跑一遍结果存进_value然后把_dirty翻转为 false。之后再访问value_dirty已经为 false直接返回_valuegetter 不会再执行。这就是惰性求值 缓存的全部含义computed 不是每次访问都重新算而是只在依赖的数据真正变化后下一次访问才会重新计算。它不会主动提前算好属于非常典型的懒汉模式。2.2 谁在依赖变化时把 _dirty 翻回去搞明白缓存之后问题就变成_dirty从 false 变回 true 的时机是谁控制的答案在 effect 构造时的第二个参数也就是调度器 scheduler 里。new ReactiveEffect(getter, () { ... })的第二个参数就是这个调度器。当 computed 依赖的响应式数据发生变化时trigger会找到对应 effect但它不会直接执行effect.run()而是执行effect.scheduler()。调度器里做的事情就是如果当前_dirty为 false就把它改成 true然后调用triggerRefValue(this)去通知依赖了 computed 对象本身的 effect该排上队了。一个关键点是调度器并没有重新执行 getter它只是把脏标记置位。真正的计算依然推迟到下一次有人访问computedRef.value时才发生。这个设计保证了不访问不算、多次访问只算一次的特性。2.3 为什么 computed 默认不可以赋值computed 默认只有 getter 没有 setter如果你在业务里尝试computedValue.value xxx非生产环境会给你警告computed value is readonly。从源码看默认的_setter就是一个抛错的函数除非你显式传入带 setter 的 computed 配置。但即使传入了 setter它也不是直接修改 computed 内部的_value而是通过 setter 去改源数据再借由源数据的变化反过来驱动 computed 重新计算。理解了这一点你就明白 computed 的数据流本质是单向的源数据 - 派生值。2.4 用全选复选框的例子串一遍链路我给你一个实际业务里最常见的场景页面有一个列表还有一个个全选复选框allChecked是 computed依赖列表每一项的checked状态。当用户勾选其中一项时链路是这样的列表数组发生了变化trigger被调用trigger 找到登记在数组上的 effect也就是 computed 内部那个 effect 的 schedulerscheduler 发现_dirty是 false把它改成 true再调用triggerRefValue(this)通知依赖allChecked的渲染 effect组件的渲染 effect 被调度执行模板里读取allChecked.value发现_dirty是 true于是重新执行 getter得出新结果并缓存。这里最反直觉的地方在于computed 本身不会主动推新值它只是在你下次读取的时候才去算。所以性能优化时你只需要记住一句口诀computed 的惰性决定了它天然适合派生值读取频繁、源数据变化相对不频繁的场景。3. watch 源码全链路它到底在盯什么watch 的入口在runtime-core/src/apiWatch.ts对外导出的是watch函数内部真正干活的是doWatch。这一节我们把doWatch的骨架看完再去拆几个高频疑问。3.1 入参规范化把各种 source 统一成 getterwatch的第一个参数 source 可以是多种类型ref、reactive 对象、普通函数、甚至数组。源码的第一步就是把这些千奇百怪的入参统一成一个 getter 函数。function doWatch(source, cb, options) { let getter: () any if (isRef(source)) { getter () source.value } else if (isReactive(source)) { getter () source } else if (isArray(source)) { getter () source.map(s isRef(s) ? s.value : s) } else if (isFunction(source)) { getter source } // ... }这里有几个只有看过源码才知道的细节如果 source 是 refgetter 返回source.value所以 watch 监听 ref 时实际上追踪的是.value这个读取动作如果 source 是 reactive 对象getter 直接返回整个对象读取整个对象会让依赖收集覆盖到所有嵌套属性这就是为什么直接 watch 一个 reactive 对象会自动变成深度监听如果 source 是函数watch 只会追踪函数体内实际读取到的响应式依赖。这种精细化控制是 Vue 3 对比 Vue 2 的一个重大改进如果 source 是数组会逐个标准化回调参数里拿到的也就是数组形式的新旧值。3.2 job 与 flushwatch 回调为什么不是同步执行的doWatch的核心是构造一个 job 函数和一个 effectconst job: SchedulerJob () { if (!effect.active) return if (cb) { const newValue effect.run() if (deep || forceTrigger || hasChanged(newValue, oldValue)) { cb(newValue, oldValue, onCleanup) oldValue newValue } } } const effect new ReactiveEffect(getter, job)初始执行时effect.run()跑一遍 getter触发 track 收集依赖同时把这个返回值作为初始的oldValue。之后当源数据变化时trigger找到这个 effect执行的不是effect.run()而是 job。job 里再一次effect.run()拿到新的值和oldValue比较如果变化了才执行回调。这里关键的是flush选项。默认flush: pre意味着 job 会被丢进组件更新前的调度队列也就是queuePreFlushCb。Vue 3 不再像 Vue 2 那样一变化就同步执行 watcher而是把所有 job 放进一个队列合并执行。这种做法显著提升了性能但也带来一个反直觉的点watch 回调不是数据一变就立刻触发而是要等队列 flush。如果你写了依赖先后顺序的代码就需要理解这个调度机制。3.3 watch 监听 reactive 对象时为什么经常不生效这个坑我见的太多了watch(reactiveObj, cb)然后你修改对象深层属性回调就是不动。用源码逻辑解释非常直接当 source 是 reactive 对象时getter 被规范化为() source。这个函数只读取了 source 这一层没有读取source.a.b。所以初始运行 effect 时track 只对source 这个对象本身建立了依赖深层属性根本没有被访问也就没有被登记。之后直接改source.a.btrigger 在依赖表里找不到对应的 effect回调自然不执行。解决办法有两个方向加{ deep: true }源码里doWatch会调用traverse递归读取对象全部嵌套属性把所有依赖全部收集改为 getter 形式比如watch(() obj.a.b, cb)精准追踪最少的依赖。我的个人建议是能精确就尽量精确。deep 虽然省事但依赖面越广响应式系统需要维护的关联越多大对象、深层级数据下性能开销会肉眼可见地增长。用 getter 形式还有一个额外好处当对象结构变化时你可以用 getter 返回一个基本类型值比如() obj.list.length这样新旧值比较更可靠。3.4 immediate 的真相它根本没改监听逻辑immediate: true的源码实现非常简单就是在创建完 effect 之后额外调用一次回调if (immediate) { if (cb) { cb(newValue, oldValue, onCleanup) } }这里有个非常容易忽略的点初始时oldValue是 undefined所以immediate: true时第一次回调拿到的 oldValue 是 undefined。我在项目里见过不少同学在这里踩坑以为第一次回调也能拿到当前值作为旧值。如果业务上需要区分首次执行要么在回调里判断oldValue undefined要么换个思路用flush: sync配合手动触发。4. watchEffect、watchPostEffect 和 watch 的关系很多人把watchEffect、watchPostEffect、watch当成三套完全不同的 API其实从源码看它们底层就是一条链路。4.1 watchEffect 只是 doWatch 的特化看源码里watchEffect的完整实现export function watchEffect(effect, options?) { return doWatch(effect, null, options) }注意它是把 effect 作为 source 传进去同时 cb 传 null。也就是说watchEffect是没有回调的 watch它只负责跑副作用函数并且自动追踪副作用里读取到的响应式数据。每次依赖变化effect 会重新执行但没有新旧值的比较。watchPostEffect就更简单了它只是预设了flush: postexport function watchPostEffect(effect) { return watchEffect(effect, { flush: post }) }从源码反推使用场景选型就清晰了需要拿到新旧值做比较用watch(source, cb)只关心数据变了我要重新做一件事不在乎新旧值用watchEffect更简洁需要在组件渲染完之后操作 DOM用watchPostEffect。4.2 watch 回调里的第三个参数 onCleanup 是干嘛的doWatch里那个onCleanup参数官方叫副作用清理很多业务同学根本没注意到它。它的源码实现非常朴素就是闭包保存一个函数let cleanup: (() void) | undefined const onCleanup (fn: () void) { cleanup fn }然后在每次执行回调之前先执行上一次注册的 cleanup。它的意义在于当你需要取消上一次副作用时不用手动维护一堆状态标记。举个例子我在一个搜索场景里用它做过接口竞态处理。用户输入关键词触发 watch每次关键词变化先把上一次发出的请求 abort 掉再发新的请求。用 onCleanup 写起来干净很多watch(searchKeyword, async (newVal, oldVal, onCleanup) { let cancelled false onCleanup(() { cancelled true }) const res await fetchSuggestions(newVal) if (!cancelled) { state.suggestions res } })这个模式我用了好几个项目比手动记录请求序号的方式省心得多也避免了老请求覆盖新请求的经典 bug。5. computed 和 watch 的差异与选型读懂源码后终于能说清了这个需求到底该用 computed 还是 watch 这个问题在读完源码之后是可以给出非常确切的答案的而不是凭经验猜。5.1 数据流向不同是根本差异computed 是根据其他响应式数据派生出一个新值它产生的是一个可读取的值而且这个值会被缓存。watch 是监听某个数据变化后执行副作用它不产生值它只触发动作。判断标准很简单如果你需要的是一个能被模板读取、能从已有数据推导出的值优先用 computed如果你要做的是发请求、改 DOM、写日志这类事情只能用 watch 或 watchEffect。把 watch 用来计算派生值、把 computed 用来做副作用都是典型的错误姿势。5.2 缓存能力差异computed 有缓存watch 没有。下面这个反例我在 code review 时见过好几次// 错误示范用手动赋值的 watch 代替 computed const reversed ref([]) watch(list, (newVal) { reversed.value newVal.slice().reverse() }, { immediate: true })这个写法的问题在于reversed每次被读取都没有缓存只要模板里读取多处就需要重新走一遍赋值链路。而用 computed 是这样的const reversed computed(() list.value.slice().reverse())同一个计算逻辑computed 只在依赖变化后重新计算一次后续读取都是直接命中缓存。当然也有反过来不适合的情况如果这个派生值你需要经常手动赋值、手动控制中间状态computed 的单向数据流就格格不入了用 watch 加手工维护反而更直接。5.3 依赖收集的精细度不同computed 的依赖是所见即所得getter 里读了哪些响应式属性就只追踪这些没读的都不会触发重新计算。watch 则分情况source 是 getter 函数时追踪范围与 computed 完全一致source 是 reactive 对象时只有加 deep 才会全量追踪否则只追踪到对象本身这一层。这个差异直接影响性能优化。用一个表格对比一下更直观维度computedwatch/watchEffect产出一个可缓存的派生值副作用动作缓存有依赖不变不重算无每次响应式变化都可能触发依赖收集仅 getter 内读取的响应式数据取决于 source 类型与 deep 选项回调参数无只返回值有新旧值watch 专属适用场景模板取值、派生计算接口请求、DOM 操作、日志统计6. 常见坑与排查思路源码视角的 debug 方法这部分是落地的关键。我把自己真实踩过、以及帮同事排查过的高频问题整理成一份速查每一个都能对应到源码的某个机制。6.1 computed 不更新的通用排查法遇到 computed 不更新第一反应不是怀疑 Vue 有 bug而是检查getter 里到底访问了哪个真正会变化的响应式数据。我遇到过最典型的场景是 getter 里访问了一个从接口返回、用普通对象包裹的数据而这个数据本身并没有经过reactive或ref处理。getter 运行的时候track 找不到这个普通对象的响应式依赖后续数据变了 trigger 自然也没法通知 computed表现出来就是永远不更新。排查方法非常实用在 getter 里加一行console.log。如果源数据确实变了但 log 没打印说明依赖没被收集问题在 track 阶段如果 log 打印了但 UI 没变问题就出在下游渲染和 computed 本身无关。用这个二分法能快速定位 80% 的 computed 问题。6.2 watch 回调不触发的两个高频原因第一类是依赖收集问题。上一节已经讲过监听 reactive 对象加上 deep或者改成 getter 精确追踪即可。第二类是新旧值比较问题。doWatch默认用hasChanged判断本质是!Object.is(value, oldValue)const hasChanged (value, oldValue) !Object.is(value, oldValue)如果 source 是引用类型getter 每次返回的都是同一个引用哪怕内部字段变了Object.is也会认为没变化回调不会触发。这里最隐蔽的两个分支监听数组本身比如watch(arr, cb)arr.push()后引用没变回调不触发。解决watch(() arr.length, cb)或watch(() [...arr], cb)监听 reactive 对象的某个字段字段是对象类型替换为一个新对象时引用变了能触发但原地修改内部属性则要看是否 deep。6.3 别再被同步执行的直觉坑了Vue 3 默认 flush 是 prewatch 回调会被合入队列在组件更新之前统一执行。这带来一个好处的同事也会让一些同学困惑我明明改了数据watch 怎么还要等一等才执行如果你真的需要同步拿到变化可以给 watch 加flush: sync但要注意同步执行会让每次数据变化都立刻跑一遍回调丧失了队列合并的性能优势频繁触发的场景下要慎重。大多数情况下我更建议调整业务逻辑去适配 pre 的默认调度而不是反过来让全局调度妥协。6.4 一个源码级别的调试心得最后分享一个我常用的排查方法我叫它最小复现法把出问题的链路拆成数据源、computed、watch、模板四层每层单独验证。先确认数据源有没有真的变化再确认 computed getter 里有没有触发依赖收集然后确认 watch source 规范化的结果是哪种 getter最后才看模板渲染。这个方法我用了很多次每次都能快速定位是收集依赖失败还是触发链路断了比盯着控制台瞎猜高效得多。回到开头那句话Vue 3 源码里 computed 和 watch 的代码量都不大真正的难点是理解 track/trigger 这条主线。把 track 想成点菜、trigger 想成催菜、调度器想成后厨排队叫号整个响应式链路就通了。我个人啃源码的体会是不要从头顺序读先从computed.ts和apiWatch.ts的类定义和主函数看起遇到 track/thrigger 再跳去读effect.ts读完回到入口继续循环几轮整个图景就自然立在脑子里了。实践是最好的验证方式找一个自己真实出过 bug 的场景对照源码一步步走一遍比看十篇解析都管用。