Vue 3 核心源码深度剖析系列(v3.5.44)-第 4 章 响应式内幕(三):computed / watch/ effectScope与调度

4 阅读54分钟

Vue 3 核心源码深度剖析系列(v3.5.44)-第 4 章 响应式内幕(三):computed / watch / effectScope 与调度

本章面向已读完第 02 章(effect 与依赖追踪)与第 03 章(reactive / ref 代理)的读者。 如果说第 02 章回答了"依赖是怎么被记录下来的",第 03 章回答了"读写是怎么被拦截的", 那么本章要回答的是三个更高层的问题:

  1. computed 为什么能"只在被读时才计算"、并且"没人读就不算"?(惰性 + 缓存 + 失效)
  2. watch 的一次回调到底是谁、在什么时机、被谁调用起来的?(构造 + 调度 + flush)
  3. 一堆 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? 三个收益:

  1. 零分配复用:依赖集合不变时,prepareDeps 把每个 link.version 置 -1,运行时 dep.track() 若发现 link.version === -1 就只更新版本号并把它"挪到尾部",不 new 新对象(dep.ts:127-151)。
  2. 访问顺序保序:链表按"本次求值中依赖被访问的顺序"重排,cleanupDeps 后 deps 头尾稳定。
  3. 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<<0ACTIVEcomputed 本身不直接用它做"停止",但 endBatch 会用它过滤 ReactiveEffect
1<<1RUNNING正在执行 fn,用于防止自递归 notify
1<<2TRACKING是否已接入订阅体系:只有被真实读取过(有下游订阅者)才置位
1<<3NOTIFIED已在本次批处理中被 notify 过,防止重复入队
1<<4DIRTY结果可能失效,需要重算
1<<7EVALUATED曾经成功求值过至少一次,用于 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 &#34;1&#34; --> &#34;1&#34; Dep : dep (输出)
  Dep &#34;1&#34; --> &#34;*&#34; Link : subs (谁读我)
  ComputedRefImpl &#34;1&#34; --> &#34;*&#34; 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
}

三步,顺序不可交换:

  1. 先 dep.track()——把"当前活跃的 Subscriber"登记为 this.dep 的订阅者(如果当前没有活跃订阅者,track 直接返回 undefined,link 为 undefined)。
  2. 再 refreshComputed(this)——判定是否需要重算,需要则执行 fn。
  3. 最后把 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.4computed._deps = link(...),effect.run(),内部 ReactiveEffect 维护 deps;读取时手动 trackDep 与 cleanupDep
3.5activeSub = 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

这个例子实证了三条结论:

  1. 没有下游订阅者时,源变化不会触发 computed 求值(runs 保持 0)。
  2. 一旦有下游,源变化只是把 computed 标记为脏并调度下游,求值推迟到下游真正读取。
  3. 重复读取不重算,命中闸门 1。

4.3 computed 的依赖标记:_deps 与 Dep 的作用、链式嵌套、为何"没人读就不重算"

4.3.1 Dep 在 computed 体系中的四重职责

职责相关字段/函数说明
版本计数Dep.version每次 trigger +1,供 Link.version 对账
订阅者链表Dep.subs / subsHead / sc谁依赖我;subs 是尾,遍历时从尾向前
computed 反向引用Dep.computedaddSub 据此做"惰性接入 + 级联订阅"
属性级 GCDep.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
  }
}

