☰
Vue 3 源码解读:computed 与 watch 的响应式原理与排错指南
2026/10/15 15:21:18 网站建设 项目流程

先说点实在的。我最早啃 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.count,track就会把"当前正在运行的 effect"和"count 这个 key"的依赖关系记录下来,存进一张巨大的依赖表里。

这张依赖表的数据结构是一个三层嵌套的 Map:

  • 第一层targetMap:键是响应式目标对象本身;
  • 第二层depsMap:键是对象的属性名 key;
  • 第三层dep:是一个 Set,里面存的是所有依赖了这个 key 的 effect。

源码简化之后是下面这段,重点看我标出来的那几行:

type Dep = Set<ReactiveEffect> type KeyToDepMap = Map<any, Dep> const targetMap = new WeakMap<any, 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 ComputedRefImpl<T> { private _value: T | undefined private _dirty = true public readonly effect: ReactiveEffect constructor(getter: ComputedGetter<T>, private readonly _setter: ComputedSetter<T>) { 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,直接返回_value,getter 不会再执行。

这就是"惰性求值 + 缓存"的全部含义: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状态。

当用户勾选其中一项时,链路是这样的:

  1. 列表数组发生了变化,trigger被调用;
  2. trigger 找到登记在数组上的 effect,也就是 computed 内部那个 effect 的 scheduler;
  3. scheduler 发现_dirty是 false,把它改成 true,再调用triggerRefValue(this)通知依赖allChecked的渲染 effect;
  4. 组件的渲染 effect 被调度执行,模板里读取allChecked.value,发现_dirty是 true,于是重新执行 getter,得出新结果并缓存。

这里最反直觉的地方在于:computed 本身不会主动"推"新值,它只是在你下次读取的时候才去算。所以性能优化时,你只需要记住一句口诀:computed 的惰性决定了它天然适合"派生值读取频繁、源数据变化相对不频繁"的场景。

3. watch 源码全链路:它到底在盯什么

watch 的入口在runtime-core/src/apiWatch.ts,对外导出的是watch函数,内部真正干活的是doWatch。这一节我们把doWatch的骨架看完,再去拆几个高频疑问。

3.1 入参规范化:把各种 source 统一成 getter

watch的第一个参数 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 是 ref,getter 返回source.value,所以 watch 监听 ref 时,实际上追踪的是.value这个读取动作;
  • 如果 source 是 reactive 对象,getter 直接返回整个对象,读取整个对象会让依赖收集覆盖到所有嵌套属性,这就是为什么"直接 watch 一个 reactive 对象会自动变成深度监听";
  • 如果 source 是函数,watch 只会追踪函数体内实际读取到的响应式依赖。这种精细化控制是 Vue 3 对比 Vue 2 的一个重大改进;
  • 如果 source 是数组,会逐个标准化,回调参数里拿到的也就是数组形式的新旧值。

3.2 job 与 flush:watch 回调为什么不是同步执行的

doWatch的核心是构造一个 job 函数和一个 effect:

const 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.b,trigger 在依赖表里找不到对应的 effect,回调自然不执行。

解决办法有两个方向:

  1. 加{ deep: true },源码里doWatch会调用traverse递归读取对象全部嵌套属性,把所有依赖全部收集;
  2. 改为 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: 'post':

export 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 是 'pre',watch 回调会被合入队列,在组件更新之前统一执行。这带来一个好处的同事,也会让一些同学困惑:"我明明改了数据,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 的场景,对照源码一步步走一遍,比看十篇解析都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询