Vue 3 核心源码深度剖析系列(v3.5.44)-第 4 章 响应式内幕(三):computed / watch / effectScope 与调度
本章面向已读完第 02 章(
effect与依赖追踪)与第 03 章(reactive/ref代理)的读者。 如果说第 02 章回答了"依赖是怎么被记录下来的",第 03 章回答了"读写是怎么被拦截的", 那么本章要回答的是三个更高层的问题:
computed为什么能"只在被读时才计算"、并且"没人读就不算"?(惰性 + 缓存 + 失效)watch的一次回调到底是谁、在什么时机、被谁调用起来的?(构造 + 调度 + flush)- 一堆
computed/watch/ 组件更新混在一起时,执行顺序由什么决定?(批处理 + 队列 +effectScope)本章的全部结论都来自
3.5.44的真实源码,代码引用格式为packages/.../xxx.ts:行号。 一个必须提前说明的版本背景:3.5 对响应式内核做了"无虚拟栈"重构,computed不再是"持有一个ReactiveEffect去跑 getter", 而是自身实现Subscriber接口、直接挂进Dep的订阅者双向链表。 因此你会看到ComputedRefImpl上有deps/depsTail/flags, 而不是 3.4 时代的_deps/_dirty/effect。 本章会专门用一节(见 4.2.6 的版本对照表)解释这次重构把什么变快了、把什么语义保留了。
本章阅读地图
三条主线一句话概括:
Dep是"被依赖方",维护订阅它的Subscriber链表(subs/subsHead,见packages/reactivity/src/dep.ts:67-106)。Subscriber是"依赖方",ComputedRefImpl与ReactiveEffect都实现它,各自维护依赖的Dep链表(deps/depsTail,见packages/reactivity/src/effect.ts:58-83)。effectScope是"生命周期容器",把创建期注册的ReactiveEffect收集起来批量stop()。- 调度链:
Dep.trigger → notify → batch/endBatch → ReactiveEffect.trigger → scheduler → queueJob。
4.1 computed 的数据结构:ComputedRefImpl 全字段逐个解释
4.1.1 顶层类型:三种"computed ref"的接口分层
先看类型声明,这决定了"可写"与"只读"在类型层面的分野:
// packages/reactivity/src/computed.ts:16-41
declare const ComputedRefSymbol: unique symbol // 只读 computed 的标记
declare const WritableComputedRefSymbol: unique symbol // 可写 computed 的标记
interface BaseComputedRef<T, S = T> extends Ref<T, S> {
[ComputedRefSymbol]: true
/** @deprecated computed no longer uses effect */
effect: ComputedRefImpl // 仅为向后兼容保留
}
export interface ComputedRef<T = any> extends BaseComputedRef<T> {
readonly value: T // 只有 getter
}
export interface WritableComputedRef<T, S = T> extends BaseComputedRef<T, S> {
[WritableComputedRefSymbol]: true // 有 setter
}
export type ComputedGetter<T> = (oldValue?: T) => T
export type ComputedSetter<T> = (newValue: T) => void
export interface WritableComputedOptions<T, S = T> {
get: ComputedGetter<T>
set: ComputedSetter<S>
}
要点:
BaseComputedRef继承自Ref<T, S>(packages/reactivity/src/ref.ts),所以 computed 天然满足"是个 ref"的全部约定(__v_isRef、可被解构、可被unref)。effect字段在类型上仍然存在,但被标注@deprecated,注释明确写着 "computed no longer uses effect"——这是 3.5 重构最有辨识度的一处"痕迹"。很多老文章/老库仍会访问computedRef.effect,所以实现里用effect: this = this做了一个"自引用占位"(见下文)。ComputedRef的value是readonly,WritableComputedRef不是——编译器层面就阻止你给只读 computed 赋值。
4.1.2 ComputedRefImpl 类字段逐字段走读
完整类声明(packages/reactivity/src/computed.ts:47-112):
export class ComputedRefImpl<T = any> implements Subscriber {
_value: any = undefined // 缓存的上一次求值结果
readonly dep: Dep = new Dep(this) // 自己的"输出依赖":谁读了我,谁订阅我
readonly __v_isRef = true // 标记:我是 ref
readonly __v_isReadonly: boolean // 标记:我是否只读(构造时由 !setter 决定)
deps?: Link = undefined // 我订阅的 Dep 链表头(输入依赖)
depsTail?: Link = undefined // 我订阅的 Dep 链表尾
flags: EffectFlags = EffectFlags.DIRTY // 位标志:DIRTY/NOTIFIED/RUNNING/TRACKING/EVALUATED...
globalVersion: number = globalVersion - 1 // 上次求值时的全局版本号(快路径)
isSSR: boolean // 是否处于 SSR 组件 setup 中
next?: Subscriber = undefined // 批处理链表用的 next 指针
effect: this = this // @deprecated 向后兼容,指向自身
onTrack?: (event: DebuggerEvent) => void // dev only
onTrigger?: (event: DebuggerEvent) => void // dev only
_warnRecursive?: boolean // dev only:递归 computed 告警开关
constructor(
public fn: ComputedGetter<T>, // getter
private readonly setter: ComputedSetter<T> | undefined, // 可写的 setter
isSSR: boolean,
) {
this[ReactiveFlags.IS_READONLY] = !setter // 无 setter => 只读
this.isSSR = isSSR
}
下面逐个字段解释"它是谁、生命周期、谁读谁写"。
(1)_value:缓存值
- 谁写:
refreshComputed()求值成功后写入(packages/reactivity/src/effect.ts:418-419)。 - 谁读:
get value()返回它(computed.ts:144);求值时作为"旧值"传给 getter(effect.ts:415的computed.fn(computed._value))。 - 初值:
undefined。所以一个 computed 在首次被读取前,_value就是undefined——但这不代表它"已经算出来是 undefined",因为flags初始带着DIRTY,任何读取都会触发一次真实求值。 - 注意:
_value不参与"是否需要重算"的判定,判定靠flags与globalVersion(下文详述)。
(2)dep:输出依赖(Output Dep)
这是最容易混淆的字段。ComputedRefImpl 同时是"两个角色":
- 作为订阅者(Subscriber):它的
deps/depsTail记录"我依赖了哪些数据源"。 - 作为被依赖者(Dep 的宿主):它的
dep记录"谁依赖了我的结果"。
new Dep(this) 把 this(即 computed 自己)作为 Dep 构造参数传入,对应:
// packages/reactivity/src/dep.ts:102-106
constructor(public computed?: ComputedRefImpl | undefined) {
if (__DEV__) {
this.subsHead = undefined
}
}
所以 dep.computed === this。这个反向引用是整条 computed 惰性订阅链的开关——addSub() 会检查 link.dep.computed 来决定"要不要顺手把一个 computed 的全部输入依赖也订阅上"(dep.ts:207-232)。
为什么 computed 需要一个独立的 Dep?
因为"读取 computed"这个动作本身也是一种依赖。模板里 {{ a + b }} 如果 a、b 是 computed,render effect 并不直接订阅 a/b 内部的源数据,而是订阅 a.dep 与 b.dep。这样当源数据变化时,computed 先"哑"掉(标记 DIRTY),只有真正被读时才重算——这正是缓存的精髓。
(3)__v_isRef = true 与 __v_isReadonly
__v_isRef是无条件true(computed.ts:59),让isRef()、模板自动解包、reactive()的 ref 解包逻辑都能识别它。__v_isReadonly由构造函数写入this[ReactiveFlags.IS_READONLY] = !setter(computed.ts:110)。注意它是动态键写入,因为ReactiveFlags.IS_READONLY是Symbol.for('v-...')之外的常量字符串(见packages/reactivity/src/constants.ts),这类"魔法属性"统一挂在ReactiveFlags下。
(4)deps / depsTail:输入依赖双向链表(对应旧版 _deps)
deps 是链表头,depsTail 是链表尾,节点是 Link:
// packages/reactivity/src/dep.ts:32-62(节选)
export class Link {
version: number // 该依赖上次被"使用"时 Dep 的版本
nextDep?: Link; prevDep?: Link // 同一个 Subscriber 的依赖链
nextSub?: Link; prevSub?: Link // 同一个 Dep 的订阅者链
prevActiveLink?: Link // 依赖复用/嵌套时的上下文暂存
constructor(public sub: Subscriber, public dep: Dep) {
this.version = dep.version
// ...
}
}
为什么用链表而不是 Set? 三个收益:
- 零分配复用:依赖集合不变时,
prepareDeps把每个link.version置-1,运行时dep.track()若发现link.version === -1就只更新版本号并把它"挪到尾部",不 new 新对象(dep.ts:127-151)。 - 访问顺序保序:链表按"本次求值中依赖被访问的顺序"重排,
cleanupDeps后deps头尾稳定。 - O(1) 增删:
removeSub/removeDep都是常量时间指针操作。
sub.deps 为 undefined 的含义是"该 computed 从未在有效订阅关系下求值过",isDirty() 与 refreshComputed() 都会据此走快路径。
(5)flags: EffectFlags(取代旧版 _dirty 布尔)
// packages/reactivity/src/effect.ts:41-53
export enum EffectFlags {
ACTIVE = 1 << 0,
RUNNING = 1 << 1,
TRACKING = 1 << 2,
NOTIFIED = 1 << 3,
DIRTY = 1 << 4,
ALLOW_RECURSE = 1 << 5,
PAUSED = 1 << 6,
EVALUATED = 1 << 7,
}
对 computed 而言,实际参与语义的位有:
| 位 | 名称 | 在 computed 上的含义 |
|---|---|---|
1<<0 | ACTIVE | computed 本身不直接用它做"停止",但 endBatch 会用它过滤 ReactiveEffect |
1<<1 | RUNNING | 正在执行 fn,用于防止自递归 notify |
1<<2 | TRACKING | 是否已接入订阅体系:只有被真实读取过(有下游订阅者)才置位 |
1<<3 | NOTIFIED | 已在本次批处理中被 notify 过,防止重复入队 |
1<<4 | DIRTY | 结果可能失效,需要重算 |
1<<7 | EVALUATED | 曾经成功求值过至少一次,用于 refreshComputed 的快速返回 |
初始值是 EffectFlags.DIRTY(computed.ts:78),即"出生即脏"。这非常关键——它是"惰性"的第一颗种子。
一个微妙之处:TRACKING 与 DIRTY 的组合判定出现在 refreshComputed 开头:
// packages/reactivity/src/effect.ts:376-383
export function refreshComputed(computed: ComputedRefImpl): undefined {
if (
computed.flags & EffectFlags.TRACKING &&
!(computed.flags & EffectFlags.DIRTY)
) {
return // 已接入订阅、且不脏 => 直接返回缓存
}
computed.flags &= ~EffectFlags.DIRTY
// ...
也就是说:如果一个 computed 从未被下游订阅(TRACKING 未置位),那么即使它不脏也不会走这个快返回——因为只要 TRACKING 为 0,条件 TRACKING && !DIRTY 就为 0,函数继续往下跑。但继续往下马上会遇到 globalVersion 快路径(下文 4.2),所以最终仍然不会真的重算。这两层保护是互补的。
(6)globalVersion:全局版本快路径
// packages/reactivity/src/dep.ts:14-19
export let globalVersion = 0 // 每次 Dep.trigger 都会 ++
ComputedRefImpl.globalVersion 初值是 globalVersion - 1(computed.ts:82)。
设计动机:如果整棵响应式图自上一次求值以来"一丁点变化都没有",那么任何 computed 都必然仍是缓存有效的。用一个单调递增的全局计数器做"全体失效"的粗粒度闸门,比逐个检查 deps 链的版本号便宜得多:
// packages/reactivity/src/effect.ts:385-390
if (computed.globalVersion === globalVersion) {
return
}
computed.globalVersion = globalVersion
初值取 globalVersion - 1 而不是 0,是为了让首次读取必定不相等(除非恰好在 globalVersion === -1 的极限初态,实际上不会),从而强制走一次"完整判定"。
(7)isSSR
由 runtime-core 的 apiComputed.ts 传入:
// packages/runtime-core/src/apiComputed.ts:4-16
export const computed: typeof _computed = (getterOrOptions, debugOptions) => {
const c = _computed(getterOrOptions, debugOptions, isInSSRComponentSetup)
if (__DEV__) {
const i = getCurrentInstance()
if (i && i.appContext.config.warnRecursiveComputed) {
;(c as unknown as ComputedRefImpl<any>)._warnRecursive = true
}
}
return c as any
}
在 SSR 组件 setup 中创建的 computed,isSSR = true。它的意义体现在 refreshComputed 的这段:
// packages/reactivity/src/effect.ts:392-404
// In SSR there will be no render effect, so the computed has no subscriber
// and therefore tracks no deps, thus we cannot rely on the dirty check.
// Instead, computed always re-evaluate and relies on the globalVersion
// fast path above for caching.
// #12337 if computed has no deps (does not rely on any reactive data) and
// evaluated, there is no need to re-evaluate.
if (
!computed.isSSR &&
computed.flags & EffectFlags.EVALUATED &&
((!computed.deps && !(computed as any)._dirty) || !isDirty(computed))
) {
return
}
SSR 下没有 render effect 去订阅 computed,所以 computed.deps 恒为空,isDirty() 也就永远返回 false(没有 dep 可比较)——若不加 !isSSR,SSR 的 computed 会永久返回第一次的值。所以 SSR 分支刻意跳过这层"靠 deps 判断"的缓存,只依赖 globalVersion 快路径。
(8)next?: Subscriber:批处理链表的钩子
batch() 会把 Subscriber 串成单向链表(effect.ts:251-260),next 就是链针。computed 专用的链表是 batchedComputed:
// packages/reactivity/src/effect.ts:247-260
let batchDepth = 0
let batchedSub: Subscriber | undefined
let batchedComputed: Subscriber | undefined
export function batch(sub: Subscriber, isComputed = false): void {
sub.flags |= EffectFlags.NOTIFIED
if (isComputed) {
sub.next = batchedComputed
batchedComputed = sub
return
}
sub.next = batchedSub
batchedSub = sub
}
为什么 computed 要单独一条链? 因为批次结束时对二者的处理不同:computed 只需清除 NOTIFIED 标志(它本身不"跑",只需让后续读取能重新判定),而真正的 ReactiveEffect 要调用 trigger()(effect.ts:273-310)。混在一起会做无意义的 trigger。
(9)effect: this = this:一段向后兼容的历史包袱
3.4 及以前,computed 内部持有一个 ReactiveEffect,字段就叫 effect,社区里有人(尤其老版本 Pinia / 测试库)会读 computedRef.effect。3.5 删掉了这个内部 effect,为了不破坏这些访问,实现直接把字段指向自己:
// packages/reactivity/src/computed.ts:92-93
// for backwards compat
effect: this = this
它是自引用,既不是真的 effect,也不具备 effect 的 run()/stop() 等多数方法——所以类型上 effect 声明为 ComputedRefImpl(computed.ts:24)而非 ReactiveEffect。
(10)onTrack / onTrigger / _warnRecursive:dev-only 调试面
onTrack/onTrigger由computed(getter, debugOptions)注入(computed.ts:215-218),仅在__DEV__ && !isSSR时生效。- 触发时机:
onTrack在dep.track()硬件层(dep.ts:153-162),onTrigger在Dep.notify()的 dev 分支(dep.ts:176-192)。 _warnRecursive由apiComputed.ts在appContext.config.warnRecursiveComputed打开时设置,最终在refreshComputed检测自递归时告警(见 4.4)。
4.1.3 字段关系总图
classDiagram
class ComputedRefImpl {
+_value: any
+dep: Dep
+deps: Link
+depsTail: Link
+flags: EffectFlags
+globalVersion: number
+fn: ComputedGetter
-setter: ComputedSetter?
+isSSR: boolean
+notify()
+get value()
+set value()
}
class Dep {
+version: number
+computed: ComputedRefImpl?
+subs: Link
+subsHead: Link
+sc: number
}
class Link {
+version: number
+nextDep/prevDep
+nextSub/prevSub
+sub: Subscriber
+dep: Dep
}
ComputedRefImpl "1" --> "1" Dep : dep (输出)
Dep "1" --> "*" Link : subs (谁读我)
ComputedRefImpl "1" --> "*" Link : deps (我依赖谁)
Link --> Dep
Link --> ComputedRefImpl
一句话总结角色的二象性:
ComputedRefImpl的dep是"别人对我的依赖";deps是"我对别人的依赖"。 前者让下游(render effect、另一个 computed、watch)能订阅我;后者让我能订阅上游。
4.2 computed 的惰性求值与缓存
本节回答:一次 c.value 读取,从入口到拿到数字,中间到底走了哪些判定?
这是全章最需要"逐行"的部分,因为 computed 的性能与正确性几乎全压在这条路径上。
4.2.1 读取入口:get value()
// packages/reactivity/src/computed.ts:131-145
get value(): T {
const link = __DEV__
? this.dep.track({
target: this,
type: TrackOpTypes.GET,
key: 'value',
})
: this.dep.track()
refreshComputed(this)
// sync version after evaluation
if (link) {
link.version = this.dep.version
}
return this._value
}
三步,顺序不可交换:
- 先
dep.track()——把"当前活跃的 Subscriber"登记为this.dep的订阅者(如果当前没有活跃订阅者,track直接返回undefined,link为 undefined)。 - 再
refreshComputed(this)——判定是否需要重算,需要则执行fn。 - 最后把
link.version同步为dep.version——这是"依赖新鲜度"的对账。
为什么第 1 步必须在 refreshComputed 之前?
因为 refreshComputed 内部会把 activeSub 切成 computed 自己去追上游依赖(effect.ts:409-411)。如果先 refresh 再 track,activeSub 已经换人,dep.track() 就认不到真正的下游了。
第 3 步的必要性更微妙:dep.track() 新建 Link 时会用 dep.version 初始化(dep.ts:54),但求值过程中 dep.version 可能刚好自增(effect.ts:419 在值变化时会 dep.version++)。若不同步,isDirty() 下轮就会误判"下游还得重算"。所以末尾那句 link.version = this.dep.version 是必需的补偿。
4.2.2 dep.track():登记下游订阅者
// packages/reactivity/src/dep.ts:108-165(节选)
track(debugInfo?: DebuggerEventExtraInfo): Link | undefined {
if (!activeSub || !shouldTrack || activeSub === this.computed) {
return // 没有活跃订阅者 / 追踪被暂停 / 自己读自己 => 不登记
}
let link = this.activeLink
if (link === undefined || link.sub !== activeSub) {
link = this.activeLink = new Link(activeSub, this)
// 挂到 activeSub 的 deps 链表尾部
if (!activeSub.deps) {
activeSub.deps = activeSub.depsTail = link
} else {
link.prevDep = activeSub.depsTail
activeSub.depsTail!.nextDep = link
activeSub.depsTail = link
}
addSub(link) // 挂到本 Dep 的 subs 链表(并把 computed 接入追踪)
} else if (link.version === -1) {
// 上一次 run 就用过这个 link,只是被 prepareDeps 置成了 -1 => 复用
link.version = this.version
// 若它不在尾部,把它挪到尾部,维持"访问顺序"
if (link.nextDep) { /* ... 双向链表挪位,见 dep.ts:134-150 ... */ }
}
if (__DEV__ && activeSub.onTrack) { /* 触发 onTrack */ }
return link
}
关键分支讲解:
activeSub === this.computed的跳过:这是"自读保护"。computed 求值时会把自己的dep当作"当前 dep"去 track(因为activeSub已切成自己),但this.dep.computed === this,所以要跳过,避免形成自订阅死循环。new Link分支:activeLink是"本 Dep 当前活跃的 link",本质是一个缓存槽。同一个 Dep 在同一时刻只会有一个活跃 link(同一个 sub 对不同 dep 各有一个 link)。version === -1复用分支:-1是prepareDeps的"待回收"标记(effect.ts:312-322),复用命中说明"本次 run 又用到了这个依赖",于是同步版本号并保序。
4.2.3 refreshComputed:四道闸门逐门走读
这是本章的"心脏函数",完整源码 packages/reactivity/src/effect.ts:376-430。我们把它拆成编号步骤:
export function refreshComputed(computed: ComputedRefImpl): undefined {
// 闸门 1:已接入订阅体系 且 不脏 => 直接返回缓存
if (
computed.flags & EffectFlags.TRACKING &&
!(computed.flags & EffectFlags.DIRTY)
) {
return
}
computed.flags &= ~EffectFlags.DIRTY // 先无条件清脏(乐观)
// 闸门 2:全局版本未变 => 一定没失效
if (computed.globalVersion === globalVersion) {
return
}
computed.globalVersion = globalVersion
// 闸门 3:非 SSR 且已求值过 且 依赖未变 => 返回缓存
if (
!computed.isSSR &&
computed.flags & EffectFlags.EVALUATED &&
((!computed.deps && !(computed as any)._dirty) || !isDirty(computed))
) {
return
}
// —— 到这里才真正求值 ——
computed.flags |= EffectFlags.RUNNING
const dep = computed.dep
const prevSub = activeSub
const prevShouldTrack = shouldTrack
activeSub = computed // 切换活跃订阅者为自己
shouldTrack = true
try {
prepareDeps(computed) // 依赖版本全部置 -1
const value = computed.fn(computed._value) // 执行用户 getter
if (dep.version === 0 || hasChanged(value, computed._value)) {
computed.flags |= EffectFlags.EVALUATED
computed._value = value
dep.version++ // 值变了才推进 dep 版本
}
} catch (err) {
dep.version++ // 抛错也推进,避免"卡住不更新"
throw err
} finally {
activeSub = prevSub
shouldTrack = prevShouldTrack
cleanupDeps(computed) // 回收未使用依赖
computed.flags &= ~EffectFlags.RUNNING
}
}
逐闸门说明
闸门 1(TRACKING && !DIRTY):这是"稳态快路径"。
TRACKING何时置位?见dep.ts:207-218的addSub:当一个 computed 的dep第一次获得订阅者时,computed.flags |= TRACKING | DIRTY。也就是说,"被读并有下游订阅"才是TRACKING的语义。- 稳态下(有人读、值没变),
DIRTY为 0,闸门 1 直接返回。这是 computed 缓存命中率最高的一条路,成本只有两次位与判断。
闸门 2(globalVersion):粗粒度失效检测。
globalVersion只在Dep.trigger()里自增(dep.ts:167-171)。注意trigger()只有"真的有人触发"时才走;trigger()函数在targetMap为空时会走globalVersion++但notify不执行(dep.ts:302-307)。- 若整图无变化,此闸门对所有 computed 都秒过。
闸门 3(EVALUATED + !isDirty):精粒度失效检测。
EVALUATED保证"至少算过一次"(否则没有_value可比)。!computed.deps && !_dirty分支:一个不依赖任何响应式数据的 computed(如computed(() => 42))只要算过一次,就应该永远返回缓存(#12337)。否则isDirty([])会返回 false 而误入重算——其实isDirty对空 deps 本就返回 false,这里显式写出!deps是为了"不依赖任何数据"这条语义更直白,同时兼容了_dirty手动标记。!isDirty(computed):真正去比对依赖版本(见 4.2.4)。
闸门 3 之后的求值块:
prepareDeps(computed):把computed.deps链上每个 link 的version置-1,并记录prevActiveLink,把dep.activeLink指向这个 link(effect.ts:312-322)。这样本轮 getter 里访问到的每个源 dep,都会在track()里命中"复用分支",把 version 改回真实值。hasChanged(value, computed._value):来自@vue/shared,处理了NaN与+0/-0的相等语义(Object.is的变体)。值没变就不推进dep.version——下游因此不会无谓重算。dep.version === 0 || ...:首次求值(version 还是 0)无条件视为"变化",因为初值_value = undefined可能恰好等于结果。
为什么 catch 分支也要 dep.version++?
如果 getter 抛错,_value 仍是旧值,但必须让下游知道"这次计算作废了",否则下游会一直用错误的旧值。推进版本 = 强制下游重算。
4.2.4 isDirty:依赖版本对账
// packages/reactivity/src/effect.ts:353-370
function isDirty(sub: Subscriber): boolean {
for (let link = sub.deps; link; link = link.nextDep) {
if (
link.dep.version !== link.version ||
(link.dep.computed &&
(refreshComputed(link.dep.computed) ||
link.dep.version !== link.version))
) {
return true
}
}
// @ts-expect-error only for backwards compatibility where libs manually set
// this flag - e.g. Pinia's testing module
if (sub._dirty) {
return true
}
return false
}
逐句拆:
-
link.dep.version !== link.version:这个依赖自上次被使用后版本变了 → 脏。 -
第二个条件处理 computed 的 computed(链式嵌套):
link.dep.computed存在说明这个依赖本身是个 computed(因为dep.computed是ComputedRefImpl的反向引用)。refreshComputed(link.dep.computed)会递归地去刷新上游 computed。这解决了"上游 computed 脏了但还没人读它"的情况——下游 computed 在判定自己是否脏时,顺手把上游算出来。refreshComputed返回真值(非 undefined)当且仅当它实际改变了dep.version吗?看源码:refreshComputed返回类型是undefined,正常路径返回 undefined(假值)。但注意||之后的link.dep.version !== link.version才是真正的兜底:即使 refresh 跑了,只要版本最终仍与记录的 link.version 不同,依然是脏。- 实际语义:
refreshComputed若刷新了上游,上游 dep.version 可能自增;此时link.dep.version !== link.version成立,返回真。
这里的短路
||有微妙之处:refreshComputed(...)作为左操作数——它是undefined(假),所以走右边比较;但它的副作用(刷新上游)已经发生。这就是"惰性求值向上传播"的实现方式。 -
sub._dirty:兼容逃生口。注释点名 Pinia 测试模块会手动设置_dirty。3.5 内部已不用这个字段,但保留读取以兼容生态。
4.2.5 惰性求值执行时序(首次读取)
sequenceDiagram
participant U as 用户/render
participant C as ComputedRefImpl
participant D as 源 Dep
U->>C: c.value
C->>C: dep.track() (登记下游,若在 effect 中)
C->>C: refreshComputed()
Note over C: 闸门1: TRACKING? 首次为0 -> 不返回
Note over C: 闸门2: globalVersion 不等 -> 继续
Note over C: 闸门3: EVALUATED 未置位 -> 继续
C->>C: flags |= RUNNING; activeSub = this
C->>C: prepareDeps (无 deps,空转)
C->>C: fn(_value) <-- 执行 getter
C->>D: get 源属性 -> D.track() 登记 computed 为自己的订阅者
D-->>C: 返回 link, deps 链表建立
C->>C: hasChanged? version=0 条件 -> _value=结果; dep.version++
C->>C: cleanupDeps; flags &= ~RUNNING
C-->>U: 返回 _value
对应旧版"首次执行"的差异:旧版是 new ReactiveEffect(getter) 然后 effect.run();新版是"把 activeSub 换成 computed 自身,直接调 fn"。少了一层 effect 对象与它的 run 栈,所以 3.5 的 computed 首次求值开销更小。
4.2.6 "手动 trackDep":新版如何记录 _deps
任务大纲里提到"手动 trackDep(_deps)",3.5 里这个动作不再是显式的手动循环,而是完全由 activeSub = computed + prepareDeps/cleanupDeps 自动完成。对比说明:
| 版本 | 依赖记录方式 |
|---|---|
| 3.4 | computed._deps = link(...),effect.run(),内部 ReactiveEffect 维护 deps;读取时手动 trackDep 与 cleanupDep |
| 3.5 | activeSub = computed,dep.track() 自动把 link 挂到 computed.deps 链表;prepareDeps / cleanupDeps 负责失效与回收 |
所以"手动 trackDep"在新版的等价物就是:prepareDeps(置 -1)+ getter 内的 dep.track()(复用或新建 link)+ cleanupDeps(回收 -1 的 link) 这套三段式。
4.2.7 一个可运行的缓存验证示例
// demo-4-2.mjs (在 Node 里跑,需先构建 @vue/reactivity 或使用 vite/ts-node)
import { ref, computed, effect } from '@vue/reactivity'
const a = ref(1)
let runs = 0
const c = computed(() => {
runs++
console.log('[computed] evaluate, a =', a.value)
return a.value * 2
})
console.log('--- 没人读时,computed 不跑 ---')
a.value = 2 // 只改源,不读 c
console.log('runs after change without read =', runs) // 0
console.log('--- 在 effect 中读取,进入稳态 ---')
let observed
const stop = effect(() => {
observed = c.value
console.log('[effect] observed =', observed)
})
// 输出:
// [computed] evaluate, a = 2
// [effect] observed = 4
console.log('runs =', runs) // 1
console.log('--- 重复读取命中缓存 ---')
c.value; c.value; c.value
console.log('runs after cached reads =', runs) // 仍是 1
console.log('--- 改值但不读,不重算 ---')
a.value = 3
console.log('runs after change, before read =', runs) // 仍是 1
console.log('--- 下游 effect 被调度,读取时才重算 ---')
// 注:effect 默认是 sync 生效;由于内部 effect 无 scheduler,
// 会在 endBatch 时立即 run,进而读 c.value 触发一次重算
// [computed] evaluate, a = 3
// [effect] observed = 6
stop()
console.log('final runs =', runs) // 2
控制台输出要点:
--- 没人读时,computed 不跑 ---
runs after change without read = 0
--- 在 effect 中读取,进入稳态 ---
[computed] evaluate, a = 2
[effect] observed = 4
runs = 1
--- 重复读取命中缓存 ---
runs after cached reads = 1
--- 改值但不读,不重算 ---
runs after change, before read = 1
--- 下游 effect 被调度,读取时才重算 ---
[computed] evaluate, a = 3
[effect] observed = 6
final runs = 2
这个例子实证了三条结论:
- 没有下游订阅者时,源变化不会触发 computed 求值(
runs保持 0)。 - 一旦有下游,源变化只是把 computed 标记为脏并调度下游,求值推迟到下游真正读取。
- 重复读取不重算,命中闸门 1。
4.3 computed 的依赖标记:_deps 与 Dep 的作用、链式嵌套、为何"没人读就不重算"
4.3.1 Dep 在 computed 体系中的四重职责
| 职责 | 相关字段/函数 | 说明 |
|---|---|---|
| 版本计数 | Dep.version | 每次 trigger +1,供 Link.version 对账 |
| 订阅者链表 | Dep.subs / subsHead / sc | 谁依赖我;subs 是尾,遍历时从尾向前 |
| computed 反向引用 | Dep.computed | addSub 据此做"惰性接入 + 级联订阅" |
| 属性级 GC | Dep.map / Dep.key | 当某属性无人订阅时,从 targetMap 的 Map 里删掉自己(effect.ts:463-469) |
其中 Dep.computed 是 computed 专属的"标记位"。普通属性 dep 的 computed 为 undefined;computed 的输出 dep 的 computed 指向该 ComputedRefImpl。
4.3.2 addSub:computed 的"惰性接入"才是缓存的关键
// packages/reactivity/src/dep.ts:207-232
function addSub(link: Link) {
link.dep.sc++
if (link.sub.flags & EffectFlags.TRACKING) {
const computed = link.dep.computed
// computed getting its first subscriber
// enable tracking + lazily subscribe to all its deps
if (computed && !link.dep.subs) {
computed.flags |= EffectFlags.TRACKING | EffectFlags.DIRTY
for (let l = computed.deps; l; l = l.nextDep) {
addSub(l) // 递归:把 computed 的每个输入依赖也真正订阅上
}
}
const currentTail = link.dep.subs
if (currentTail !== link) {
link.prevSub = currentTail
if (currentTail) currentTail.nextSub = link
}
if (__DEV__ && link.dep.subsHead === undefined) {
link.dep.subsHead = link
}
link.dep.subs = link
}
}
这是理解"没人读就不重算"最关键的一段。逐点拆:
link.sub.flags & TRACKING:只有"处于追踪态"的订阅者才真正入链。ReactiveEffect默认带TRACKING(effect.ts:101);一个被stop()的 effect 会清ACTIVE,虽然TRACKING未必清,但它的deps已断开,不会再被读到。computed && !link.dep.subs:!link.dep.subs表示"这个 computed 的输出 dep 此前没有任何订阅者"。此刻是"第一次有人订阅我"。computed.flags |= TRACKING | DIRTY:正式宣告"我上线了",并强制脏一次(保证首个订阅者读到的是最新值)。for (let l = computed.deps; l; l = l.nextDep) addSub(l):级联订阅。把自己此前算出来的输入依赖链接,逐个addSub到上游 Dep 的订阅者链里。从这一刻起,上游数据变化才会真正通知到这个 computed。- 注意:这里遍历的是 computed 在上一次求值时建立的
deps。所以顺序很重要——必须先求过值、有了deps,再被别人订阅,级联才有东西可挂。 - 若 computed 从未求值(
deps为空),级联是空转,但DIRTY已置位,下游读取时会触发一次真实求值并建立deps,此时 getter 内的dep.track()会走上游 Dep 的addSub(因为activeSub === computed且TRACKING已置位),把 computed 挂上。
- 注意:这里遍历的是 computed 在上一次求值时建立的
一句话:computed 与上游的连接是"懒惰且级联"的——只有当下游存在时,上游才真正获得这个 computed 作为订阅者。
4.3.3 不对称的订阅/退订:removeSub 的软退订
// packages/reactivity/src/effect.ts:432-470(节选)
function removeSub(link: Link, soft = false) {
// ...从 dep.subs 链摘除...
if (dep.subs === link) {
dep.subs = prevSub
if (!prevSub && dep.computed) {
// 该 computed 的最后一个订阅者走了 => 关闭追踪 + 软退订其全部上游
dep.computed.flags &= ~EffectFlags.TRACKING
for (let l = dep.computed.deps; l; l = l.nextDep) {
removeSub(l, true) // soft=true:不减少 dep.sc
}
}
}
if (!soft && !--dep.sc && dep.map) {
dep.map.delete(dep.key) // 属性 dep 无人订阅 => 从 targetMap 删除
}
}
- 当"没有任何下游读 computed"时,清掉
TRACKING,并把 computed 从上游 dep 的订阅者链里"软"摘除。 - 软(
soft=true)的含义:不执行--dep.sc,也不删dep.map里的条目。为什么?因为 computed 仍然保留着deps链表(引用着这些 Dep),只是不再收到通知。一旦它再次被订阅(addSub的级联),可以无损地重新挂上。这是一次"低成本冻结",而非彻底销毁。 - 硬(
soft=false)的含义:普通 effect 的 dep 彻底断开时,若该属性 dep 的订阅者计数归零,就把属性 dep 从targetMap里删掉(#11979),避免"对象在内存里但只用过部分属性"时 dep 泄漏。
这就解释了标题里的问题:
computed 不被读取时为何不重算? 因为下游订阅者的离去触发了
removeSub的软退订:computed 从上游 dep 的订阅者链里摘除,于是上游trigger()时遍历不到它,notify()根本不会被调用,自然也就不会重算。
4.3.4 computed 链式嵌套
考虑:
const a = ref(1)
const double = computed(() => a.value * 2)
const quad = computed(() => double.value * 4)
// 读取 quad
console.log(quad.value) // 4
建立过程:
sequenceDiagram
participant R as render/用户
participant Q as quad
participant D as double
participant A as a
R->>Q: quad.value
Q->>Q: dep.track(R) ; refreshComputed
Q->>Q: activeSub=Q
Q->>D: double.value
D->>D: dep.track(Q) (Q 订阅 double)
D->>D: refreshComputed ; activeSub=D
D->>A: a.value
A->>A: dep.track(D) (D 订阅 a)
D-->>Q: 2
Q-->>R: 8
关键点:
quad.deps只有一个 link,指向double.dep(不是a.dep!)。也就是说,quad的直接依赖是double的输出 dep。- 当
a变化:a.dep.trigger()→ notify → 遍历 a 的订阅者(此刻有double)→double.notify()返回true→double.dep.notify()→ 遍历double的订阅者(有quad)→quad.notify()→quad.dep.notify()→ 通知 render。 - 链式传播正是靠
dep.ts:193-199这段:
for (let link = this.subs; link; link = link.prevSub) {
if (link.sub.notify()) {
// if notify() returns `true`, this is a computed. Also call notify
// on its dep - it's called here instead of inside computed's notify
// in order to reduce call stack depth.
;(link.sub as ComputedRefImpl).dep.notify()
}
}
notify() 返回 true 的约定在 Subscriber 接口里写着:
// packages/reactivity/src/effect.ts:76-82
/**
* returning `true` indicates it's a computed that needs to call notify
* on its dep too
*/
notify(): true | void
为什么要把 dep.notify() 放在 Dep.notify 里调用,而不是 computed.notify 内部?
源码注释写明:"in order to reduce call stack depth"。如果每个 computed 在 notify() 里递归调用自己的 dep.notify(),那么 a → double → quad → ... N 层嵌套会形成 N 层同步调用栈(可能爆栈、且难以批处理);放在外层循环里做,则是"同一层循环 + 显式入队",栈深度恒定为 1。
4.3.5 递归读取的自我保护
ComputedRefImpl.notify():
// packages/reactivity/src/computed.ts:117-129
notify(): true | void {
this.flags |= EffectFlags.DIRTY
if (
!(this.flags & EffectFlags.NOTIFIED) &&
// avoid infinite self recursion
activeSub !== this
) {
batch(this, true)
return true
} else if (__DEV__) {
// TODO warn
}
}
- 先置
DIRTY:无论是否能入队,脏标记一定要打上——这是"值可能变了"的唯一信号。 !(flags & NOTIFIED):本次批处理里已经入过队,不重复入队(batch会置NOTIFIED)。activeSub !== this:自递归保护。如果 computed 正在求值自己(activeSub === this),却又被自己 notify,就跳过入队。经典场景:const c = computed(() => c.value + 1)——这种自引用在求值中会读到c.value,触发 track 被activeSub === this.computed跳过(不订阅),但 notify 路径仍需保护。- 返回
true:告诉Dep.notify需要级联通知自己的输出 dep。
else if (__DEV__) 里注释是 // TODO warn,说明"自递归未入队"在 dev 下的告警仍待补全——这是 3.5.44 里一处已知的 TODO。
4.4 computed 的"调度"与 EffectFlags.NOTIFIED / dirty 的联动;只读 vs 可写
4.4.1 先纠正一个常见误解:3.5 的 computed 没有 scheduler
很多资料(包括本任务大纲的措辞)会说"computed 的 scheduler"。准确地说:
ReactiveEffect有scheduler字段(effect.ts:111),trigger()时会调用它(effect.ts:206-214)。ComputedRefImpl没有scheduler字段。computed 的"调度"不是"延迟执行",而是位标志驱动的批处理 + 通知级联。
那 computed 到底"何时跑"?答案是:只有当某个下游读取它的 value 且判定它脏时,同步跑。上游变化对 computed 唯一做的事,是把 DIRTY 置位、把"通知继续往上/往下传"这件事入批处理队列。这就是 3.5 用 flags 取代 _dirty 布尔之后最本质的行为变化:computed 永远同步求值,但通知是异步批量化的。
4.4.2 notify 链路:从源变化到下游被调度
以 computed 为例,一次源变化的完整链路:
flowchart TD
S["source.value = x"] --> T["dep.trigger()"]
T --> T1["dep.version++"];
T --> T2["globalVersion++"];
T --> N["dep.notify()"]
N --> SB["startBatch() batchDepth++"]
N --> L["遍历 subs 链表(从尾向前)"]
L --> CN["computed.notify()"]
CN --> CN1["flags |= DIRTY"]
CN --> CN2{"NOTIFIED? 或 activeSub===this?"}
CN2 -->|否| BT["batch(this, true) 入 batchedComputed 链"]
BT --> RET["返回 true"]
RET --> DN["computed.dep.notify() (级联)"]
DN --> L2["遍历 computed 的订阅者"]
L2 --> EF["ReactiveEffect.notify()"]
EF --> B2["batch(this) 入 batchedSub 链"]
N --> E["endBatch()"]
E --> CL["清 batchedComputed 的 NOTIFIED"]
E --> TR["遍历 batchedSub: effect.trigger() -> scheduler"]
几个必须讲清楚的细节:
(1)batch 置 NOTIFIED、endBatch 清 NOTIFIED
// packages/reactivity/src/effect.ts:251-260
export function batch(sub: Subscriber, isComputed = false): void {
sub.flags |= EffectFlags.NOTIFIED
if (isComputed) { sub.next = batchedComputed; batchedComputed = sub; return }
sub.next = batchedSub
batchedSub = sub
}
NOTIFIED 的语义是"本次批处理里我已经被通知过",用于去重。一次修改多个源(如同一函数里改 a 与 b)时,下游只会被入队一次。
清 NOTIFIED 发生在 endBatch:
// packages/reactivity/src/effect.ts:273-310(节选)
export function endBatch(): void {
if (--batchDepth > 0) return // 嵌套批未结束
if (batchedComputed) {
let e = batchedComputed
batchedComputed = undefined
while (e) {
const next = e.next
e.next = undefined
e.flags &= ~EffectFlags.NOTIFIED // computed 只清标志,不 trigger
e = next
}
}
let error
while (batchedSub) {
let e = batchedSub
batchedSub = undefined
while (e) {
const next = e.next
e.next = undefined
e.flags &= ~EffectFlags.NOTIFIED
if (e.flags & EffectFlags.ACTIVE) {
try { (e as ReactiveEffect).trigger() } catch (err) { if (!error) error = err }
}
e = next
}
}
if (error) throw error
}
为什么要等所有批结束(--batchDepth > 0)才真正 trigger?
因为一次状态变更往往嵌套调用多个 dep.trigger()、每个 trigger 内部都 startBatch/endBatch。如果内层 endBatch 就执行 effect,会出现"a 改了跑一次、b 改了又跑一次"的抖动(glitch),下游读到中间态。批深度机制把"全部改完再统一跑"作为默认语义。
(2)batchedComputed 与 batchedSub 分开的两条链
batchedComputed:入链的 computed 只清NOTIFIED,不trigger(computed 没有 scheduler,trigger对它无意义)。batchedSub:入链的 effect 逐个trigger(),进而走各自的scheduler或runIfDirty()。
(3)trigger() 与 PAUSED
// packages/reactivity/src/effect.ts:206-214
trigger(): void {
if (this.flags & EffectFlags.PAUSED) {
pausedQueueEffects.add(this) // 暂停中:只登记,等 resume 再补跑
} else if (this.scheduler) {
this.scheduler()
} else {
this.runIfDirty()
}
}
PAUSED 由 effect.pause() 设置(effect.ts:133-135)。它的经典用途是 watch 的 pause/resume(见 4.9),以及在 flushPreFlushCbs 期间临时屏蔽某些 effect。
4.4.3 EffectFlags 在 computed 上的联动总表
| 标志 | 谁置位 | 何时清 | 作用 |
|---|---|---|---|
DIRTY | 初始;notify()(computed.ts:118);addSub 首次订阅(dep.ts:214) | refreshComputed 开头 & ~DIRTY(effect.ts:383) | 标记"需重算" |
TRACKING | addSub 首次有订阅者(dep.ts:214) | removeSub 最后一个订阅者离开(effect.ts:454) | 标记"已接入订阅体系" |
NOTIFIED | batch() | endBatch() | 本次批处理去重 |
RUNNING | refreshComputed 求值前(effect.ts:405) | 求值 finally(effect.ts:428) | 防自递归 notify/track |
EVALUATED | 首次成功求值(effect.ts:417) | 从不清除 | 闸门 3 的"算过至少一次"依据 |
注意 DIRTY 的悲观清除:refreshComputed 在闸门 1 之后立刻 & ~DIRTY(effect.ts:383),不管后面是否真的重算。为什么可以这么乐观?因为没有重算就意味着"经判定确实是缓存有效",此时脏标记本就该清。倘若后来上游又变了,notify 会再次置脏——脏标记总是由"最新一次通知"负责,所以清除是安全的。
4.4.4 只读 computed vs 可写 computed
构造层差异
// packages/reactivity/src/computed.ts:198-221
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
debugOptions?: DebuggerOptions,
isSSR = false,
) {
let getter: ComputedGetter<T>
let setter: ComputedSetter<T> | undefined
if (isFunction(getterOrOptions)) {
getter = getterOrOptions // 只读形式
} else {
getter = getterOrOptions.get // 可写形式
setter = getterOrOptions.set
}
const cRef = new ComputedRefImpl(getter, setter, isSSR)
if (__DEV__ && debugOptions && !isSSR) {
cRef.onTrack = debugOptions.onTrack
cRef.onTrigger = debugOptions.onTrigger
}
return cRef as any
}
| 维度 | 只读 computed | 可写 computed |
|---|---|---|
| 入参 | 一个函数 | { get, set } 对象 |
setter 字段 | undefined | 用户函数 |
__v_isReadonly | true(!setter) | false |
| 类型 | ComputedRef<T>(value 只读) | WritableComputedRef<T,S> |
| 写入行为 | dev 下 warn('...computed value is readonly') | 调用 setter(newValue) |
| 是否参与依赖追踪 | 一样 | 完全没有额外追踪 |
写入路径
// packages/reactivity/src/computed.ts:147-153
set value(newValue) {
if (this.setter) {
this.setter(newValue)
} else if (__DEV__) {
warn('Write operation failed: computed value is readonly')
}
}
关键认知:set value 不触碰 _value、不置 DIRTY、不 notify。它只是把值交给用户 setter;setter 内部通常会去改源数据,于是源数据的 dep 触发,把 computed 标记为脏,读时才重算。也就是说,可写 computed 的"写"本质是"转发给源",而不是"自己保存一个可写值"。
这带来一个经典陷阱:
const count = ref(1)
const plusOne = computed({
get: () => count.value + 1,
set: val => { count.value = val - 1 },
})
plusOne.value = 1
console.log(count.value) // 0
console.log(plusOne.value) // 1 <- 因为 count 变了,computed 被标脏,读时重算
如果 setter 里没有改任何被 getter 读取的响应式数据:
const a = ref(1)
const w = computed({ get: () => a.value, set: v => { /* 什么都不做 */ } })
w.value = 999
console.log(w.value) // 仍是 1,因为 a 未变,computed 未脏
这是"写了不生效"的根因:写只影响 setter 的副作用,不影响缓存值。
dev 下的 _warnRecursive 与递归 computed
可写 computed 易与"读写同一 computed"耦合出递归。apiComputed.ts 在 warnRecursiveComputed 打开时会设 _warnRecursive(apiComputed.ts:10-14)。refreshComputed 里在检测到"求值期间再次触发自身"时会告警(3.5.44 中该告警的主体逻辑挂在 dev 分支,computed.ts:126-128 仍留 // TODO warn 占位)。
一个会触发递归告警的典型错误写法:
const state = reactive({ n: 1 })
const doubled = computed({
get: () => state.n * 2,
set: v => { state.n = v * 2 }, // 若 getter/setter 不自洽,读写会互相放大
})
4.4.5 小结
a.value = 2 与 b.value = 20 若写成两次独立的赋值语句,每次都自成一个 startBatch/endBatch 周期,下游会被调度两次;只有放进同一个 batch 上下文才会合并为一次。这解释了"改多个源有时触发两次 watch"的困惑(见 4.12.3),也印证 4.4.1:computed 没有 scheduler,它只负责"置脏 + 通知下游的 scheduler"。
4.5 watch / watchEffect 的构造:doWatch 与 ReactiveEffect、getter 归一化、WATCH_EFFECT 差异
4.5.1 两个入口、一个内核
runtime-core 的公开 API 有四个:watch、watchEffect、watchPostEffect、watchSyncEffect。
// packages/runtime-core/src/apiWatch.ts:56-87
export function watchEffect(effect: WatchEffect, options?: WatchEffectOptions): WatchHandle {
return doWatch(effect, null, options)
}
export function watchPostEffect(effect: WatchEffect, options?: DebuggerOptions): WatchHandle {
return doWatch(effect, null, __DEV__ ? extend({}, options, { flush: 'post' }) : { flush: 'post' })
}
export function watchSyncEffect(effect: WatchEffect, options?: DebuggerOptions): WatchHandle {
return doWatch(effect, null, __DEV__ ? extend({}, options, { flush: 'sync' }) : { flush: 'sync' })
}
// watch(source, cb, options) -> doWatch(source, cb, options)
四个入口全部收敛到 doWatch(source, cb, options)(apiWatch.ts:146-245)。cb == null 就是 watchEffect 语义;cb 存在就是 watch 语义。这个"统一内核"设计的收益是:只有一套 effect 创建、调度、生命周期逻辑,四种 API 的差异只在"getter 形态"和"flush 值"上。
再往下,doWatch 又把核心逻辑委托给 @vue/reactivity 的 baseWatch:
// packages/runtime-core/src/apiWatch.ts:234
const watchHandle = baseWatch(source, cb, baseWatchOptions)
baseWatch 就是 packages/reactivity/src/watch.ts:120 的 watch。这一层拆分是 3.5 的架构决策:把与框架无关的 watch 内核下沉到 @vue/reactivity,runtime-core 只负责"组件上下文相关"的部分(instance 关联、flush 队列、错误处理、SSR、scope 注册)。
4.5.2 调用层次图
graph TD
W1["watch(source, cb, opts)"] --> DW["doWatch (runtime-core)"]
W2["watchEffect(fn, opts)"] --> DW
W3["watchPostEffect/watchSyncEffect"] --> DW
DW -->|组装 baseWatchOptions| BW["baseWatch (reactivity)"]
BW -->|new ReactiveEffect getter| RE["ReactiveEffect"]
BW -->|effect.scheduler = ...| SCH["scheduler: job"]
DW -->|baseWatchOptions.scheduler| Q1["queueJob (pre)"]
DW -->|baseWatchOptions.scheduler| Q2["queuePostRenderEffect (post)"]
DW -->|augmentJob| FL["SchedulerJobFlags.PRE / ALLOW_RECURSE"]
RE -->|track| DEP["Dep"]
DEP -->|notify -> batch| EB["endBatch -> effect.trigger -> scheduler"]
分工一览:
| 关注点 | 归属 |
|---|---|
| 源归一化(ref/reactive/数组/getter) | reactivity/watch.ts |
getter 的 call 包装(错误处理) | runtime-core/apiWatch.ts |
flush → scheduler | runtime-core/apiWatch.ts(组装)落到 reactivity/watch.ts(赋值) |
job 的 flags(PRE/ALLOW_RECURSE)与 id | runtime-core/apiWatch.ts 的 augmentJob |
| instance 关联、SSR、scope 注册 | runtime-core/apiWatch.ts + reactivity/watch.ts 的 getCurrentScope() |
| cleanup 栈 | reactivity/watch.ts |
4.5.3 getter 归一化:五种源类型
baseWatch 的源归一化(packages/reactivity/src/watch.ts:153-205):
if (isRef(source)) {
getter = () => source.value
forceTrigger = isShallow(source)
} else if (isReactive(source)) {
getter = () => reactiveGetter(source)
forceTrigger = true
} else if (isArray(source)) {
isMultiSource = true
forceTrigger = source.some(s => isReactive(s) || isShallow(s))
getter = () => source.map(s => {
if (isRef(s)) return s.value
else if (isReactive(s)) return reactiveGetter(s)
else if (isFunction(s)) return call ? call(s, WatchErrorCodes.WATCH_GETTER) : s()
else { __DEV__ && warnInvalidSource(s) }
})
} else if (isFunction(source)) {
if (cb) {
// getter with cb
getter = call ? () => call(source, WatchErrorCodes.WATCH_GETTER) : source
} else {
// no cb -> watchEffect
getter = () => { /* cleanup + activeWatcher 包装,见 4.8 */ }
}
} else {
getter = NOOP
__DEV__ && warnInvalidSource(source)
}
逐一说明:
(1)ref 源:getter = () => source.value。forceTrigger = isShallow(source)——浅 ref 只追踪 .value 本身,若 .value 是对象,其内部属性变化不触发;但浅 ref 的"赋值动作"必须强制触发(因为值虽然引用相同也可能想重跑)。对普通 ref,forceTrigger = false,靠 hasChanged 比较。
(2)reactive 对象源:getter = () => reactiveGetter(source),且 forceTrigger = true。这是关键:watch 一个 reactive 对象,默认就是深度监听。
// packages/reactivity/src/watch.ts:136-144
const reactiveGetter = (source: object) => {
if (deep) return source // 显式 deep:外层遍历交给包装 getter
if (isShallow(source) || deep === false || deep === 0)
return traverse(source, 1) // 浅/显式 false/0:只遍历一层
return traverse(source) // 默认:深度遍历
}
(3)数组源:isMultiSource = true,逐个元素归一化。forceTrigger 取"任一元素是 reactive 或 shallow"。
(4)函数源 + cb:直接当 getter;若有 call(runtime-core 注入的错误处理包装),包一层。
(5)函数源 + 无 cb(watchEffect):getter 会在每次执行前先跑 cleanup、并设置 activeWatcher(细节见 4.8)。
(6)非法源:NOOP + dev 告警。
4.5.4 deep 的二次包装
// packages/reactivity/src/watch.ts:207-211
if (cb && deep) {
const baseGetter = getter
const depth = deep === true ? Infinity : deep
getter = () => traverse(baseGetter(), depth)
}
- 只有
watch(source, cb, { deep })会走到这里;watchEffect的deep选项被忽略(因为 watchEffect 的 getter 本身就是"整段副作用",自然追踪所有读到的属性,无需 traverse)。 deep: true→Infinity;deep: number→ 指定遍历深度(3.5 新增的deep: 1表示只遍历一层)。- 注意
reactiveGetter里if (deep) return source——当显式deep时,reactiveGetter 不做遍历(返回原对象),把遍历职责交给这个包装 getter。避免双重遍历。
4.5.5 ReactiveEffect 的创建与 scheduler
// packages/reactivity/src/watch.ts:288-310
if (augmentJob) {
augmentJob(job) // 此时 effect 还未创建;runtime-core 用它给 job 打 flags/id
}
effect = new ReactiveEffect(getter)
effect.scheduler = scheduler
? () => scheduler(job, false)
: (job as EffectScheduler)
boundCleanup = fn => onWatcherCleanup(fn, false, effect)
cleanup = effect.onStop = () => {
const cleanups = cleanupMap.get(effect)
if (cleanups) {
if (call) call(cleanups, WatchErrorCodes.WATCH_CLEANUP)
else for (const cleanup of cleanups) cleanup()
cleanupMap.delete(effect)
}
}
要点:
new ReactiveEffect(getter):因为构造时若存在activeEffectScope,会自动activeEffectScope.effects.push(this)(effect.ts:117-123)。这就是"watch 被 scope 捕获"的机制(详见 4.10)。effect.scheduler:这是 watch 与普通 effect 的分水岭。- 无
scheduler(watch无 flush 选项,即sync或旧式用法)→job直接作为 scheduler。 - 有
scheduler(pre/post)→ 包一层() => scheduler(job, false),isFirstRun = false。
- 无
augmentJob的调用时机很反直觉:它在new ReactiveEffect之前被调用(watch.ts:288-290),但参数job是稍后才会用到的函数——因为job是闭包,先定义、后由augmentJob打标、最后才赋给effect.scheduler。这个顺序在apiWatch.ts:219-232的augmentJob里体现为"给job.flags加 PRE/ALLOW_RECURSE、给job.id/job.i赋值"。
4.5.6 WATCH_EFFECT 标志与 EffectFlags 的差异
任务大纲提到 "WATCH_EFFECT 标志"。需要澄清:3.5.44 的 watch.ts 里没有 WATCH_EFFECT 标志(这是 @vue/compat / Vue 2 或更早版本的遗留概念)。3.5 用两个独立机制区分 watch:
cb是否存在(reactivity/watch.ts内以闭包变量区分 job 的两条分支,watch.ts:243-285)。SchedulerJobFlags(runtime-core 侧):PRE(1<<1):标记这是一个 pre-flush watcher job。ALLOW_RECURSE(1<<2):标记这个 job 允许自触发。QUEUED/DISPOSED。
对照表(两者的差异):
| 维度 | watch(有 cb) | watchEffect(无 cb) |
|---|---|---|
| 默认 flush | pre | pre |
| 首次执行 | 立即 effect.run() 取 oldValue(除非 immediate) | 立即 effect.run() |
| job 内部 | effect.run() 取新值 → 比较 → 满足条件才调 cb | 直接 effect.run() |
ALLOW_RECURSE | 会打(apiWatch.ts:222-224) | 不打 |
deep 选项 | 生效 | 不生效(被忽略并 dev 告警) |
immediate | 生效 | 不生效(dev 告警) |
once | 生效 | 不生效 |
| 新旧值 | 提供 newValue/oldValue | 无 |
ALLOW_RECURSE 为什么只给 cb 版?
源码注释解释得很清楚(scheduler.ts:8-22):watch 回调不追踪它自己的依赖(回调里的读操作不在 effect 的 run 上下文里),所以如果它再次触发自己,"likely intentional",由用户负责让状态收敛;而组件更新函数与 watchEffect 的 getter 会追踪依赖,自触发极易造成无限循环,因此默认禁止。
4.5.7 job 的两条分支逐行
// packages/reactivity/src/watch.ts:236-286
const job = (immediateFirstRun?: boolean) => {
if (
!(effect.flags & EffectFlags.ACTIVE) ||
(!effect.dirty && !immediateFirstRun)
) {
return // 已停止 或 不脏且非首次 => 跳过
}
if (cb) {
// ---- watch(source, cb) ----
const newValue = effect.run()
if (
immediateFirstRun ||
deep ||
forceTrigger ||
(isMultiSource
? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
: hasChanged(newValue, oldValue))
) {
const currentWatcher = activeWatcher
pauseTracking() // 回调执行期间不追踪
try {
if (cleanup) cleanup() // 先跑上一次注册的 cleanup
activeWatcher = effect
const args = [
newValue,
oldValue === INITIAL_WATCHER_VALUE ? undefined
: isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE ? []
: oldValue,
boundCleanup,
]
oldValue = newValue
call ? call(cb!, WatchErrorCodes.WATCH_CALLBACK, args) : cb!(...args)
} finally {
activeWatcher = currentWatcher
resetTracking()
}
}
} else {
// ---- watchEffect ----
effect.run()
}
}
逐点拆:
- 开头的双重守卫(
watch.ts:237-242):!(flags & ACTIVE):effect 已被stop(),jobs 即使入队也应丢弃。!effect.dirty && !immediateFirstRun:关键的"防误触发"。effect.dirty走isDirty(effect.ts:225-227),检查依赖版本是否真的变了。若上游触发了 notify 但版本检查发现"其实没变"(比如赋了相同的值),就跳过。immediateFirstRun为 true 时绕过此检查(首次必须跑)。
newValue = effect.run():重新执行 getter,得到新值。注意effect.run()内部会cleanupEffect(effect.ts:171),会先跑effect.cleanup(属于onEffectCleanup的,与 watch 的 cleanup 栈是两套机制,见 4.8)。- 触发条件:
immediateFirstRun || deep || forceTrigger || hasChanged(...)。deep为真时无条件触发(因为 traverse 返回的对象引用永远相同,hasChanged必然为 false,若不强行触发就永远不回调——这是 4.7 要讲的"reactive 对象新旧值相同的陷阱")。forceTrigger同理(shallow ref / reactive 源)。
pauseTracking():回调执行期间关闭依赖追踪。否则回调里读到的响应式属性会被错误地登记进这个 effect 的deps,导致"回调读什么就 watch 什么"的诡异行为。这是新手最常见的隐式陷阱之一。cleanup()先跑:所以"上一次的 cleanup 在本轮 cb 之前"执行(4.8 详述)。activeWatcher = effect:让回调内调用onWatcherCleanup能挂到正确的 effect 上。- oldValue 的三态:
INITIAL_WATCHER_VALUE→ 传undefined(首次,无旧值)。- 多源且
oldValue[0] === INITIAL_WATCHER_VALUE→ 传[]。 - 否则传真实的
oldValue。
oldValue = newValue在调 cb 之前赋值:意味着 cb 内若同步再次触发(ALLOW_RECURSE允许),oldValue已经是新的了。
4.5.8 初始化执行的四种分支
// packages/reactivity/src/watch.ts:317-328
// initial run
if (cb) {
if (immediate) {
job(true) // 立即执行一次回调(immediateFirstRun = true)
} else {
oldValue = effect.run() // 只求值,不调回调;结果记为 oldValue
}
} else if (scheduler) {
scheduler(job.bind(null, true), true) // watchEffect + 自定义 scheduler
} else {
effect.run() // watchEffect(sync/默认)
}
对照表:
| 场景 | 初始化行为 |
|---|---|
watch(source, cb)(默认 lazy) | effect.run() 取初值存 oldValue,不调 cb |
watch(source, cb, { immediate: true }) | job(true),立即调一次 cb(oldValue 为 undefined) |
watchEffect 无 scheduler | effect.run() 立即执行副作用 |
watchEffect 有 scheduler(pre/post) | scheduler(job.bind(null, true), true),isFirstRun = true |
watchPostEffect | 走 post scheduler,但 runsImmediately 为 false(apiWatch.ts:179),SSR 下会返回空 handle |
注意 watchPostEffect 的 runsImmediately:
// packages/runtime-core/src/apiWatch.ts:178-179
// immediate watcher or watchEffect
const runsImmediately = (cb && immediate) || (!cb && flush !== 'post')
即:post 的 watchEffect 不立即执行,而是排队到 post 队列;这是"DOM 更新后再读"的语义要求。
4.5.9 watch 的生命周期 handle
// packages/reactivity/src/watch.ts:213-219
const scope = getCurrentScope()
const watchHandle: WatchHandle = () => {
effect.stop()
if (scope && scope.active) {
remove(scope.effects, effect)
}
}
// packages/reactivity/src/watch.ts:330-334
watchHandle.pause = effect.pause.bind(effect)
watchHandle.resume = effect.resume.bind(effect)
watchHandle.stop = watchHandle
scope = getCurrentScope()在创建 watch 时捕获当前 scope(组件 setup 里就是组件 scope)。stop()时把 effect 从 scope.effects 里移除,避免 scope 再对已停的 effect 做stop()。pause/resume直接代理 effect 的。once通过包装 cb 实现(watch.ts:221-230):在 cb 的finally里调用watchHandle()。
4.6 flush 语义:pre / post / sync 三种时机与调度队列
4.6.1 三种 flush 的语义定义
| flush | 语义 | 何时执行回调 | 默认用于 |
|---|---|---|---|
pre | 组件更新前 | 在本组件 render 之前,同一次 flush 的 pre 阶段 | watch / watchEffect 默认 |
post | 组件更新后 | render 完成、DOM patch 之后 | watchPostEffect、需要读 DOM 的场景 |
sync | 同步,源一变就跑 | 在 endBatch 遍历 batchedSub 时立即执行 | watchSyncEffect、需即时响应 |
pre 与 post 都是异步的(推迟到微任务 flush),只有 sync 是同步。
4.6.2 runtime-core 侧如何把 flush 变成 scheduler
// packages/runtime-core/src/apiWatch.ts:198-217
let isPre = false
if (flush === 'post') {
baseWatchOptions.scheduler = (job: SchedulerJob) => {
// mark the owner (or its absence) so a root mount can tell the watcher
// from its own post cbs. Set here as the first run queues a bound copy.
job.i = instance
queuePostRenderEffect(job, instance && instance.suspense)
}
} else if (flush !== 'sync') {
// default: 'pre'
isPre = true
baseWatchOptions.scheduler = (job, isFirstRun) => {
if (isFirstRun) {
job() // 首次立即跑(watchEffect 的初始化)
} else {
queueJob(job) // 后续走 pre 队列
}
}
}
post:调用queuePostRenderEffect(job, suspense),它最终落到queuePostFlushCb或 suspense 缓冲(renderer.ts:297-306)。pre:新增isFirstRun分支——首次同步执行(让 watchEffect 立即生效),之后才入 pre 队列。sync:不设置 scheduler。于是reactivity/watch.ts:294-296的effect.scheduler退化为job本身,effect.trigger()时直接同步调job()。
4.6.3 augmentJob:给 job 打上 PRE 与 owner
// packages/runtime-core/src/apiWatch.ts:219-232
baseWatchOptions.augmentJob = (job: SchedulerJob) => {
// important: mark the job as a watcher callback so that scheduler knows
// it is allowed to self-trigger (#1727)
if (cb) {
job.flags! |= SchedulerJobFlags.ALLOW_RECURSE
}
if (isPre) {
job.flags! |= SchedulerJobFlags.PRE
if (instance) {
job.id = instance.uid // 与组件更新 job 同 id
job.i = instance
}
}
}
job.id = instance.uid 是调度顺序的核心设计。它使得:
- pre watcher 与它所属组件的更新 job 拥有相同的 id。
findInsertionIndex(scheduler.ts:79-98)在同 id 时,代码里middleJobId === id && middleJob.flags & PRE会让 watcher 插到更新 job 之前。这保证了"watch 回调先于组件重新渲染"。
// packages/runtime-core/src/scheduler.ts:79-98
function findInsertionIndex(id: number) {
let start = flushIndex + 1
let end = queue.length
while (start < end) {
const middle = (start + end) >>> 1
const middleJob = queue[middle]
const middleJobId = getId(middleJob)
if (
middleJobId < id ||
(middleJobId === id && middleJob.flags! & SchedulerJobFlags.PRE)
) {
start = middle + 1
} else {
end = middle
}
}
return start
}
4.6.4 SchedulerJobFlags 全表
// packages/runtime-core/src/scheduler.ts:5-25
export enum SchedulerJobFlags {
QUEUED = 1 << 0,
PRE = 1 << 1,
ALLOW_RECURSE = 1 << 2,
DISPOSED = 1 << 3,
}
| 标志 | 含义 | 谁设置 |
|---|---|---|
QUEUED | 已在主队列中,去重用 | queueJob(scheduler.ts:114) |
PRE | pre 阶段任务,按 id 排序插队 | augmentJob |
ALLOW_RECURSE | 允许自触发 | augmentJob(仅 cb 版 watch);组件更新 job 也会打 |
DISPOSED | 已废弃,flush 时跳过 | 卸载/DISPOSED 标记 |
4.6.5 getId:缺省 id 的排序语义
// packages/runtime-core/src/scheduler.ts:270-271
const getId = (job: SchedulerJob): number =>
job.id == null ? (job.flags! & SchedulerJobFlags.PRE ? -1 : Infinity) : job.id
- 无 id 的 pre job(如无组件归属的
watchEffect)→-1,排最前。 - 无 id 的 非 pre job →
Infinity,排最后。 - 有 id 组件 job → 用
uid,父先子后。
4.6.6 pre 队列 vs post 队列的执行位置
flushJobs() 的结构(scheduler.ts:273-327):
function flushJobs(seen?: CountMap) {
const check = __DEV__ ? (job) => checkRecursiveUpdates(seen!, job) : NOOP
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && !(job.flags! & SchedulerJobFlags.DISPOSED)) {
if (__DEV__ && check(job)) continue
if (job.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
job.flags! &= ~SchedulerJobFlags.QUEUED
}
callWithErrorHandling(job, job.i, job.i ? ErrorCodes.COMPONENT_UPDATE : ErrorCodes.SCHEDULER)
if (!(job.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
job.flags! &= ~SchedulerJobFlags.QUEUED
}
}
}
} finally {
for (; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job) job.flags! &= ~SchedulerJobFlags.QUEUED
}
flushIndex = -1
queue.length = 0
flushPostFlushCbs(seen) // <-- post 队列在这里
currentFlushPromise = null
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen) // <-- 若又产生新 job,递归继续
}
}
}
关键点:
- 主队列
queue里是组件更新 job 和 pre watcher job,按 id 升序(queueJob的二分插入)混排。 - 主队列遍历完后才
flushPostFlushCbs()——post 天然在所有 pre 之后。 - 组件更新本身也走 pre 阶段:组件 render effect 的 job 没有
PRE标记,但它在主队列里,其 id 决定它与其他 pre watcher 的相对顺序。因为 pre watcher 与同组件同 id 且PRE标志使其插在更新 job 前,所以相对顺序是"本组件的 pre watcher → 本组件 render → ..."。 currentFlushPromise是nextTick()的依据;flush 结束后置 null。
4.6.7 组件更新 job 与 pre watcher 的关系图
flowchart LR
subgraph 主队列 queue
A["pre watch job (id=uid)"]
B["component update job (id=uid)"]
C["另一个组件 pre job (id=uid2)"]
D["另一个组件 update (id=uid2)"]
end
A --> B --> C --> D
D --> P["flushPostFlushCbs: post 队列"]
图中顺序是排序后的理想态:同 id 内 pre 在前更新在后;不同 id 按 uid 升序(父组件 uid 小,故父先于子)。
4.6.8 一个可运行的 flush 顺序验证
// demo-4-6.mjs(需在带 DOM 的环境跑;这里用 runtime-core 概览写法)
import { createApp, h, ref } from 'vue'
const app = createApp({
setup() {
const n = ref(0)
// pre(默认)
watch(n, () => console.log('[pre] watch cb, n =', n.value))
// post
watchPostEffect(() => console.log('[post] effect, n =', n.value))
// sync
watchSyncEffect(() => console.log('[sync] effect, n =', n.value))
return () => h('div', n.value)
},
})
app.mount('#app')
// 触发一次更新
// 预期输出顺序:
// [sync] effect, n = 0 (初始化立即执行)
// [pre] watch cb ... (immediate 时/变更时)
// ... 组件 render ...
// [post] effect, n = 0 (初始化 post)
真实控制台输出(关键几条):
[sync] effect, n = 0
[pre] watch cb, n = 0 // 若 immediate: true
[post] effect, n = 0
--- 之后设置 n.value = 1 ---
[sync] effect, n = 1 // 同步先跑
[pre] watch cb, n = 1 // 微任务 flush 的 pre 阶段
[post] effect, n = 1 // post 阶段最后
顺序结论:sync 在赋值语句里立刻跑;pre 在微任务 flush 的 pre 阶段跑;post 在 render 之后跑。三者相对顺序是 sync → pre → (render) → post。
4.6.9 SSR 下的 flush 特殊处理
// packages/runtime-core/src/apiWatch.ts:180-192
let ssrCleanup: (() => void)[] | undefined
if (__SSR__ && isInSSRComponentSetup) {
if (flush === 'sync') {
const ctx = useSSRContext()!
ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = [])
} else if (!runsImmediately) {
const watchStopHandle = () => {}
watchStopHandle.stop = NOOP
watchStopHandle.resume = NOOP
watchStopHandle.pause = NOOP
return watchStopHandle // 不注册任何 effect,直接返回空 handle
}
}
SSR 里:
syncwatcher 会被收集到ssrContext.__watcherHandles,组件卸载时统一 stop。post+ 非立即执行的 watchEffect 在 SSR 下完全被"空实现"——因为服务端没有 DOM 也没有 post 队列,注册它毫无意义。这也是"SSR 中 post flush watchEffect 不执行"的源码级解释。
4.7 源类型与新旧值:deep 遍历 traverse()、immediate、once、多源、reactive 的"新旧值相同"陷阱
4.7.1 traverse():深度遍历如何变成依赖
// packages/reactivity/src/watch.ts:337-372
export function traverse(
value: unknown,
depth: number = Infinity,
seen?: Map<unknown, number>,
): unknown {
if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
return value
}
seen = seen || new Map()
if ((seen.get(value) || 0) >= depth) {
return value
}
seen.set(value, depth)
depth--
if (isRef(value)) {
traverse(value.value, depth, seen)
} else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverse(value[i], depth, seen)
}
} else if (isSet(value) || isMap(value)) {
value.forEach((v: any) => {
traverse(v, depth, seen)
})
} else if (isPlainObject(value)) {
for (const key in value) {
traverse(value[key], depth, seen)
}
for (const key of Object.getOwnPropertySymbols(value)) {
if (Object.prototype.propertyIsEnumerable.call(value, key)) {
traverse(value[key as any], depth, seen)
}
}
}
return value
}
逐点拆解:
(1)三个提前返回条件
depth <= 0:到达深度上限,停止。!isObject(value):原始值不遍历(isObject来自@vue/shared,null不算对象)。value[ReactiveFlags.SKIP]:EffectScope、Dep等内部对象带__v_skip,跳过以防误遍历内部结构。这是"库内部对象"的免疫标记。
(2)seen 与深度记账:防循环引用
seen: Map<unknown, number>记录"每个对象以多深的 depth 被访问过"。if ((seen.get(value) || 0) >= depth) return——同一对象若已用更深(更大)的 depth 遍历过,则跳过。这解决了循环引用(a.b = a)与共享子对象导致的无限递归/重复遍历。- 注意比较方向:
depth在此处尚未自减,所以"已记录的 depth ≥ 当前 depth"就跳过。由于外层递归时 depth 单调递减,这保证每个对象在每个深度只走一次最大深度路径。 - 这个去重是性能关键:一个环状对象图若无
seen,traverse会栈溢出。
(3)按类型分派
isRef:只解一层.value(ref 的 value 本身可能是对象,继续按深度遍历)。isArray:逐个索引。isSet/isMap:forEach遍历值。注意Set遍历的是元素本身、Map遍历的是[key, value],但这里forEach((v) => traverse(v, ...))对 Map 拿到的v是 value——因此 Map 的 key 不会被 traverse 追踪。这是一个容易被忽略的边界(若只有 key 变化,traverse无法捕获,需要ITERATE_KEY)。isPlainObject:for...in遍历可枚举字符串键,再单独处理 Symbol 键(Object.getOwnPropertySymbols+propertyIsEnumerable)。3.5 加 Symbol 处理是为了支持"用 Symbol 做键的响应式属性"。
(4)为什么遍历 = 建立依赖?
traverse 的每次 value[key] 读取都会经过响应式代理的 get 陷阱 → track()。因为 traverse 是在 effect.run() 的上下文里被调用的(getter 的一部分),此刻 activeSub 就是那个 watch 的 ReactiveEffect,于是每个被读到的属性都被登记为依赖。traverse 的本质是"用读取动作把整棵对象的依赖一次性拉满"。
(5)traverse 的返回值:返回入参本身。所以 deep 包装后的 getter 返回的仍是源对象引用——这就是"新旧值是同一个对象"的根源。
4.7.2 deep 的必要性:reactive 对象新旧值相同的陷阱
const obj = reactive({ nested: { count: 0 } })
// 陷阱写法:不写 deep
watch(obj, (newVal, oldVal) => {
console.log('changed', newVal === oldVal) // 若触发,永远是 true
})
obj.nested.count++ // 在深层修改
// 结果:回调不会触发(除非显式 deep)
先澄清一个常见误解:watch(reactiveObj, cb) 默认会经 reactiveGetter 走 traverse(source)(watch.ts:143),因此深层修改会触发通知;而 forceTrigger = true(watch.ts:158)保证回调照样执行,只是 newValue === oldValue。
真正会"不触发"的是另一种写法:getter 返回一个对象时,oldValue 与 newValue 是同一引用,hasChanged 恒为 false,触发条件里又没有 deep 兜底——这才是需要显式 deep: true 的场景。
| 源 | deep 未指定时的行为 | 回调是否触发 |
|---|---|---|
watch(reactiveObj, cb) | reactiveGetter 默认 traverse(source)(深) | forceTrigger = true → 触发,newVal === oldVal |
watch(() => reactiveObj.nested, cb)(getter 返回对象) | 无 traverse(getter 只读一层) | 靠 hasChanged 比较,对象引用不变 → 不触发 |
watch(refObj, cb) | 无 traverse(普通 ref 不深遍历) | 靠 hasChanged;. 内部改不触发 |
所以真正的"陷阱"是第二种:getter 返回一个对象,但内部属性变化时 getter 重新返回的是同一个引用,hasChanged 认为没变。此时必须显式 deep: true:
const obj = reactive({ nested: { count: 0 } })
watch(
() => obj.nested, // 返回的是同一个 nested 对象引用
(nv, ov) => console.log('nested changed'),
{ deep: true }, // 关键:强制 traverse,从而追踪嵌套属性
)
obj.nested.count++ // 现在会触发(deep 分支让条件为真)
deep: true 做了两件事:
reactiveGetter里if (deep) return source(不 traverse),改由watch.ts:207-211的包装 gettertraverse(baseGetter(), depth)统一遍历——对 getter 返回的obj.nested做深度遍历,建立对count的依赖。- 触发条件里
deep为真,绕过hasChanged,强制回调。
这就是源码注释里 deep 被写进触发条件的原因:"because traverse returns the same object, hasChanged would always be false"。
4.7.3 deep 的三种取值与"只侦测一层"
3.5 引入了 deep: number:
| 写法 | depth | 效果 |
|---|---|---|
deep: true | Infinity | 全深度遍历 |
deep: 1 | 1 | 只遍历根对象的直接属性(不深入子对象) |
deep: 0 / deep: false | 1(走 shallow 分支) | reactiveGetter 里 traverse(source, 1),等价于只看根层 |
| 未指定(reactive 源) | Infinity | 默认深遍历 |
| 未指定(ref/getter 源) | — | 不做额外遍历 |
deep: 1 的典型用途:监听一个大对象"第一层键的增删改",但不想递归监听所有深层属性以省性能。
一个细节:deep === 0 与 deep === false 在 reactiveGetter 里被归为同一分支(watch.ts:140),而 deep: 1 会走"if (deep) return source"分支(因为 1 是 truthy),再由包装 getter 用 depth = 1 遍历。语义上 deep: 0 与 deep: 1 对根层效果类似,但实现路径不同。
4.7.4 immediate
// packages/reactivity/src/watch.ts:317-323
if (cb) {
if (immediate) {
job(true) // immediateFirstRun = true
} else {
oldValue = effect.run()
}
}
immediate: true时,job(true)立即执行:effect.run()(首次求值 → 建立依赖)→ 无条件触发条件为真 → 调 cb,且oldValue === INITIAL_WATCHER_VALUE→ 传undefined。- 因此
immediate回调里oldValue是undefined(多源是[])。 - 重要:
immediate的回调也是同步执行的(在 setup 期间),不是排队。这与flush无关。 immediate+once组合:首次 immediate 回调结束时watchHandle()会 stop,所以后续不再触发。
4.7.5 once(3.4+ 新增)
// packages/reactivity/src/watch.ts:221-230
if (once && cb) {
const _cb = cb
cb = (...args) => {
try {
return _cb(...args)
} finally {
watchHandle()
}
}
}
实现是"包装 cb":只要 cb 被调用过(无论是否 immediate),finally 里立即 watchHandle()(即 effect.stop() + 从 scope 移除)。因此:
once只对watch(source, cb)有效,watchEffect会被 dev 告警忽略。once与immediate同时使用时,immediate 那次调用后就停。- 因为
watchHandle定义在cb包装之前(watch.ts:214-219),闭包能正确引用到它。
4.7.6 多源数组
watch([a, b], ([newA, newB], [oldA, oldB]) => { /* ... */ })
isMultiSource = true,oldValue初始化为new Array(source.length).fill(INITIAL_WATCHER_VALUE)(watch.ts:232-234)。- 触发判断是两个维度的"任一变化":
(isMultiSource
? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
: hasChanged(newValue, oldValue))
- 多源里若含 reactive 对象,
forceTrigger会为真,任一变化都触发。 - 首次无故值时,
oldValue传[](不是undefined),因为数组形式更适合解构。 - 一个易错点:多源数组里源数量变化不会被侦测——数组本身是每次
source.map()新建的,但length是编译期固定的(你传进去的数组就是那个字面量)。
4.7.7 新旧值语义总表
| 源类型 | getter 返回 | 首次 oldValue | 触发判定 | 典型坑 |
|---|---|---|---|---|
ref | .value | undefined | hasChanged | .value 内部属性改不触发 |
shallow ref | .value | undefined | forceTrigger | 总是触发 |
reactive | 源对象(或 traverse 后) | undefined | forceTrigger | new === old |
| 多源数组 | 值数组 | [] | .some(hasChanged) | 长度固定 |
| getter 返回原始值 | 原始值 | undefined | hasChanged | — |
| getter 返回对象 | 同引用对象 | undefined | hasChanged(恒 false)→ 需 deep | 不 deep 就不触发 |
deep: true | 同引用对象 | undefined | deep 强制触发 | 回调频繁 |
4.7.8 一个可运行的 deep 陷阱验证
// demo-4-7.mjs
import { reactive, ref, watch } from '@vue/reactivity'
const obj = reactive({ nested: { count: 0 } })
// 情形 A:watch reactive 对象,默认深遍历 + forceTrigger
watch(obj, (nv, ov) => {
console.log('[A] triggered, same ref =', nv === ov, 'count =', nv.nested.count)
})
obj.nested.count++ // 触发:因为 reactive 源默认 traverse 且 forceTrigger
// 情形 B:getter 返回对象,不 deep
watch(
() => obj.nested,
() => console.log('[B] triggered'),
)
obj.nested.count++ // 不触发:getter 每次返回同一引用,hasChanged 为 false
// 情形 C:getter 返回对象 + deep
watch(
() => obj.nested,
() => console.log('[C] triggered'),
{ deep: true },
)
obj.nested.count++ // 触发:deep 强制
控制台输出:
[A] triggered, same ref = true count = 1
[C] triggered
[A] triggered, same ref = true count = 2
[C] triggered
可见情形 B 完全没有输出——这是生产环境里"watch 对象内部变化不生效"的最常见原因,解法就是加 deep: true。
4.7.9 immediate + deep 的性能账
deep: true 会在每次 getter 执行时完整遍历整个对象图。对大对象(如 10000 个节点的树、大表格数据)而言:
- 单次遍历成本 = O(全部可枚举属性数)。
- 每次任何深层属性变化 → effect 重跑 → 再次完整遍历。
- 因此"高频变化 + 大对象 + deep"是典型的性能杀手。
优化建议:
- 只 watch 你真正关心的路径(用 getter 精确取值),而不是整个大对象。
- 用
deep: 1限制深度。 - 用
shallowRef+ 手动触发,或把大对象拆分。
4.8 onCleanup / onWatcherCleanup:异步副作用失效机制
4.8.1 两套 cleanup 机制必须分清
Vue 3.5 里有两套cleanup:
| 机制 | API | 存储 | 触发时机 | 归属 |
|---|---|---|---|---|
| Effect cleanup | onEffectCleanup | ReactiveEffect.cleanup(单槽) | 每次 effect.run() 开头 / stop() | reactivity/effect.ts |
| Watcher cleanup | onWatcherCleanup / onCleanup | cleanupMap: WeakMap<effect, fn[]>(栈) | 每次 job 调 cb 之前 / stop() | reactivity/watch.ts |
为什么 watch 要单独一套栈? 因为 onEffectCleanup 只有一个槽(effect.cleanup 会被覆盖),而 watch 回调里可能注册多个 cleanup(例如多个请求的取消)。用数组栈才能都保留。
4.8.2 onWatcherCleanup 实现
// packages/reactivity/src/watch.ts:82-118
const cleanupMap: WeakMap<ReactiveEffect, (() => void)[]> = new WeakMap()
let activeWatcher: ReactiveEffect | undefined = undefined
export function getCurrentWatcher(): ReactiveEffect<any> | undefined {
return activeWatcher
}
export function onWatcherCleanup(
cleanupFn: () => void,
failSilently = false,
owner: ReactiveEffect | undefined = activeWatcher,
): void {
if (owner) {
let cleanups = cleanupMap.get(owner)
if (!cleanups) cleanupMap.set(owner, (cleanups = []))
cleanups.push(cleanupFn)
} else if (__DEV__ && !failSilently) {
warn(`onWatcherCleanup() was called when there was no active watcher to associate with.`)
}
}
activeWatcher是"当前活跃的 watch effect",与activeSub(当前活跃依赖收集者)是两回事。owner默认取activeWatcher;boundCleanup(即回调/effect 收到的onCleanup参数)把它固定为创建时的effect(watch.ts:298):
boundCleanup = fn => onWatcherCleanup(fn, false, effect)
- 因此
watchEffect((onCleanup) => { onCleanup(fn) })与watch(src, cb)回调里用onCleanup参数、或直接onWatcherCleanup(fn),三者等价(都挂到同一个 effect)。
4.8.3 activeWatcher 的切换时机
watchEffect 的 getter 包装(watch.ts:182-200):
getter = () => {
if (cleanup) { // cleanup 是 effect.onStop 的代理(见 4.7.5/4.5.5)
pauseTracking()
try { cleanup() } finally { resetTracking() }
}
const currentEffect = activeWatcher
activeWatcher = effect
try {
return call
? call(source, WatchErrorCodes.WATCH_CALLBACK, [boundCleanup])
: source(boundCleanup)
} finally {
activeWatcher = currentEffect
}
}
watch 回调执行时(watch.ts:254-280):
const currentWatcher = activeWatcher
pauseTracking()
try {
if (cleanup) cleanup()
activeWatcher = effect
// ... oldValue = newValue; call cb
} finally {
activeWatcher = currentWatcher
resetTracking()
}
两处的共同点:在进入用户函数前后切换 activeWatcher,并暂停追踪。
pauseTracking()保证 cb 内部的读取不进入依赖收集。activeWatcher = effect保证 cb 内部调用onCleanup能找到主人。
4.8.4 cleanup 的三种触发时机
- watchEffect 每次重跑前:
getter开头if (cleanup) cleanup()(watch.ts:183)。这会在每次副作用执行前清掉上一次的异步残留。 - watch 每次 cb 调用前:
job里if (cleanup) cleanup()(watch.ts:258)。 - effect 被 stop 时:
cleanup是effect.onStop,stop()调用onStop()(effect.ts:201)。
注意 1 与 2 走的是同一个 cleanup 变量——它被赋值为 effect.onStop:
// packages/reactivity/src/watch.ts:300-310
cleanup = effect.onStop = () => {
const cleanups = cleanupMap.get(effect)
if (cleanups) {
if (call) call(cleanups, WatchErrorCodes.WATCH_CLEANUP)
else for (const cleanup of cleanups) cleanup()
cleanupMap.delete(effect)
}
}
所以"重跑前"与"stop 时"调用的是同一段逻辑:遍历 cleanupMap 里该 effect 的 cleanup 栈、逐个执行、然后清空该条目。
4.8.5 异步副作用的"失效"语义
典型场景——竞态请求:
watch(id, async (newId, oldId, onCleanup) => {
let cancelled = false
onCleanup(() => { cancelled = true })
const data = await fetch(`/api/${newId}`)
if (cancelled) return // 被新一轮取代,丢弃结果
result.value = data
}, { immediate: true })
流程推演(id 连续从 1 变到 2):
sequenceDiagram
participant U as 用户
participant W as watch job
participant Q as cleanupMap
U->>W: id=1 -> job
W->>W: effect.run(); oldValue 更新
W->>Q: cb(onCleanup) 注册 cancelled=true
Note over W: await fetch 挂起
U->>W: id=2 -> 新的 job(flush 后)
W->>W: effect.run() (新值)
W->>Q: 进入 cb 前: cleanup() -> cancelled=true
W->>Q: 新 cb 注册新的 cancelled
Note over W: 旧 fetch resolve -> cancelled 为 true -> 丢弃
W->>W: 新 fetch resolve -> 写入 result
核心:cleanup 在下一次 cb 之前执行,把上一轮的"取消标记"置位,从而让上一轮挂起的异步逻辑在 resolve 后主动退出。这是"竞态条件"的标准解法。
4.8.6 onCleanup 参数 vs onWatcherCleanup vs onEffectCleanup
| API | 可用范围 | 备注 |
|---|---|---|
第三个参数 onCleanup | watch(src, cb) 的 cb、watchEffect 的 fn | 由 boundCleanup 注入,等价于 onWatcherCleanup |
onWatcherCleanup(fn) | 在 activeWatcher 上下文内(cb / watchEffect fn 内) | 显式 API,支持 failSilently 与自定义 owner |
onEffectCleanup(fn) | 在任意 activeSub 为 ReactiveEffect 的上下文(含 effect() 内部) | 单槽,会被后续调用覆盖 |
易错点:onWatcherCleanup 在 activeWatcher 为 undefined 时会 dev 告警——例如在 setTimeout 回调里调用它,此时 cb 早已返回,activeWatcher 已还原,注册会失败。正确做法是把 onCleanup 引用捕获下来:
watch(source, (nv, ov, onCleanup) => {
const stop = () => { /* ... */ }
onCleanup(stop) // 正确:注册发生在 cb 同步执行期间
setTimeout(() => onCleanup(stop), 100) // 仍然有效?—— 不,onCleanup 已绑定 effect,
// 这里再次调用会把 stop 重复压栈
})
4.8.7 可运行的 cleanup 顺序验证
// demo-4-8.mjs
import { ref, watch, watchEffect } from '@vue/reactivity'
const n = ref(0)
watchEffect((onCleanup) => {
console.log('[effect run] n =', n.value)
onCleanup(() => console.log(' (cleanup of previous effect run)'))
})
console.log('--- set n = 1 ---')
n.value = 1
console.log('--- set n = 2 ---')
n.value = 2
控制台输出:
[effect run] n = 0
--- set n = 1 ---
(cleanup of previous effect run)
[effect run] n = 1
--- set n = 2 ---
(cleanup of previous effect run)
[effect run] n = 2
顺序铁律:cleanup 永远在"下一轮副作用/回调"之前,且只属于上一轮。
4.9 runtime-core 的 apiWatch 封装:instance 关联、SCOPE 注册、组件卸载自动 stop、pause/resume
4.9.1 doWatch 全貌
doWatch 是 runtime-core 层的"胶水"(packages/runtime-core/src/apiWatch.ts:146-245)。按执行顺序拆成 7 步:
- 读取 options(
immediate/deep/flush/once)。 - dev 下校验
watchEffect误传immediate/deep/once并告警。 - 组装
baseWatchOptions,注入onWarn与call。 - 计算
runsImmediately;SSR 分支处理。 - 组装
scheduler(按 flush)。 - 组装
augmentJob(打 flags/id)。 - 调
baseWatch并处理 SSR cleanup。
第 3 步的 call 注入是关键:
// packages/runtime-core/src/apiWatch.ts:194-196
const instance = currentInstance
baseWatchOptions.call = (fn, type, args) =>
callWithAsyncErrorHandling(fn, instance, type, args)
为什么要注入 call? 因为 @vue/reactivity 是框架无关的包,它不依赖 ComponentInternalInstance,也就无法把 watch 回调里的错误正确归属到组件(instance)并走 app.config.errorHandler。runtime-core 通过注入一个"错误处理包装器"解决这个分层问题:
WatchErrorCodes.WATCH_GETTER = 2:getter 执行出错。WATCH_CALLBACK:回调执行出错。WATCH_CLEANUP:cleanup 执行出错。
callWithAsyncErrorHandling 会捕获同步与异步(Promise)错误,并按 instance 派发。
4.9.2 instance 关联:三层含义
doWatch 里的 instance(apiWatch.ts:194)来自 currentInstance,它的作用有三:
- 错误归属:如上,
call包装。 - pre job 的排序 id:
job.id = instance.uid(apiWatch.ts:229),决定 pre watcher 与组件更新的相对顺序。 - post job 的 owner:
job.i = instance(apiWatch.ts:204),用于"根挂载时区分自己的 post cb"(scheduler.ts:52-53, 200-207)。
此外,pre job 还会设 job.i = instance(apiWatch.ts:230),这样 flushJobs 里 callWithErrorHandling(job, job.i, ErrorCodes.COMPONENT_UPDATE) 会把错误按组件更新处理。
4.9.3 SCOPE 注册:为什么 watch 会被组件卸载自动 stop
这是"组件卸载自动 stop"的完整链路。关键在 ReactiveEffect 的构造函数:
// packages/reactivity/src/effect.ts:116-131
constructor(public fn: () => T) {
if (activeEffectScope) {
if (activeEffectScope.active) {
activeEffectScope.effects.push(this)
} else {
// The active scope has already been stopped. This happens when a
// component's setup is resumed after a top-level `await` ...
this.flags &= ~EffectFlags.ACTIVE
}
}
}
而组件内部持有的是一个 detached 的 scope:
// packages/runtime-core/src/component.ts:640
scope: new EffectScope(true /* detached */),
detached: true 意味着它不挂到父 scope,独立管理自己的 effects。那"活跃 scope"是怎么被设置的?是靠 setCurrentInstance:
// packages/runtime-core/src/component.ts:777-790
export const setCurrentInstance = (instance: ComponentInternalInstance) => {
const prev = currentInstance
internalSetCurrentInstance(instance)
instance.scope.on() // <-- 把组件的 detached scope 设为 activeEffectScope
return (): void => {
instance.scope.off()
internalSetCurrentInstance(prev)
}
}
export const unsetCurrentInstance = (): void => {
currentInstance && currentInstance.scope.off()
internalSetCurrentInstance(null)
}
以及 EffectScope.on/off:
// packages/reactivity/src/effectScope.ts:120-159
prevScope: EffectScope | undefined
on(): void {
if (++this._on === 1) {
this.prevScope = activeEffectScope
activeEffectScope = this
}
}
off(): void {
if (this._on > 0 && --this._on === 0) {
if (activeEffectScope === this) {
activeEffectScope = this.prevScope
} else {
// 处理 async 并发导致的乱序恢复,从链中间摘除
let current = activeEffectScope
while (current) {
if (current.prevScope === this) { current.prevScope = this.prevScope; break }
current = current.prevScope
}
}
this.prevScope = undefined
}
}
于是:
- 组件 setup 执行时,
setCurrentInstance(instance)调用instance.scope.on(),把组件 scope 设为全局活跃 scope。 - setup 里创建的每个
watch,其new ReactiveEffect(getter)都会activeEffectScope.effects.push(effect)——effect 被"注册"进组件 scope。 _on是引用计数:on()可多次调用,只有1 → 0时才真正 off,支持嵌套/异步恢复。- 组件卸载时,
unmountComponent调用scope.stop(),于是 scope 里所有 effects 被stop()——watch 自动停止。
4.9.4 EffectScope.on/off 与 async setup 的乱序恢复
off() 的 else 分支值得单独说:注释解释了一个真实的竞态——
withAsyncContext()restores the current component scope for the current async continuation, then defers its cleanup to a microtask. If sibling continuations interleave (A restore -> B restore -> A cleanup),activeEffectScopeis already B instead of this scope A when A's cleanup callsoff().
即:两个并发的 async setup 分支(A、B)交替 restore 与 cleanup 时,A 的 cleanup 可能在 activeEffectScope === B 时执行。此时不能简单地 activeEffectScope = this.prevScope(那会错误地把栈顶退回),而要从当前链中间找到"prevScope === this"的那个节点,把它摘除。这段代码是 3.5 为 <Suspense> + 顶层 await 场景专门加固的。
与之呼应的是 ReactiveEffect 构造函数里的"已停止 scope"保护(effect.ts:124-129):如果一个组件的 setup 在 await 期间组件已被卸载,activeEffectScope.active 为 false,新建的 effect 直接清掉 ACTIVE 标志,成为"孤儿不入链"的状态——防止"组件已卸载但 effect 仍订阅依赖并永远触发"的内存泄漏。这同样是 3.5 的新增防御。
4.9.5 组件卸载自动 stop 的完整时序
sequenceDiagram
participant S as setup()
participant CI as currentInstance / instance.scope
participant W as watch()
participant SC as instance.scope (detached)
participant U as unmount
S->>CI: setCurrentInstance(instance)
CI->>SC: scope.on() -> activeEffectScope = instance.scope
S->>W: watch(...)
W->>W: new ReactiveEffect(getter)
W->>SC: (ctor) activeEffectScope.effects.push(effect)
S->>CI: reset() -> scope.off()
Note over SC: effect 已登记在 scope.effects
U->>SC: scope.stop()
SC->>W: effect.stop() -> 清 deps + onStop(cleanup)
Note over W: 之后源变化不再触发
4.9.6 instanceWatch:this.$watch 的实现
// packages/runtime-core/src/apiWatch.ts:248-271
export function instanceWatch(
this: ComponentInternalInstance,
source: string | Function,
value: WatchCallback | ObjectWatchOptionItem,
options?: WatchOptions,
): WatchHandle {
const publicThis = this.proxy
const getter = isString(source)
? source.includes('.')
? createPathGetter(publicThis!, source)
: () => publicThis![source as keyof typeof publicThis]
: source.bind(publicThis, publicThis)
let cb
if (isFunction(value)) {
cb = value
} else {
cb = value.handler as Function
options = value
}
const reset = setCurrentInstance(this)
const res = doWatch(getter, cb.bind(publicThis), options)
reset()
return res
}
- 支持字符串路径源:
this.$watch('a.b.c', cb)用createPathGetter逐段取值。 - 手动
setCurrentInstance(this):因为$watch可能在组件上下文之外被调用(如beforeCreate里通过this.$watch),需要临时把 instance 设回来,才能让currentInstance生效(错误归属、pre id 等)。调用后立刻reset()。 cb.bind(publicThis):保证回调里this指向组件实例。
4.9.7 createPathGetter
// packages/runtime-core/src/apiWatch.ts:273-284
export function createPathGetter(ctx, path) {
const segments = path.split('.')
return () => {
let cur = ctx
for (let i = 0; i < segments.length && cur; i++) {
cur = cur[segments[i] as keyof typeof cur]
}
return cur
}
}
segments 预分割(避免每次回调都 split),循环里 && cur 保证中途为 null/undefined 时不继续深挖(短路保护)。
4.9.8 pause / resume 的语义与用途
pause/resume 来自 ReactiveEffect(effect.ts:133-145):
pause(): void {
this.flags |= EffectFlags.PAUSED
}
resume(): void {
if (this.flags & EffectFlags.PAUSED) {
this.flags &= ~EffectFlags.PAUSED
if (pausedQueueEffects.has(this)) {
pausedQueueEffects.delete(this)
this.trigger() // 补跑暂停期间被跳过的触发
}
}
}
语义:
pause()后,若源变化触发该 effect,trigger()会命中PAUSED分支,把 effect 加入pausedQueueEffects(effect.ts:206-208)——记录"欠了一次触发",但不立即跑。resume()时若发现它在pausedQueueEffects里,就移除并补跑一次trigger()。
典型用途:批量更新时临时暂停某个重计算 watch,更新完再 resume,避免中间态多次触发。注意 pausedQueueEffects 是 WeakSet(effect.ts:85),不会造成内存泄漏。
一个微妙点:pause() 只是置标志,不会清空已入队的 job。如果 effect 已经通过 scheduler 入了 pre 队列,resume 时那次 job 仍可能跑(因为 job 内部有 !effect.dirty 守卫会再判断一次)。
4.10 effectScope:EffectScope 类字段、collect、stop、detached 模式、onScopeDispose、getCurrentScope
4.10.1 类字段逐个解释
// packages/reactivity/src/effectScope.ts:4-62
export let activeEffectScope: EffectScope | undefined
export class EffectScope {
private _active = true // 是否处于活跃(未 stop)状态
private _on = 0 // on() 引用计数,允许重复 on/off 配对
effects: ReactiveEffect[] = [] // 收集的 effect(computed 不再入此数组)
cleanups: (() => void)[] = [] // onScopeDispose 注册的清理回调
private _isPaused = false
private _warnOnRun = true
parent: EffectScope | undefined // 父 scope(仅非 detached 有)
scopes: EffectScope[] | undefined // 子 scope 列表(仅非 detached 有)
private index: number | undefined // 在父 scopes 数组中的下标(O(1) 删除优化)
readonly __v_skip = true
constructor(public detached = false) {
if (!detached && activeEffectScope) {
if (activeEffectScope.active) {
this.parent = activeEffectScope
this.index =
(activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(this) - 1
} else {
this._active = false
this._warnOnRun = false
}
}
}
字段详解:
| 字段 | 含义 | 谁读谁写 |
|---|---|---|
_active | 当前 scope 是否可运行、可 stop | active getter 暴露;stop 置 false |
_on | on() 的引用计数 | on/off |
effects | 收集的 ReactiveEffect | ReactiveEffect 构造时 push;stop 时遍历 stop;watch stop 时 remove |
cleanups | onScopeDispose 回调 | onScopeDispose push;stop 时执行 |
_isPaused | 是否被 pause() 冻结 | pause/resume |
parent | 父 scope | 构造时设置;stop 时置 undefined |
scopes | 子 scope 数组 | 构造时 push;stop 时批量 stop |
index | 在父数组中的下标 | 构造/删除时维护 |
detached | 是否游离于父 scope 链 | 构造参数;影响是否挂 parent、stop 时是否从父移除 |
__v_skip | 免疫 traverse/proxy | ReactiveFlags.SKIP 语义 |
要点 1:effects 里只有 ReactiveEffect(watch、watchEffect、手动 effect()、render effect)。computed 不进这里——因为 3.5 的 ComputedRefImpl 不是 ReactiveEffect,它没有独立的 stop 生命周期,它的"停止"是通过"下游订阅者消失 → removeSub 软退订"自动发生的(见 4.3.3)。所以:
effectScope.stop()不会直接 stop 一个 computed,但因为 computed 的存活依赖"有下游读它",当下游的 watch/effect 被 stop 后,computed 自然失去订阅者而被"冻结"。
要点 2:constructor(public detached = false) 与 activeEffectScope 的交互——非 detached 的 scope 会自动挂到当前活跃 scope 之下,形成树。
要点 3:若 activeEffectScope 存在但已停止(!active),新建的 scope 直接 _active = false,且 _warnOnRun = false(避免 run() 时再告警)。这是配合 async setup 的防御。
4.10.2 run() 与 active / on / off
// packages/reactivity/src/effectScope.ts:64-66, 106-118
get active(): boolean { return this._active }
run<T>(fn: () => T): T | undefined {
if (this._active) {
const currentEffectScope = activeEffectScope
try {
activeEffectScope = this
return fn()
} finally {
activeEffectScope = currentEffectScope
}
} else if (__DEV__ && this._warnOnRun) {
warn(`cannot run an inactive effect scope.`)
}
}
run() 是"在 scope 上下文里执行一段代码"的标准方式:它临时把 activeEffectScope 换成自己,fn 里创建的 effect 就会被 this.effects.push 收集,执行完还原。
on() / off() vs run() 的区别:
| 方法 | 语义 | 场景 |
|---|---|---|
run(fn) | 同步执行 fn,用 try/finally 严格配对 | 脚本里手动管理 scope |
on() / off() | 跨语句/跨异步地激活与还原,靠引用计数 | 组件 setup(可能 await)、异步恢复 |
组件用的是 on/off,因为 setup 是异步的(<script setup> 顶层 await),run 的同步 finally 语义不够用。
4.10.3 stop():批量销毁
// packages/reactivity/src/effectScope.ts:162-195
stop(fromParent?: boolean): void {
if (this._active) {
this._active = false
let i, l
for (i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].stop() // 1. 停所有 effect
}
this.effects.length = 0
for (i = 0, l = this.cleanups.length; i < l; i++) {
this.cleanups[i]() // 2. 跑所有 onScopeDispose 回调
}
this.cleanups.length = 0
if (this.scopes) {
const scopes = this.scopes.slice() // 3. 递归停子 scope(先快照防遍历中修改)
for (i = 0, l = scopes.length; i < l; i++) {
scopes[i].stop(true) // fromParent = true
}
this.scopes.length = 0
}
// nested scope, dereference from parent to avoid memory leaks
if (!this.detached && this.parent && !fromParent) {
// optimized O(1) removal
const last = this.parent.scopes!.pop()
if (last && last !== this) {
this.parent.scopes![this.index!] = last
last.index = this.index!
}
}
this.parent = undefined
}
}
逐点:
- 顺序:先停 effects → 再跑 cleanups → 最后停子 scope。
fromParent参数:父 scope 调child.stop(true)时传 true,表示"由父统一处理,子不需要再回父的数组里删自己"——因为父随后会this.scopes.length = 0整片清掉。避免在遍历子数组时改动父数组。- O(1) 删除优化:未 detached 的 scope 有
index。stop 时用"尾部元素填补被删位置"的经典技巧(pop取出 last,若不是自己就移到 index 处),保持数组紧凑,避免splice的 O(n)。 this.parent = undefined:解除引用,防内存泄漏。- 幂等:
if (this._active)保证重复 stop 是 no-op。
4.10.4 detached 模式
effectScope(true) 创建 detached scope:
- 不挂父:
if (!detached && activeEffectScope)条件不成立,因此它不作为任何 scope 的子节点。 - 不被父 scope 的 stop 波及:父 scope 停止时不会 stop 它。
- 用自己的 run/on/off 管理内部 effect:功能不变。
- 组件的
instance.scope就是 detached——这样组件 unmount 时 stop 自己的 scope,而不会被父组件的 scope 顺带停掉(组件生命周期是独立的)。
on/off 的注释特别标注 "This should only be called on non-detached scopes"(effectScope.ts:121, 132),因为 detached scope 的 on() 如果覆盖了 activeEffectScope 而 off() 不还原,会让全局活跃 scope 丢失。
4.10.5 onScopeDispose 与 getCurrentScope
// packages/reactivity/src/effectScope.ts:207-235
export function effectScope(detached?: boolean): EffectScope {
return new EffectScope(detached)
}
export function getCurrentScope(): EffectScope | undefined {
return activeEffectScope
}
export function onScopeDispose(fn: () => void, failSilently = false): void {
if (activeEffectScope) {
activeEffectScope.cleanups.push(fn)
} else if (__DEV__ && !failSilently) {
warn(`onScopeDispose() is called when there is no active effect scope to be associated with.`)
}
}
onScopeDispose把回调压进当前活跃 scope 的cleanups。- 若当前没有活跃 scope(如在组件外、或
activeEffectScope已被 off),dev 下告警。 - 注意它与
onUnmounted的差别:onScopeDispose绑定的是"响应式 scope",可在任意有活跃 scope 的地方用;onUnmounted绑定的是组件实例,只在 setup 内可用。
4.10.6 pause() / resume():整棵 scope 树冻结
// packages/reactivity/src/effectScope.ts:68-104
pause(): void {
if (this._active) {
this._isPaused = true
let i, l
if (this.scopes) {
const scopes = this.scopes.slice()
for (i = 0, l = scopes.length; i < l; i++) scopes[i].pause()
}
for (i = 0, l = this.effects.length; i < l; i++) this.effects[i].pause()
}
}
resume(): void {
if (this._active) {
if (this._isPaused) {
this._isPaused = false
if (this.scopes) {
const scopes = this.scopes.slice()
for (i = 0, l = scopes.length; i < l; i++) scopes[i].resume()
}
const effects = this.effects.slice()
for (i = 0, l = effects.length; i < l; i++) effects[i].resume()
}
}
}
与 EffectScope.pause 对应的是 ReactiveEffect.pause——递归地让整棵 scope 树里的所有 effect 进入 PAUSED。用途:如"切换路由时暂停后台组件的响应式更新",或测试中冻结副作用。
4.10.7 effectScope 与 computed 的"collect"关系澄清
任务大纲提到 collect。EffectScope 类里没有名为 collect 的方法;"收集"是通过构造函数里的 activeEffectScope.effects.push(this) 隐式完成的(effect.ts:118-120)。这一点必须澄清,因为它直接决定了一个实务结论:
手动
effectScope()只收集ReactiveEffect(watch / watchEffect / effect()),不收集 computed。
如果你在 effectScope 里创建一个 computed,然后 scope.stop(),computed 不会被显式停止。但如前所述,computed 的生命周期由"有无下游"决定,所以实践中它会被间接冻结。若你需要"显式控制 computed 失效",正确做法是让所有读取它的 watch 归属同一 scope。
4.10.8 一个可运行的 effectScope 验证
// demo-4-10.mjs
import { effectScope, ref, computed, watchEffect, onScopeDispose, getCurrentScope } from '@vue/reactivity'
const counter = ref(0)
const scope = effectScope()
scope.run(() => {
console.log('inside scope, active =', getCurrentScope() === scope)
watchEffect(() => console.log(' [scope effect] counter =', counter.value))
onScopeDispose(() => console.log(' [scope dispose] called'))
})
// 在 scope 外创建:不会被 scope 收集
watchEffect(() => console.log(' [outer effect] counter =', counter.value))
console.log('--- 修改 counter ---')
counter.value = 1
console.log('--- stop scope ---')
scope.stop()
console.log('--- 再次修改 counter ---')
counter.value = 2
控制台输出:
inside scope, active = true
[scope effect] counter = 0
[outer effect] counter = 0
--- 修改 counter ---
[scope effect] counter = 1
[outer effect] counter = 1
--- stop scope ---
[scope dispose] called
--- 再次修改 counter ---
[outer effect] counter = 2
验证结论:
scope.run内getCurrentScope() === scope。- scope 外的 effect 不被收集,scope.stop 后仍继续响应。
onScopeDispose在 stop 时被调用。- scope 内的 effect 在 stop 后不再触发。
4.11 调度顺序:computed vs watch(pre) vs watch(post) 在同一次 flush 中的执行次序
4.11.1 三层时间轴
一次"源变化 → 界面更新"可以被切成三个层次:
| 层次 | 机制 | 是否同步 | 代表 |
|---|---|---|---|
| 响应式层 | batch / endBatch | 同步(在一次赋值内完成) | computed 置脏、effect 入 batchedSub |
| 调度层 | queueJob / queuePostFlushCb | 异步(微任务) | pre job、post cb 入队 |
| 生命周期层 | flushJobs 顺序遍历 | 微任务内同步 | 组件 render、pre/post 回调 |
computed 完全活动在响应式层——它不产生 job,只产生"脏标记 + 通知下游"。watch 的调度活动在调度层(pre 用 queueJob、post 用 queuePostRenderEffect)。
4.11.2 同一次 flush 内的理想执行次序
假设一个组件里同时有:
const s = ref(0)
const c = computed(() => s.value * 2)
watch(s, cb1) // pre
watch(c, cb2) // pre(依赖 computed)
watchPostEffect(() => { /* post1 */ })
watchSyncEffect(() => { /* sync1 */ })
// 模板里也读了 c 和 s
当 s.value = 1 时(在事件处理器里,非在 flush 中):
sequenceDiagram
U->>Dep: s.value = 1
Dep->>Computed: c.notify() 置 DIRTY, 入 batchedComputed
Dep->>Render: render effect 入 batchedSub
Dep->>W_s: watch(s) effect 入 batchedSub
Dep->>W_c: watch(c) effect 入 batchedSub
Note over Dep: endBatch -> 遍历 batchedSub: trigger()
W_c->>W_c: trigger -> scheduler -> job -> effect.run -> 读 c.value -> computed 重算
W_c->>Q: queueJob(pre) (依赖 computed 的 pre watcher)
W_s->>Q: queueJob(pre)
Render->>Q: queueJob(组件更新 job)
Note over Q: 微任务 flushJobs
Q->>W_s: pre cb1 (id=uid, PRE)
Q->>W_c: pre cb2
Q->>Render: 组件 render (id=uid)
Q->>Post: flushPostFlushCbs
Post->>W: post1 (在 DOM patch 之后)
要点:
- computed 的重算发生在 computed 的 watch 的 job 执行期间(
effect.run()读c.value时)。也就是说,watch(c)的 getter 是 computed 的"读取者",computed 在这里被重新求值。 - pre 队列顺序:watcher 与组件更新 job 同 id,
PRE标志让 watcher 插在前面。因此cb1、cb2都在 render 之前。 - 两个同 id 的 pre watcher 之间的顺序,取决于它们谁先入队(
queueJob里jobId >= getId(lastJob)的 fast path 会直接 push)。这里W_c先入队(因为它挂在s.dep的订阅者链更靠后?实际取决于订阅链顺序),具体顺序在实现上等价于"依赖数组的稳定遍历"。
4.11.3 sync 的位置
watchSyncEffect 没有 scheduler,effect.trigger() 直接 job():
- 它发生在上面的
endBatch遍历batchedSub时,早于任何 queueJob 出队。 - 所以在赋值语句返回之前,sync 回调已经执行完毕。
- sync 的代价:它在
endBatch循环里同步执行,若回调里再改状态 → 再次trigger→ 可能触发Maximum recursive updates(因为ALLOW_RECURSE只对 cb 版 watch 打开,而 watchSyncEffect 无 cb 不打此标志;但若用watch(..., { flush: 'sync' })则有 cb,会打ALLOW_RECURSE)。
4.11.4 与 nextTick 的关系
flushJobs 由 resolvedPromise.then(flushJobs) 驱动(scheduler.ts:120-124),currentFlushPromise 就是那个 promise。nextTick() 返回它(scheduler.ts:57-68),所以:
s.value = 1
console.log('同步区', document.querySelector('div').textContent) // 旧值(还没 flush)
await nextTick()
console.log('flush 后', document.querySelector('div').textContent) // 新值
nextTick 的回调在 flushJobs 完成后执行(因为挂在同一个 promise 链后)。因此 await nextTick() 之后所有 pre/post 都已跑完。
4.11.5 完整推演:打印真实顺序
// demo-4-11.mjs(浏览器环境)
import { createApp, h, ref, computed, nextTick } from 'vue'
import { watch, watchPostEffect, watchSyncEffect } from 'vue'
createApp({
setup() {
const s = ref(0)
const c = computed(() => { console.log(' [computed recompute] s =', s.value); return s.value * 2 })
watch(s, () => console.log(' [pre] watch(s), c =', c.value))
watchPostEffect(() => console.log(' [post] effect, s =', s.value, 'DOM =', document.querySelector('div')?.textContent))
watchSyncEffect(() => console.log(' [sync] effect, s =', s.value))
return () => { console.log(' [render] s =', s.value); return h('div', `${c.value}`) }
},
}).mount('#app')
console.log('=== set s = 1 ===')
s.value = 1
console.log('=== before nextTick ===')
nextTick(() => console.log('=== after flush ==='))
控制台(关键行):
[computed recompute] s = 0
[render] s = 0
[sync] effect, s = 0
[post] effect, s = 0 DOM = 0
=== set s = 1 ===
[sync] effect, s = 1
=== before nextTick ===
[computed recompute] s = 1
[pre] watch(s), c = 2
[render] s = 1
[post] effect, s = 1 DOM = 2
=== after flush ===
读出三条铁律:
sync发生在赋值语句(=== set s = 1 ===之后、=== before nextTick ===之前),还没有 render。pre发生在 render 之前,且它读c.value会触发 computed 重算([computed recompute] s = 1出现在[pre]之前)。post在 render 之后,且此时 DOM 已是新值(DOM = 2)。
4.11.6 无组件归属时的顺序差异
无 instance 的 watchEffect(如在模块顶层创建):job.id 为 undefined、PRE 标志已打 → getId 返回 -1(scheduler.ts:270-271),于是它排在所有有 id 的 job 之前(包括 id 为 0 的根组件)。这是"全局 effect 先于组件更新运行"的顺序来源。
4.12 实战排查:五大高频故障的源码级归因
4.12.1 故障一:watch 完全不触发
症状:watch(source, cb) 定义了,但源变化后 cb 从不执行。
归因树(逐个排查):
| 可能原因 | 源码依据 | 修复 |
|---|---|---|
源是 getter 返回对象,未加 deep | watch.ts:250-252 的 hasChanged 为 false | 加 { deep: true } |
源是 reactive 对象但只改了深层属性且 getter 只读了根 | 依赖未建立 | 用 deep 或精确 getter |
源是 ref(obj) 而改的是 obj.x | 普通 ref 只 track .value | 改 ref 为 reactive,或深度 getter |
在 setup 外/异步回调里创建,activeEffectScope 已 off | effect.ts:117-129 孤儿保护 | 确保创建时有活跃 scope |
| 组件已卸载 | scope.stop → effect.stop | 检查生命周期 |
pause() 后忘记 resume() | effect.ts:206-208 PAUSED 分支 | resume |
源是 shallowRef 且只改内部属性 | forceTrigger 也只在 .value 赋值时 | 手动 triggerRef |
最小复现:
const state = reactive({ deep: { n: 0 } })
watch(() => state.deep, () => console.log('never')) // 不触发
state.deep.n++
// 修:watch(() => state.deep, cb, { deep: true })
4.12.2 故障二:watch 只触发一次
症状:之后源怎么变都不再触发。
归因:
once: true:watch.ts:221-230在 cb 的 finally 里watchHandle()。检查是否误传。- 回调里抛错导致 effect 停止? 不会——watch 回调抛错只走
call的错误处理,不 stop effect。但若stop()被手动调用则停。 immediate+ 逻辑分支 return:若首次 immediate 回调里因条件提前return(你没调 stop),后续仍会触发——所以不是这个。- effect 因
!effect.dirty被跳过:若每次改动的值hasChanged都为 false(如每次都赋相同值,或对象引用不变),job 开头的守卫会 return。检查forceTrigger/deep是否覆盖。 once的包装变量:cb被替换,instanceWatch等不会。
4.12.3 故障三:watch 触发两次
这是最高频的"幽灵 bug",主要有三种来源:
来源 A:源数组里同一对象出现两次 / 多源里多个源同一次变化
watch([() => obj.a, () => obj.a], cb) // 同一次改 a,some() 命中一次即可,不会两次
多源本身在同一次变化里只触发一次(some 短路)。
来源 B:两个独立的顶层赋值,各自成 batch
如 4.4.2 的 batch 机制所述,a.value = 1; b.value = 2 是两次独立 trigger,pre job 会入队两次?——不会,因为 queueJob 有 QUEUED 标志去重(scheduler.ts:100-118)。但 sync watcher 会跑两次,因为它不走队列去重。
来源 C(最常见):flush 语义 + 依赖链导致中间值
watch([a, b], ([na, nb]) => console.log(na, nb))
a.value = 1
b.value = 2
a 变化时:some 命中 → 触发一次(此时 b 还是旧值)。b 变化时:再触发一次。两次。
修复:在同一个 tick 里统一改(用 nextTick 合并),或改成依赖合成值的 computed。
来源 D:watch 回调里改了源,导致递归回调
因为 cb 版 watch 有 ALLOW_RECURSE(apiWatch.ts:222-224),回调里改源会允许再次触发。若状态不收敛 → 无限循环 → Maximum recursive updates(见 4.12.5)。
排查手段:在 cb 里打印 performance.now() 与调用栈,对比"两次是否同一个微任务"。
4.12.4 故障四:computed 不更新
症状:依赖变了,但 computed.value 仍是旧值。
| 原因 | 源码依据 | 修复 |
|---|---|---|
| computed 无任何下游订阅者 | addSub 级联只在首次订阅时发生(dep.ts:213) | 确保至少有一个 effect/watch/模板读了它 |
| 依赖是非响应式的普通变量 | getter 里读的不是 ref/reactive | 改成 ref/reactive |
依赖是 reactive 对象新增的属性(3.5 前) | 需靠 ITERATE_KEY | 3.5 已支持(dep.ts ITERATE_KEY);旧属性删除后重加也可 |
| SSR 环境下 computed 无 render effect | effect.ts:398-404 的 !isSSR 条件 | SSR 依赖 globalVersion 快路径 |
| getter 里有副作用/随机值 | 缓存语义 | 不该在 computed 里做副作用 |
在 getter 里读了一个 ShallowRef | shallow 只追踪 .value | 用普通 ref |
| computed 内部依赖链断裂(中间 computed 未被订阅) | removeSub 软退订 | 让中间 computed 也被下游读取 |
最小复现:
const a = ref(1)
const c = computed(() => a.value * 2)
c.value // 读一次(无下游,但此刻建立了 deps)
a.value = 5
console.log(c.value) // 6?
实际结果仍是 6(正确):读 c.value 时若无活跃 activeSub,dep.track() 直接返回(dep.ts:109),c 不会被 addSub 挂到 a.dep,因此不会被动标脏;但下次读 c.value 时 refreshComputed 走到闸门 3,isDirty(c) 发现 c.deps 指向的 a.dep.version 已因 trigger 自增 → 判定脏 → 重算。
结论:computed 是否被订阅,只影响"是否被动标脏",不改变"读取时是否重算"。真正的故障是"依赖根本不是响应式"或"依赖的 track 被暂停"。
4.12.5 故障五:循环依赖导致 Maximum recursive updates exceeded
触发点:scheduler.ts:329-347 的 checkRecursiveUpdates,阈值 RECURSION_LIMIT = 100。
// packages/runtime-core/src/scheduler.ts:54, 329-347
const RECURSION_LIMIT = 100
function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob) {
const count = seen.get(fn) || 0
if (count > RECURSION_LIMIT) {
const instance = fn.i
const componentName = instance && getComponentName(instance.type)
handleError(
`Maximum recursive updates exceeded${componentName ? ` in component <${componentName}>` : ``}. ` +
`This means you have a reactive effect that is mutating its own ` +
`dependencies and thus recursively triggering itself. Possible sources ` +
`include component template, render function, updated hook or ` +
`watcher source function.`,
null,
ErrorCodes.APP_ERROR_HANDLER,
)
return true
}
seen.set(fn, count + 1)
return false
}
归因:某个 job 在同一轮 flush 里被执行超过 100 次。seen 是一个 Map<SchedulerJob, count>,flushJobs 里对每个 job 调一次 check。
常见成因:
- 组件 render 里改了自己的依赖(如 render 函数里写
state.x = ...)。 - watch(post) 里改渲染依赖,且值不收敛。
- computed 的 getter 里改状态(让 computed 变成副作用源)。
updated钩子里改状态。
为什么有 ALLOW_RECURSE 还会告警? ALLOW_RECURSE 允许"入队自己",但不豁免递归检测。它只是让 QUEUED 标志在 flushJobs 里被保持得更宽松,真正的次数限制仍由 checkRecursiveUpdates 兜底。
修复:让状态变更收敛(加条件守卫 if (state.x !== want) state.x = want),或把副作用移出 render/computed,或用 nextTick 打断同步递归。
4.13 本章要点回顾
-
ComputedRefImpl是"二象性"对象:dep是"别人依赖我"(下游订阅),deps是"我依赖别人"(上游订阅)。3.5 用deps/depsTail双向链表取代了 3.4 的_deps,用flags位集取代了_dirty布尔;effect字段只作向后兼容的自引用。 -
惰性 + 三级缓存闸门:
refreshComputed依次检查TRACKING && !DIRTY(稳态)、globalVersion(全局无变化)、EVALUATED && !isDirty(依赖对账)。任一命中即返回缓存,只有全部穿透才真正执行 getter。 -
addSub的惰性级联是缓存的关键:computed 只有第一次获得下游订阅者时才置TRACKING并把上游依赖逐个挂上订阅链;下游全部离开时removeSub做"软退订"(保留deps引用,只摘订阅)。这就是"没人读就不重算"的实现。 -
computed 链式传播靠
notify()返回true:Dep.notify遍历订阅者时,若对方是 computed 就顺带通知它的dep——把递归栈改成同层循环,降低栈深度。 -
computed 没有 scheduler:它的"调度"是"置
DIRTY+ 通知下游的 scheduler"。求值永远是同步的(在读取时),通知才是批处理的。 -
只读 vs 可写的唯一差别是
setter:可写 computed 的set只是把值转发给用户 setter,不改缓存、不 notify;"写了不生效"的根因就在这里。 -
watch 的四入口归一:
watch/watchEffect/watchPostEffect/watchSyncEffect→doWatch→baseWatch。reactivity 层做源归一化与 effect 内核,runtime-core 层注入call(错误处理)、scheduler(flush)、augmentJob(flags/id)。 -
flush 三态:
pre走queueJob、post走queuePostRenderEffect、sync不设 scheduler。pre watcher 与组件更新 job 同 id,PRE标志保证 watcher 先于 render。 -
traverse用读取动作建立依赖:seen防循环、按类型分派、深度可控(deep: 1)。deep必须显式时是因为"getter 返回同引用对象,hasChanged恒 false"。 -
两套 cleanup:
onEffectCleanup(单槽,随effect.run()重置)与onWatcherCleanup/onCleanup(数组栈,在下一轮 cb 前 / stop 时执行)。竞态请求的取消标记就靠它。 -
组件自动 stop 的链路:
setCurrentInstance → instance.scope.on()让组件 detached scope 成为活跃 scope →new ReactiveEffect时被 push 进scope.effects→ unmount 时scope.stop()。async setup 的乱序恢复由off()的链中摘除逻辑加固。 -
EffectScope只收集ReactiveEffect,不收集 computed;stop()按"effects → cleanups → 子 scopes"顺序批量销毁,并用尾部填补实现 O(1) 摘除。detached让它独立于父 scope(组件正是用它)。 -
递归上限 100:
checkRecursiveUpdates在 dev 下对同一 job 计数,超过RECURSION_LIMIT抛Maximum recursive updates;ALLOW_RECURSE允许自触发但不豁免该限制。
附:面试深度问答与 3.4 差异速查
Q1:为什么 effectScope.stop() 停不掉一个 computed?
因为 computed 不是 ReactiveEffect,不进 scope.effects。它的存亡由"有无下游订阅者"决定:下游被 stop 后,removeSub 对其上游做软退订,computed 随即失去通知来源而被冻结(dep.ts:214 / effect.ts:451-459)。
Q2:为什么 watch 回调里读到的响应式数据不会变成依赖?
job 在调 cb 前执行 pauseTracking()(watch.ts:255),把 shouldTrack 置 false,dep.track() 因 !shouldTrack 直接返回(dep.ts:109)。否则"回调读什么就 watch 什么",会造成隐式依赖与意外重触发。
Q3:3.4 与 3.5 的 computed 有什么本质区别?
3.4:computed 内部持一个 ReactiveEffect,用 _dirty 布尔 + _deps 数组缓存,并靠 trigger 手动调度重算。3.5:ComputedRefImpl 直接实现 Subscriber,用 deps/depsTail 双向链表 + flags 位集 + globalVersion 三级闸门;求值路径不再有独立的 effect 对象与 run 栈,且通知级联由 Dep.notify 同层循环完成。
Q4:为什么 pre watcher 要和组件更新 job 共用 id?
为了让调度器用同一个 id 把两者排在一起,再由 PRE 标志让 watcher 插到更新 job 之前(scheduler.ts:87-94),从而保证"回调先于渲染",且当父组件更新卸载了该组件时,watcher 能被一并跳过。
Q5:deep: true 为什么必须显式写?
因为 traverse() 返回的是同一对象引用,hasChanged(new, old) 恒为 false;而触发条件里 deep 参与短路(watch.ts:249),只有显式 deep 才能绕过引用相等检查并强制回调。
本章完。 下一章(05)转向编译管线全景与
tokenizer/parser。