这是理解"没人读就不重算"最关键的一段。逐点拆:

  1. link.sub.flags & TRACKING:只有"处于追踪态"的订阅者才真正入链。ReactiveEffect 默认带 TRACKING(effect.ts:101);一个被 stop() 的 effect 会清 ACTIVE,虽然 TRACKING 未必清,但它的 deps 已断开,不会再被读到。
  2. computed && !link.dep.subs:!link.dep.subs 表示"这个 computed 的输出 dep 此前没有任何订阅者"。此刻是"第一次有人订阅我"。
  3. computed.flags |= TRACKING | DIRTY:正式宣告"我上线了",并强制脏一次(保证首个订阅者读到的是最新值)。
  4. 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 作为订阅者。

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[&#34;source.value = x&#34;] --> T[&#34;dep.trigger()&#34;]
  T --> T1[&#34;dep.version++&#34;];
  T --> T2[&#34;globalVersion++&#34;];
  T --> N[&#34;dep.notify()&#34;]
  N --> SB[&#34;startBatch()  batchDepth++&#34;]
  N --> L[&#34;遍历 subs 链表(从尾向前)&#34;]
  L --> CN[&#34;computed.notify()&#34;]
  CN --> CN1[&#34;flags |= DIRTY&#34;]
  CN --> CN2{&#34;NOTIFIED? 或 activeSub===this?&#34;}
  CN2 -->|否| BT[&#34;batch(this, true) 入 batchedComputed 链&#34;]
  BT --> RET[&#34;返回 true&#34;]
  RET --> DN[&#34;computed.dep.notify() (级联)&#34;]
  DN --> L2[&#34;遍历 computed 的订阅者&#34;]
  L2 --> EF[&#34;ReactiveEffect.notify()&#34;]
  EF --> B2[&#34;batch(this) 入 batchedSub 链&#34;]
  N --> E[&#34;endBatch()&#34;]
  E --> CL[&#34;清 batchedComputed 的 NOTIFIED&#34;]
  E --> TR[&#34;遍历 batchedSub: effect.trigger() -> scheduler&#34;]

几个必须讲清楚的细节:

(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)标记"需重算"
TRACKINGaddSub 首次有订阅者(dep.ts:214)removeSub 最后一个订阅者离开(effect.ts:454)标记"已接入订阅体系"
NOTIFIEDbatch()endBatch()本次批处理去重
RUNNINGrefreshComputed 求值前(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_isReadonlytrue(!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[&#34;watch(source, cb, opts)&#34;] --> DW[&#34;doWatch (runtime-core)&#34;]
  W2[&#34;watchEffect(fn, opts)&#34;] --> DW
  W3[&#34;watchPostEffect/watchSyncEffect&#34;] --> DW
  DW -->|组装 baseWatchOptions| BW[&#34;baseWatch (reactivity)&#34;]
  BW -->|new ReactiveEffect getter| RE[&#34;ReactiveEffect&#34;]
  BW -->|effect.scheduler = ...| SCH[&#34;scheduler: job&#34;]
  DW -->|baseWatchOptions.scheduler| Q1[&#34;queueJob (pre)&#34;]
  DW -->|baseWatchOptions.scheduler| Q2[&#34;queuePostRenderEffect (post)&#34;]
  DW -->|augmentJob| FL[&#34;SchedulerJobFlags.PRE / ALLOW_RECURSE&#34;]
  RE -->|track| DEP[&#34;Dep&#34;]
  DEP -->|notify -> batch| EB[&#34;endBatch -> effect.trigger -> scheduler&#34;]

分工一览:

关注点归属
源归一化(ref/reactive/数组/getter)reactivity/watch.ts
getter 的 call 包装(错误处理)runtime-core/apiWatch.ts
flush → schedulerruntime-core/apiWatch.ts(组装)落到 reactivity/watch.ts(赋值)
job 的 flags(PRE/ALLOW_RECURSE)与 idruntime-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:

  1. cb 是否存在(reactivity/watch.ts 内以闭包变量区分 job 的两条分支,watch.ts:243-285)。
  2. SchedulerJobFlags(runtime-core 侧):
    • PRE(1<<1):标记这是一个 pre-flush watcher job。
    • ALLOW_RECURSE(1<<2):标记这个 job 允许自触发。
    • QUEUED / DISPOSED。

对照表(两者的差异):

维度watch(有 cb)watchEffect(无 cb)
默认 flushprepre
首次执行立即 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 无 schedulereffect.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)
PREpre 阶段任务,按 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,递归继续
    }
  }
}

关键点:

  1. 主队列 queue 里是组件更新 job 和 pre watcher job,按 id 升序(queueJob 的二分插入)混排。
  2. 主队列遍历完后才 flushPostFlushCbs()——post 天然在所有 pre 之后。
  3. 组件更新本身也走 pre 阶段:组件 render effect 的 job 没有 PRE 标记,但它在主队列里,其 id 决定它与其他 pre watcher 的相对顺序。因为 pre watcher 与同组件同 id 且 PRE 标志使其插在更新 job 前,所以相对顺序是"本组件的 pre watcher → 本组件 render → ..."。
  4. currentFlushPromise 是 nextTick() 的依据;flush 结束后置 null。

4.6.7 组件更新 job 与 pre watcher 的关系图

flowchart LR
  subgraph 主队列 queue
    A[&#34;pre watch job (id=uid)&#34;]
    B[&#34;component update job (id=uid)&#34;]
    C[&#34;另一个组件 pre job (id=uid2)&#34;]
    D[&#34;另一个组件 update (id=uid2)&#34;]
  end
  A --> B --> C --> D
  D --> P[&#34;flushPostFlushCbs: post 队列&#34;]

图中顺序是排序后的理想态:同 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 里:

  • sync watcher 会被收集到 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 做了两件事:

  1. reactiveGetter 里 if (deep) return source(不 traverse),改由 watch.ts:207-211 的包装 getter traverse(baseGetter(), depth) 统一遍历——对 getter 返回的 obj.nested 做深度遍历,建立对 count 的依赖。
  2. 触发条件里 deep 为真,绕过 hasChanged,强制回调。

这就是源码注释里 deep 被写进触发条件的原因:"because traverse returns the same object, hasChanged would always be false"。

4.7.3 deep 的三种取值与"只侦测一层"

3.5 引入了 deep: number:

写法depth效果
deep: trueInfinity全深度遍历
deep: 11只遍历根对象的直接属性(不深入子对象)
deep: 0 / deep: false1(走 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.valueundefinedhasChanged.value 内部属性改不触发
shallow ref.valueundefinedforceTrigger总是触发
reactive源对象(或 traverse 后)undefinedforceTriggernew === old
多源数组值数组[].some(hasChanged)长度固定
getter 返回原始值原始值undefinedhasChanged—
getter 返回对象同引用对象undefinedhasChanged(恒 false)→ 需 deep不 deep 就不触发
deep: true同引用对象undefineddeep 强制触发回调频繁

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 cleanuponEffectCleanupReactiveEffect.cleanup(单槽)每次 effect.run() 开头 / stop()reactivity/effect.ts
Watcher cleanuponWatcherCleanup / onCleanupcleanupMap: 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 的三种触发时机

  1. watchEffect 每次重跑前:getter 开头 if (cleanup) cleanup()(watch.ts:183)。这会在每次副作用执行前清掉上一次的异步残留。
  2. watch 每次 cb 调用前:job 里 if (cleanup) cleanup()(watch.ts:258)。
  3. 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可用范围备注
第三个参数 onCleanupwatch(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 步:

  1. 读取 options(immediate/deep/flush/once)。
  2. dev 下校验 watchEffect 误传 immediate/deep/once 并告警。
  3. 组装 baseWatchOptions,注入 onWarn 与 call。
  4. 计算 runsImmediately;SSR 分支处理。
  5. 组装 scheduler(按 flush)。
  6. 组装 augmentJob(打 flags/id)。
  7. 调 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,它的作用有三:

  1. 错误归属:如上,call 包装。
  2. pre job 的排序 id:job.id = instance.uid(apiWatch.ts:229),决定 pre watcher 与组件更新的相对顺序。
  3. 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), activeEffectScope is already B instead of this scope A when A's cleanup calls off().

即:两个并发的 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 是否可运行、可 stopactive getter 暴露;stop 置 false
_onon() 的引用计数on/off
effects收集的 ReactiveEffectReactiveEffect 构造时 push;stop 时遍历 stop;watch stop 时 remove
cleanupsonScopeDispose 回调onScopeDispose push;stop 时执行
_isPaused是否被 pause() 冻结pause/resume
parent父 scope构造时设置;stop 时置 undefined
scopes子 scope 数组构造时 push;stop 时批量 stop
index在父数组中的下标构造/删除时维护
detached是否游离于父 scope 链构造参数;影响是否挂 parent、stop 时是否从父移除
__v_skip免疫 traverse/proxyReactiveFlags.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
  }
}

逐点:

  1. 顺序:先停 effects → 再跑 cleanups → 最后停子 scope。
  2. fromParent 参数:父 scope 调 child.stop(true) 时传 true,表示"由父统一处理,子不需要再回父的数组里删自己"——因为父随后会 this.scopes.length = 0 整片清掉。避免在遍历子数组时改动父数组。
  3. O(1) 删除优化:未 detached 的 scope 有 index。stop 时用"尾部元素填补被删位置"的经典技巧(pop 取出 last,若不是自己就移到 index 处),保持数组紧凑,避免 splice 的 O(n)。
  4. this.parent = undefined:解除引用,防内存泄漏。
  5. 幂等: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 之后)

要点:

  1. computed 的重算发生在 computed 的 watch 的 job 执行期间(effect.run() 读 c.value 时)。也就是说,watch(c) 的 getter 是 computed 的"读取者",computed 在这里被重新求值。
  2. pre 队列顺序:watcher 与组件更新 job 同 id,PRE 标志让 watcher 插在前面。因此 cb1、cb2 都在 render 之前。
  3. 两个同 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 ===

读出三条铁律:

  1. sync 发生在赋值语句(=== set s = 1 === 之后、=== before nextTick === 之前),还没有 render。
  2. pre 发生在 render 之前,且它读 c.value 会触发 computed 重算([computed recompute] s = 1 出现在 [pre] 之前)。
  3. 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 返回对象,未加 deepwatch.ts:250-252 的 hasChanged 为 false加 { deep: true }
源是 reactive 对象但只改了深层属性且 getter 只读了根依赖未建立用 deep 或精确 getter
源是 ref(obj) 而改的是 obj.x普通 ref 只 track .value改 ref 为 reactive,或深度 getter
在 setup 外/异步回调里创建,activeEffectScope 已 offeffect.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 只触发一次

症状:之后源怎么变都不再触发。

归因:

  1. once: true:watch.ts:221-230 在 cb 的 finally 里 watchHandle()。检查是否误传。
  2. 回调里抛错导致 effect 停止? 不会——watch 回调抛错只走 call 的错误处理,不 stop effect。但若 stop() 被手动调用则停。
  3. immediate + 逻辑分支 return:若首次 immediate 回调里因条件提前 return(你没调 stop),后续仍会触发——所以不是这个。
  4. effect 因 !effect.dirty 被跳过:若每次改动的值 hasChanged 都为 false(如每次都赋相同值,或对象引用不变),job 开头的守卫会 return。检查 forceTrigger/deep 是否覆盖。
  5. 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_KEY3.5 已支持(dep.ts ITERATE_KEY);旧属性删除后重加也可
SSR 环境下 computed 无 render effecteffect.ts:398-404 的 !isSSR 条件SSR 依赖 globalVersion 快路径
getter 里有副作用/随机值缓存语义不该在 computed 里做副作用
在 getter 里读了一个 ShallowRefshallow 只追踪 .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。

常见成因:

  1. 组件 render 里改了自己的依赖(如 render 函数里写 state.x = ...)。
  2. watch(post) 里改渲染依赖,且值不收敛。
  3. computed 的 getter 里改状态(让 computed 变成副作用源)。
  4. updated 钩子里改状态。

为什么有 ALLOW_RECURSE 还会告警? ALLOW_RECURSE 允许"入队自己",但不豁免递归检测。它只是让 QUEUED 标志在 flushJobs 里被保持得更宽松,真正的次数限制仍由 checkRecursiveUpdates 兜底。

修复:让状态变更收敛(加条件守卫 if (state.x !== want) state.x = want),或把副作用移出 render/computed,或用 nextTick 打断同步递归。

4.13 本章要点回顾

  1. ComputedRefImpl 是"二象性"对象:dep 是"别人依赖我"(下游订阅),deps 是"我依赖别人"(上游订阅)。3.5 用 deps/depsTail 双向链表取代了 3.4 的 _deps,用 flags 位集取代了 _dirty 布尔;effect 字段只作向后兼容的自引用。

  2. 惰性 + 三级缓存闸门:refreshComputed 依次检查 TRACKING && !DIRTY(稳态)、globalVersion(全局无变化)、EVALUATED && !isDirty(依赖对账)。任一命中即返回缓存,只有全部穿透才真正执行 getter。

  3. addSub 的惰性级联是缓存的关键:computed 只有第一次获得下游订阅者时才置 TRACKING 并把上游依赖逐个挂上订阅链;下游全部离开时 removeSub 做"软退订"(保留 deps 引用,只摘订阅)。这就是"没人读就不重算"的实现。

  4. computed 链式传播靠 notify() 返回 true:Dep.notify 遍历订阅者时,若对方是 computed 就顺带通知它的 dep——把递归栈改成同层循环,降低栈深度。

  5. computed 没有 scheduler:它的"调度"是"置 DIRTY + 通知下游的 scheduler"。求值永远是同步的(在读取时),通知才是批处理的。

  6. 只读 vs 可写的唯一差别是 setter:可写 computed 的 set 只是把值转发给用户 setter,不改缓存、不 notify;"写了不生效"的根因就在这里。

  7. watch 的四入口归一:watch/watchEffect/watchPostEffect/watchSyncEffect → doWatch → baseWatch。reactivity 层做源归一化与 effect 内核,runtime-core 层注入 call(错误处理)、scheduler(flush)、augmentJob(flags/id)。

  8. flush 三态:pre 走 queueJob、post 走 queuePostRenderEffect、sync 不设 scheduler。pre watcher 与组件更新 job 同 id,PRE 标志保证 watcher 先于 render。

  9. traverse 用读取动作建立依赖:seen 防循环、按类型分派、深度可控(deep: 1)。deep 必须显式时是因为"getter 返回同引用对象,hasChanged 恒 false"。

  10. 两套 cleanup:onEffectCleanup(单槽,随 effect.run() 重置)与 onWatcherCleanup/onCleanup(数组栈,在下一轮 cb 前 / stop 时执行)。竞态请求的取消标记就靠它。

  11. 组件自动 stop 的链路:setCurrentInstance → instance.scope.on() 让组件 detached scope 成为活跃 scope → new ReactiveEffect 时被 push 进 scope.effects → unmount 时 scope.stop()。async setup 的乱序恢复由 off() 的链中摘除逻辑加固。

  12. EffectScope 只收集 ReactiveEffect,不收集 computed;stop() 按"effects → cleanups → 子 scopes"顺序批量销毁,并用尾部填补实现 O(1) 摘除。detached 让它独立于父 scope(组件正是用它)。

  13. 递归上限 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。