Vue 3 核心源码深度剖析系列(v3.5.44)-第 2 章 响应式内幕(一):`effect` 与依赖追踪机制

Vue 3 核心源码深度剖析系列(v3.5.44)-第 2 章 响应式内幕(一):effect 与依赖追踪机制

本章对应源码:packages/reactivity/src/effect.ts(588 行)、packages/reactivity/src/dep.ts(397 行), 并交叉引用 reactive.ts、computed.ts、effectScope.ts、constants.ts、warning.ts、baseHandlers.ts。 所有行号基于 vuejs/core@3.5.44,代码引用格式为 路径:行号,可逐条跳转核对。

导语

如果把 Vue 3 的响应式系统比作一座城市,那么 reactive / ref / computed / watch 只是这座城市的"居民视角"------它们看起来是一些普通对象、.value 访问、函数调用。真正支撑这座城市的,是两套几乎从不抛头露面的底层结构:

  1. ReactiveEffect:一个"自动重跑的函数包装器"。它把任意一段读取响应式数据的函数变成"一旦依赖变化就重新执行"的订阅者。
  2. Dep / Link :一套精妙的双向链表 + 版本号依赖图。它同时维护"effect 依赖了哪些 dep"和"dep 被哪些 effect 订阅"两条链路,并用版本号把"这一轮到底读了什么、没读什么"记录下来。

Vue 3.5 对这套系统做了大规模重写 。3.4 及以前,effect.deps 是一个 Dep[] 数组(响应式 effect 集合),dep 自己是一个 Set<ReactiveEffect>,track 靠"先清空数组再重新收集"(cleanupEffect)来淘汰旧依赖,trigger 靠遍历 Set 去重。3.5 彻底换成了双向链表 Link :依赖关系不再存成数组,而是存成一串可 O(1) 插入/删除的节点;淘汰旧依赖不再"清空重建",而是靠 prepareDeps 把旧节点版本标记为 -1、cleanupDeps 再按版本号回收;去重不再靠 Set,而是靠 EffectFlags 的位标志与 depsTail 的"尾部对齐"。

这场重写带来三个可量化的收益:

  • 内存 :一条依赖关系从"数组元素 + Set 条目 + 可能的 Dep 对象"压缩成一个 Link 对象 (6 个指针字段 + 1 个数字),并让 target -> key -> Dep 的 Map 能在属性无人订阅时自我回收(dep.ts:463-469,issue #11979)。
  • 时间 :track 从"每轮数组 indexOf O(n)"变成"O(1) 尾插 + 版本号复用";trigger 从"遍历 Set + 逐个去重"变成"链表尾逆序推送 + 位标志去重"。
  • 正确性 :版本号 Link.version 与 Dep.version 的引入,让"深度相等但值未变"的 computed 能走 globalVersion 快路径(effect.ts:387-389),避免无效重算。

本章的目标,就是把这套机制逐字段、逐函数、逐行拆开讲透。读完你应该能回答这些问题:

  • 为什么 ReactiveEffect 同时需要 deps 和 depsTail?为什么 deps 是个链表而不是数组?
  • track 里那个 link.version = -1 到底是谁设的、什么时候校验、什么时候回收?
  • trigger 为什么要"从链表尾往前遍历"(link.prevSub)?这和"从前往后"在读侧有什么区别?
  • startBatch / endBatch 与 EffectFlags.NOTIFIED 如何合力避免"三角依赖"下同一个 effect 被跑多次?
  • 一个自增计数器 count.value++ 为什么不会无限递归?notify() 里 RUNNING 标志究竟拦住了什么?
  • activeSub 这个 let 变量为什么必须用 finally 恢复?嵌套 effect 里它扮演什么角色?

我们从"Vue 为什么非要一套响应式"这个更根本的问题开始。


2.1 为什么需要响应式:三条技术路线

2.1.1 问题的本质:从"我需要更新"到"数据变了通知我"

任何 MVVM / 声明式 UI 框架,最终都要回答同一个工程问题:

数据变了,如何知道"哪些视图需要重算"?

在命令式 DOM 编程里,这个问题的答案是程序员手动给的:

js 复制代码
count++
document.getElementById('counter').textContent = count  // 手动同步

问题在于:一旦 UI 结构复杂、派生关系嵌套(a -> b -> c -> d),"手动同步"会迅速失控------漏一处就是脏数据、多一处就是无谓开销。声明式框架的野心,是把"谁依赖谁"这件事自动化:让"读数据"这个动作本身携带信息,框架悄悄记下"谁读了它",数据一写就精确通知那批读者。

于是所有现代框架都落到了同一套核心抽象上:

text 复制代码
订阅者(Subscriber)  ──读完──▶  数据源(Source)
       ▲                             │
       └──────── 变更通知 ───────────┘

差别只在于:"通知"和"读取"这两端,分别由谁、在什么时机负责。由此分化出三条主流路线。

2.1.2 路线一:Object.defineProperty(Vue 2)

Vue 2 在初始化阶段 递归遍历对象,把每个属性用 Object.defineProperty 换成带 get/set 的访问器属性:

js 复制代码
// Vue 2 的 defineReactive 简化示意
Object.defineProperty(obj, 'count', {
  get() {
    dep.depend()          // 读取时:把当前 watcher 收进 dep
    return value
  },
  set(newVal) {
    if (newVal === value) return
    value = newVal
    dep.notify()          // 写入时:通知 dep 里所有 watcher
  },
})

要点与代价:

维度 表现
拦截时机 初始化时一次性劫持,之后读写都被拦截
新增/删除属性 拦截不到 。obj.newKey = 1、delete obj.key 不触发 set,这就是 Vue.set / Vue.delete 存在的根本原因
数组 索引赋值与 length 修改拦不住,只能重写 7 个变更方法 (push/pop/shift/unshift/splice/sort/reverse)屏蔽原生行为
Map/Set 完全不支持(defineProperty 对集合无意义)
递归成本 初始化时深度递归 ,大对象/大数组启动开销高;Object.freeze 直接反优化
类型覆盖 只能作用于对象属性,无法代理整个对象

Vue 2 用"初始化即劫持 + 重写数组方法 + Vue.set 兜底"补齐了所有窟窿,代价是侵入原生对象形态 (__ob__ 挂在对象上)、无法支持集合类型、以及惰性初始化困难。

2.1.3 路线二:Proxy(Vue 3)

ES2015 的 Proxy 把拦截提升到对象级别 :不是改写属性,而是包一层代理,任何 get / set / has / deleteProperty / ownKeys 等操作都进你的 trap。

packages/reactivity/src/reactive.ts:300-303 是整个 Vue 3 响应式的"总闸门":

ts 复制代码
const proxy = new Proxy(
  target,
  targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
)
proxyMap.set(target, proxy)
return proxy

reactive.ts:43-56 用 targetTypeMap 决定"挂哪套 handler":

ts 复制代码
function targetTypeMap(rawType: string) {
  switch (rawType) {
    case 'Object':
    case 'Array':
      return TargetType.COMMON      // → baseHandlers
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION  // → collectionHandlers
    default:
      return TargetType.INVALID     // → 原样返回,不代理
  }
}

相比 defineProperty 路线,Proxy 的好处是决定性的:

维度 Vue 2 (defineProperty) Vue 3 (Proxy)
拦截层级 属性级 对象级(操作级)
新增/删除属性 拦不住 set/deleteProperty 直接拦截(TriggerOpTypes.ADD/DELETE)
数组索引/length 拦不住 原生拦截,连 arr[100] = x 都行
Map/Set/WeakMap/WeakSet 不支持 独立 collectionHandlers 全套支持
惰性 初始化递归劫持 按需 :只有真正读到嵌套对象时才 reactive(res)(baseHandlers.ts:126-130)
侵入性 挂 __ob__ 等内部标记 不动原对象,只产出代理
兼容性 IE9+ 无法 polyfill(IE 全系不支持)

最后一行也是 Vue 3 放弃 IE 的技术根因------Proxy 无法被 polyfill 降级。

2.1.4 路线三:编译期信号(Solid / Svelte 5 / Qwik)

第三条路线把"依赖追踪"从运行时 搬到编译期 。以 Solid 为例,它的 createSignal / createEffect 同样是运行时结构,但编译器会分析 JSX/模板里"读了哪些 signal",把订阅关系静态确定 、把更新代码内联,运行时几乎不做动态追踪的 bookkeeping:

jsx 复制代码
// Solid:编译器知道 count 被 read,也知道订阅者的位置
const [count, setCount] = createSignal(0)
createEffect(() => console.log(count()))

这条路线的是细粒度、无虚拟 DOM、编译期闭包收集,运行时开销极低、内存占用小,代价是:

  • 强依赖编译器,逻辑必须"可静态分析",动态读取(如 obj[k] 动态键)难以追踪;
  • 依赖收集在首次执行时确定后基本固定(无 Vue 那种"每轮重新收集、淘汰未读依赖"的动态依赖);
  • 生态与心智模型与 React/Vue 差异较大。

2.1.5 三条路线的取舍:Vue 3 为什么选"运行时 Proxy + 动态收集"

把三条路线放进一张对照表:

维度 defineProperty (Vue2) Proxy + 运行时收集 (Vue3) 编译期信号 (Solid)
收集时机 初始化劫持 每次执行动态重收集 首次执行后基本静态
依赖淘汰 每轮重建数组 版本号 + 链表回收 少见
动态属性 obj[k] 不支持 天然支持 难以静态分析
集合类型 无 完整支持 部分
运行时开销 中(Dep 数组遍历) 中低(链表 O(1) + 位标志去重) 最低
编译器耦合 弱 弱(模板可选优化) 极强
心智模型 侵入式 代理透明,最"像 JS" 需学响应式原语
代表 Vue2 Vue3 / MobX / Reactively Solid / Svelte5 / Qwik

Vue 3 的选择逻辑清晰:

  1. 保留动态性 :模板里 obj[key]、list[i]、条件分支下不同依赖是常态,运行时收集才能覆盖;
  2. 拥抱代理透明 :reactive(obj) 后像普通对象一样用,obj.a.b.c = 1 全链路追踪,学习成本最低;
  3. 用工程手段压开销 :既然选了运行时收集,就在数据结构上极致优化------这正是本章 Link 双向链表 + EffectFlags 位标志 + batch 批处理的全部意义;
  4. 可 polyfill 性与生态:不惜放弃 IE,换取集合类型与新语义。

一句话总结 :Vue 3 选择了"运行时 Proxy 收集 + 编译期可选优化 "的折中路线------用 Proxy 换动态性与表达力,用双向链表换运行时开销,用编译器(后续章节的 Block 树、patchFlag)在另一维度继续省。

2.1.6 三个术语先立住:track、trigger、Subscriber

后面所有代码都围绕这三个词:

  • Subscriber(订阅者) :任何"依赖了别人、需要被通知"的东西。在 3.5 里既包括 ReactiveEffect(effect/watcher/渲染函数),也包括 ComputedRefImpl(computed)。接口定义见 effect.ts:58-83。
  • track(依赖收集) :读数据时发生,把"当前订阅者"写进"这个数据对应的 Dep"里。实现见 dep.ts:262-284。
  • trigger(变更触发) :写数据时发生,找出"这个数据对应的 Dep"里的所有订阅者,按规则通知它们。实现见 dep.ts:294-389。

读 → track → 建立订阅关系;写 → trigger → 消费订阅关系。本章 2.5 / 2.6 会分别逐行走读这两条主链路。


2.2 总体架构:从 reactive 到双向链表的一条闭环

先用一张"全景闭环图"把读者传送带连起来。注意:本节只负责给地图,不给细节------每个方框都会在 2.3~2.7 展开。

2.2.1 数据流全景图

flowchart TB subgraph 用户层[&#34;用户层(等价于 2.1 的"读/写")&#34;] U1[&#34;effect(() => { console.log(obj.count) })&#34;] U2[&#34;obj.count++&#34;] end subgraph 代理层[&#34;代理层 packages/reactivity/src/reactive.ts&#34;] P[&#34;reactive(obj) → new Proxy(target, baseHandlers)&#34;] G[&#34;baseHandlers.get(target, 'count')\nbaseHandlers.ts:55-131&#34;] S[&#34;createSetter(...) → trigger()\nbaseHandlers.ts:set&#34;] end subgraph 追踪层[&#34;追踪层 packages/reactivity/src/dep.ts&#34;] T[&#34;track(target, 'get', 'count') dep.ts:262&#34;] TM[&#34;targetMap: WeakMap> dep.ts:240&#34;] D[&#34;Dep { version, activeLink, subs, sc, map, key } dep.ts:67&#34;] L[&#34;Link { version, nextDep, prevDep, nextSub, prevSub } dep.ts:32&#34;] TR[&#34;trigger(target, 'set', 'count') dep.ts:294&#34;] end subgraph 订阅层[&#34;订阅层 packages/reactivity/src/effect.ts&#34;] E[&#34;ReactiveEffect { deps/depsTail, flags, scheduler, ... } effect.ts:87&#34;] A[&#34;activeSub(当前订阅者) effect.ts:39&#34;] B[&#34;batch / startBatch / endBatch effect.ts:247-310&#34;] end U1 --> P P --> G G -- &#34;读 count&#34; --> T T --> TM TM --> D D -- &#34;new Link(activeSub, dep)&#34; --> L L -- &#34;deps/depsTail 尾插&#34; --> E E -. &#34;run() 时 activeSub = this&#34; .-> A A --> T U2 --> P P --> S S --> TR TR --> D D -- &#34;遍历 subs(link.prevSub 逆序)&#34; --> B B -- &#34;标志去重 + 异步/同步调度&#34; --> E E -- &#34;runIfDirty → run() → 重新 track&#34; --> G

这张图里最关键的闭环是右下角那条虚线:

text 复制代码
effect.run()  ── 设置 activeSub = this ──▶  fn() 执行中读到 obj.count
        ▲                                            │
        │                                            ▼
   runIfDirty() ◀── trigger → notify → batch ◀── track 把 this 记进 Dep

读 建立了订阅关系,写消费订阅关系触发重跑,重跑时又"重新读一遍"刷新订阅关系。这就是"闭环"。

2.2.2 三个核心数据结构的关系

整个响应式系统的存储只有三层,dep.ts:234-240 的注释说得很直白:

ts 复制代码
// The main WeakMap that stores {target -> key -> dep} connections.
// Conceptually, it's easier to think of a dependency as a Dep class
// which maintains a Set of subscribers, but we simply store them as
// raw Maps to reduce memory overhead.
type KeyToDepMap = Map<any, Dep>
export const targetMap: WeakMap<object, KeyToDepMap> = new WeakMap()
层级 类型 键 / 值 生命周期 说明
第 1 层 targetMap: WeakMap<object, Map> target 对象 → 属性 Map 随 target 被 GC 弱引用,不阻止 target 回收
第 2 层 KeyToDepMap: Map<any, Dep> key(属性名/ITERATE_KEY/索引)→ Dep 无人订阅时删除(dep.ts:463-469) 每个响应式属性一个 Dep
第 3 层 Dep.subs: Link 该 dep 的订阅者链尾节点 链表增删 通过 Link 反向串起所有订阅者

而 Link(dep.ts:32-62)是唯一同时属于"两个维度"的节点:

ts 复制代码
export class Link {
  version: number          // 快照:建立/复用时 dep.version 的值
  nextDep?: Link           // 属于 sub 的 deps 链:下一个依赖
  prevDep?: Link           // 属于 sub 的 deps 链:上一个依赖
  nextSub?: Link           // 属于 dep 的 subs 链:下一个订阅者
  prevSub?: Link           // 属于 dep 的 subs 链:上一个订阅者
  prevActiveLink?: Link    // 计算期间的临时暂存(prepareDeps 用)
  constructor(public sub: Subscriber, public dep: Dep) {
    this.version = dep.version
    this.nextDep = this.prevDep = this.nextSub = this.prevSub = this.prevActiveLink = undefined
  }
}

一个 Link = 一根"关系线"的两端 :它左手(sub)握着一个订阅者,右手(dep)握着一个数据源;同时它又是两条链表的交汇节点 ------沿 nextDep/prevDep 走会串出"这个订阅者读过的全部数据",沿 nextSub/prevSub 走会串出"这个数据被哪些订阅者读过"。

这是理解整章最重要的一句话。后面 2.7 讲的"交换式复用"、2.5 讲的"尾插与搬尾",全都是围绕"同一节点要在两条链上同时正确"这件事展开的。

2.2.3 从 targetMap 取 Dep 的两条入口

整个系统只有两个函数 会去碰 targetMap,一读一写:

ts 复制代码
// 入口 1:track ------ 读侧,dep.ts:262-284
export function track(target: object, type: TrackOpTypes, key: unknown): void {
  if (shouldTrack && activeSub) {
    let depsMap = targetMap.get(target)
    if (!depsMap) targetMap.set(target, (depsMap = new Map()))
    let dep = depsMap.get(key)
    if (!dep) {
      depsMap.set(key, (dep = new Dep()))
      dep.map = depsMap   // 反向指针,供无人订阅时自删
      dep.key = key
    }
    // __DEV__ 时带上 debugInfo 调用 dep.track(...)
    ...
  }
}
ts 复制代码
// 入口 2:trigger ------ 写侧,dep.ts:294-307
export function trigger(target, type, key, newValue, oldValue, oldTarget): void {
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    // never been tracked
    globalVersion++
    return
  }
  ...
}

注意 trigger 开头那个旁路分支 :如果这个 target 从来没被读过(depsMap 不存在),直接 globalVersion++ 然后返回。为什么要 globalVersion++?因为虽然没人订阅这个 target,但可能有 computed 依赖了 globalVersion 作为"全局是否变化"的快路径(effect.ts:387-389)------见 2.6 与 2.4 的展开。

2.2.4 与 computed 的关系:订阅者也可以是订阅源

初学者常以为"dep 订阅 effect,effect 单向订阅"。实际上 3.5 里 ComputedRefImpl 既是订阅者又是订阅源 (computed.ts:47-93):

  • 它作为订阅者 :deps / depsTail / flags 字段与 ReactiveEffect 同构,能 subscribe 别人的 dep;
  • 它作为订阅源 :自己持有一个 readonly dep: Dep(computed.ts:55),能被别人 subscribe。

这带来一个特殊链路:Dep.notify 遍历 subs 时,调 link.sub.notify(),若返回 true(表示"我是 computed"),再额外调用 computed.dep.notify() (dep.ts:193-200)。这样 computed 变化能顺链传播到更下游。这就是 2.6 要讲的"NOTIFY 后的通知顺序"里最关键的一环。

2.2.5 全局变量速查表

整章会反复出现这几个模块级 let / WeakMap,先集中列出(后文引用时可回查):

变量 位置 类型 语义
activeSub effect.ts:39 `Subscriber undefined`
shouldTrack effect.ts:519 boolean 全局追踪开关(pauseTracking 可临时关闭)
batchDepth effect.ts:247 number 批处理嵌套深度,归零才执行
batchedSub effect.ts:248 `Subscriber undefined`
batchedComputed effect.ts:249 `Subscriber undefined`
pausedQueueEffects effect.ts:85 WeakSet<ReactiveEffect> 暂停期间被通知、恢复时要补跑的 effect
globalVersion dep.ts:19 number 每次响应式变更自增,computed 快路径
targetMap dep.ts:240 WeakMap 依赖总表
activeEffectScope effectScope.ts:4 `EffectScope undefined`
trackStack effect.ts:520 boolean[] shouldTrack 的保存栈

2.2.6 本节的"读代码路线"

如果你打开 effect.ts 想顺着读,推荐这条顺序(后续各节严格按此展开):

text 复制代码
1. 类与接口骨架:Subscriber(58-83) → EffectFlags(41-53) → ReactiveEffect(87-228)
2. 位标志语义:2.4 展开 EffectFlags
3. 单次执行:run()(162-192) → 拆出 prepareDeps(312) / cleanupDeps(324)(2.7)
4. 收集:dep.ts track(262) → Dep.track(108) → Link 尾插(2.5)
5. 触发:dep.ts trigger(294) → Dep.trigger(167) → Dep.notify(173) → Subscriber.notify(150)(2.6)
6. 批处理:batch(251) / startBatch(265) / endBatch(273)(2.9)
7. 生命周期:stop(194) / pause(133) / resume(137) / onEffectCleanup(558)(2.10)
8. 调试:activeSub / getCurrentScope / onTrack / onTrigger(2.12)

下一节,我们从 ReactiveEffect 的每个字段开始,回答"这个字段是谁写的、谁读的、值域是什么、什么时候变"。


2.3 ReactiveEffect 全字段剖析

2.3.1 类骨架:它同时是"订阅者"和"可执行实体"

先看完整声明(effect.ts:87-131,已按字段分组):

ts 复制代码
const pausedQueueEffects = new WeakSet<ReactiveEffect>()   // effect.ts:85

export class ReactiveEffect<T = any>
  implements Subscriber, ReactiveEffectOptions
{
  deps?: Link = undefined            // 依赖链头(effect 读过哪些 dep)
  depsTail?: Link = undefined        // 依赖链尾(最近读的那个 dep)
  flags: EffectFlags = EffectFlags.ACTIVE | EffectFlags.TRACKING  // 位标志状态机
  next?: Subscriber = undefined      // 批处理单链表指针
  cleanup?: () => void = undefined   // 下一次 run 前/stop 时要执行的清理

  scheduler?: EffectScheduler = undefined   // 触发时的调度器(不设则同步跑)
  onStop?: () => void
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void

  constructor(public fn: () => T) { ... }    // effect.ts:116-131
  ...
}

三类身份叠加:

  1. Subscriber(订阅者) :因为 implements Subscriber,它能被别人 track 进 Dep.subs;deps/depsTail/flags/next/notify 五个成员正是 Subscriber 接口要求的(effect.ts:58-83)。
  2. ReactiveEffectOptions(可配置) :因为 implements ReactiveEffectOptions,effect() 工厂能用 extend(e, options) 一次性把 scheduler/allowRecurse/onStop/onTrack/onTrigger 拷进去(effect.ts:493-495)。
  3. ReactiveEffectRunner 的执行载体 :run()(effect.ts:162)就是每次重跑的入口。

设计动机 :让 effect 既是"被通知方"又是"通知方",同一套 deps/flags 结构复用于 effect 与 computed(见 computed.ts:47-93),因此 Dep.notify 里能统一地 link.sub.notify() 而不用分支判类型------只有返回 true 的 computed 才需要额外向下传播(dep.ts:193-200)。

2.3.2 fn:唯一的构造参数,也是"重跑的内容"

属性 值
声明 constructor(public fn: () => T) effect.ts:116
类型 () => T
写者 仅构造期。effect() 里若传入的是 runner,会先解包为原 fn(effect.ts:488-490)
读者 run() 的 try { return this.fn() }(effect.ts:179);stop 后再被调用时的 return this.fn()(effect.ts:167)

effect.ts:488-490 这个"透传"很关键:

ts 复制代码
if ((fn as ReactiveEffectRunner).effect instanceof ReactiveEffect) {
  fn = (fn as ReactiveEffectRunner).effect.fn
}

它允许 effect(runner) 二次包装------runner 本身带 effect 属性,会被自动"拆包"回原函数,避免"effect 套 effect"造成函数不是原始逻辑而依赖收集错乱。

fn 不是 effect 的依赖来源,它只是"每次重跑要执行的动作"。依赖来自 fn 执行期间 incidentally 读到的响应式数据。

2.3.3 deps 与 depsTail:一条不断重排的依赖链

ts 复制代码
deps?: Link = undefined       // effect.ts:93  链头
depsTail?: Link = undefined   // effect.ts:97  链尾

这两个字段是本章的核心数据结构,语义如下:

  • deps 指向该 effect 读过的所有 dep 构成的 Link 双向链表的头;
  • depsTail 指向该链表的尾,即"本轮最近一次 track 命中的 dep 节点"。

为什么必须同时 有头尾?因为在 run() 期间,track 会不断把"本轮新读到的 dep"追加到尾部 (dep.ts:118-124):

ts 复制代码
// add the link to the activeEffect as a dep (as tail)
if (!activeSub.deps) {
  activeSub.deps = activeSub.depsTail = link
} else {
  link.prevDep = activeSub.depsTail
  activeSub.depsTail!.nextDep = link
  activeSub.depsTail = link
}

没有 depsTail,每次尾插都要 O(n) 找尾;有了它,尾插是 O(1) 。而 deps(头)在 cleanupDeps(effect.ts:349-350)里会被重设为"清理后新的头",因此头也必须单独维护。

关键性质 :deps 链表的顺序等于本轮 fn 执行时读取 dep 的顺序 。Dep.track 里那段"搬尾"逻辑(dep.ts:134-150)正是为了维持这一性质------如果本轮复用了旧节点但它不在尾部(说明先后读顺序变了),就把它从原位置摘下来重新挂到尾部:

ts 复制代码
// If this dep has a next, it means it's not at the tail - move it to the
// tail. This ensures the effect's dep list is in the order they are
// accessed during evaluation.
if (link.nextDep) { ...搬到尾部... }

为什么反复强调"顺序 = 读取顺序"?因为 isDirty 与 prepareDeps/cleanupDeps 都是从头/尾顺序扫描的 ,顺序稳定能让"未读依赖"的判定简单化;同时 2.12 的调试(onTrack 顺序)也依赖它。

字段 类型 初值 写者 读者
deps `Link undefined` undefined Dep.track(首次/搬尾)、cleanupDeps、stop
depsTail `Link undefined` undefined Dep.track(尾插/搬尾)、cleanupDeps、stop

2.3.4 flags:取代一堆散装布尔的位状态机

ts 复制代码
flags: EffectFlags = EffectFlags.ACTIVE | EffectFlags.TRACKING   // effect.ts:101

默认同时置 ACTIVE(1)与 TRACKING(4),即"活着 + 参与收集"。全部状态都编码进 flags 这一个数字里,详见 2.4。

对照 3.4 的写法,你会立刻理解这次重构的动机。3.4 的 ReactiveEffect 有独立的布尔/数字字段:

ts 复制代码
// Vue 3.4 风格(示意,非本仓代码)
active = true
allowRecurse = !!options.allowRecurse
onStop?: () => void
/**
 * @internal
 */
dirtyLevel: DirtyLevels = DirtyLevels.NotDirty

而 3.5.44 把这些全部折叠进 flags :ACTIVE/ALLOW_RECURSE/DIRTY/PAUSED...(effect.ts:41-53)。收益:

  • 一个 number 替代多个字段,内存更省;
  • flags & MASK 的位运算比多次属性读取判断更快;
  • 状态可以原子组合 (如 ACTIVE | TRACKING),而分散布尔无法表达组合语义。
字段 类型 读者 写者
flags EffectFlags(number) run、notify、trigger、resume、stop、endBatch、removeSub、addSub、refreshComputed 构造、run(增删 RUNNING)、stop(删 ACTIVE)、pause/resume(增删 PAUSED)、batch(增 NOTIFIED)、endBatch(清 NOTIFIED)、refreshComputed(增删 DIRTY/EVALUATED)

2.3.5 next:批处理队列的"单链表指针"

ts 复制代码
next?: Subscriber = undefined   // effect.ts:105

它不是 依赖链的一部分,只在 batch-endBatch 期间有效:batch(sub)(effect.ts:251-260)用头插 把一个订阅者压进 batchedSub 或 batchedComputed 单链表。

ts 复制代码
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
}

头插 + 遍历时也是头插 (endBatch: let e = batchedSub; batchedSub = undefined; while(e){...})的组合是刻意的,见 2.9 的"逆序通知、顺序执行"讨论。next 在 endBatch 处理时会被置回 undefined(effect.ts:283、296),确保不残留指向。

字段 类型 谁写 谁读
next `Subscriber undefined` batch(头插)

2.3.6 cleanup:注册的"下一次重跑前要执行的清理"

ts 复制代码
cleanup?: () => void = undefined   // effect.ts:109

写者:onEffectCleanup(fn)(effect.ts:558-567),只能在 activeSub instanceof ReactiveEffect 时注册;读者/执行者:cleanupEffect(e)(effect.ts:569-582)。

ts 复制代码
function cleanupEffect(e: ReactiveEffect) {
  const { cleanup } = e
  e.cleanup = undefined          // 先清空,防止重入时再次执行同一 cleanup
  if (cleanup) {
    const prevSub = activeSub
    activeSub = undefined        // 关键:cleanup 执行期间不该收集任何依赖
    try {
      cleanup()
    } finally {
      activeSub = prevSub
    }
  }
}

两处细节值得记忆:

  1. 先取走再置空 :e.cleanup = undefined 在调用前执行,保证 cleanup 若又注册新 cleanup 不会递归执行自身。
  2. activeSub = undefined:cleanup 里如果读了响应式数据,不应建立订阅关系(那属于"清理动作",不属于"渲染依赖")。

cleanup 由谁调?三处:run() 开头(effect.ts:171)、stop()(effect.ts:200)、以及 await 恢复场景由运行时另行安排。语义等价于 Vue 2 的 $destroy 清理与 React 的 useEffect 返回函数。

2.3.7 scheduler:决定"通知后怎么跑"

ts 复制代码
scheduler?: EffectScheduler = undefined   // effect.ts:111
export type EffectScheduler = (...args: any[]) => any   // effect.ts:8

它被 trigger() 读取以决定触发路径(effect.ts:206-214):

ts 复制代码
trigger(): void {
  if (this.flags & EffectFlags.PAUSED) {
    pausedQueueEffects.add(this)          // 暂停中:先记账,resume 时补跑
  } else if (this.scheduler) {
    this.scheduler()                      // 有调度器:交给它(如组件更新队列)
  } else {
    this.runIfDirty()                     // 默认:脏了才同步跑
  }
}
场景 是否传 scheduler 效果
watchEffect / 用户 effect 否 依赖一变立刻同步重跑
watch(sync 模式除外) 是 交给 scheduler 做队列/flush/pre/post 时机控制
组件渲染 effect 是 交给 runtime-core 的 queueJob,批量异步刷新

extend(e, options)(effect.ts:495)把 scheduler 挂在实例上,使"何时跑"从"数据变化瞬间"解耦为"调度策略决定"------这是 Vue 异步批量更新的基石(第 14 章展开)。

2.3.8 三个回调:onStop / onTrack / onTrigger

ts 复制代码
onStop?: () => void                          // effect.ts:112
onTrack?: (event: DebuggerEvent) => void     // effect.ts:113
onTrigger?: (event: DebuggerEvent) => void   // effect.ts:114
  • onStop :stop() 里主动调用(effect.ts:201),用于释放外部资源(定时器、监听器)。
  • onTrack :Dep.track 末尾在 __DEV__ 下调用(dep.ts:153-162),携带 { target, type, key }。仅在开发模式生效。
  • onTrigger :Dep.notify 里在 __DEV__ 下按原始顺序 对每个 onTrigger 回调调用一次,且用 NOTIFIED 标志去重(dep.ts:180-191)。

DebuggerEvent / DebuggerEventExtraInfo 的类型定义在 effect.ts:10-26:

ts 复制代码
export type DebuggerEvent = { effect: Subscriber } & DebuggerEventExtraInfo
export type DebuggerEventExtraInfo = {
  target: object
  type: TrackOpTypes | TriggerOpTypes
  key: any
  newValue?: any
  oldValue?: any
  oldTarget?: Map<any, any> | Set<any>
}

注意 onTrigger 不是每个订阅者调一次就完事 :Dep.notify 只对"尚未置 NOTIFIED"的订阅者调 onTrigger(dep.ts:181),这样同一批里同一 effect 的 onTrigger 也只打印一次------这与 2.9 的批处理去重是同一套 NOTIFIED 语义。

2.3.9 已消失的字段:active / dirtyLevel / allowRecurse / trackId / parent / isSSR 去哪儿了

任务/资料中常提到 3.4 的这几个字段,但在 3.5.44 里它们不再以独立字段存在,映射关系如下(这是理解版本差异的关键):

3.4 字段 3.5.44 的新形态 出处
active: boolean flags & EffectFlags.ACTIVE effect.ts:45;读取见 effect.ts:165、195;endBatch 判活见 effect.ts:297
dirtyLevel: DirtyLevels flags & EffectFlags.DIRTY + isDirty() 重算 effect.ts:49、353-370
allowRecurse: boolean flags & EffectFlags.ALLOW_RECURSE effect.ts:50;判定见 effect.ts:153
NOTIFIED 布尔 flags & EffectFlags.NOTIFIED effect.ts:48;batch/endBatch 增清
trackId(去重/自触发识别) 已被删除 ,改由 activeSub === this 与 flags & RUNNING 判定 见 2.11
parent(effect 栈) 已被删除 ,改由模块级 activeSub + try/finally 恢复 见 2.8
isSSR(effect 侧) effect 无此字段 ;仅 ComputedRefImpl.isSSR 保留 computed.ts:86、398-402
ANIMATION/subtree 等 无(属渲染侧概念) 见第 11 章

为什么删 trackId?3.4 用它给 effect 编号,靠"本轮 id 变化"识别自触发;3.5 直接用 activeSub === this(dep.ts:109 的 track,computed.ts:122 的 notify)判断"是不是自己触发了自己",无需额外计数器。

为什么删 parent?3.4 维护一个显式 effect 栈 effectStack;3.5 改成"单变量 activeSub + run() 的 try/finally 恢复"(effect.ts:173-189)。栈的语义由JS 调用栈本身承担,更轻、更省内存:

ts 复制代码
const prevEffect = activeSub
const prevShouldTrack = shouldTrack
activeSub = this
shouldTrack = true
try {
  return this.fn()
} finally {
  cleanupDeps(this)
  activeSub = prevEffect          // 恢复"父"订阅者
  shouldTrack = prevShouldTrack
  this.flags &= ~EffectFlags.RUNNING
}

这里的 prevEffect 就是"父订阅者"的等价物------它被保存在栈帧局部变量 里,天然形成 LIFO 恢复,不需要额外的 parent 指针或数组。

注意 ReactiveEffect 上仍有一个 parent 概念,但它属于 EffectScope (effectScope.ts:31),不是 effect 之间的父子------EffectScope 用它做嵌套作用域链的 O(1) 摘除(effectScope.ts:185-192),与 effect 执行栈无关。

2.3.10 字段读写者总表(速查)

字段 类型 初值 主要写者 主要读者 生命周期
fn () => T 构造传入 无(只读) run、stop 后调用 与其持久的 effect 同生命周期
deps Link? undefined Dep.track、cleanupDeps、stop prepareDeps、cleanupDeps、isDirty、stop 每次 run 可能重排/回收
depsTail Link? undefined Dep.track、cleanupDeps、stop Dep.track、cleanupDeps 同上
flags number `ACTIVE TRACKING` 多处位运算 多处位判定
next Subscriber? undefined batch endBatch 仅批处理期间非空
cleanup ()=>void? undefined onEffectCleanup cleanupEffect 每次 run 前清空
scheduler EffectScheduler? undefined extend(option) trigger 与实例同生命周期
onStop ()=>void? undefined extend(option) stop 同上
onTrack/onTrigger (e)=>void? undefined extend(option) Dep.track/Dep.notify(DEV) 同上

effect() 工厂把"构造 + 配置 + 首次 run + 绑定 runner"四步串起来(effect.ts:484-505):

ts 复制代码
export function effect<T = any>(fn, options?): ReactiveEffectRunner<T> {
  if ((fn as ReactiveEffectRunner).effect instanceof ReactiveEffect) {
    fn = (fn as ReactiveEffectRunner).effect.fn     // 拆包
  }
  const e = new ReactiveEffect(fn)
  if (options) extend(e, options)                   // 挂 scheduler/onStop/onTrack...
  try {
    e.run()                                          // 立即首次执行,建立初始依赖
  } catch (err) {
    e.stop()                                         // 首跑失败 → 立即 stop,避免悬挂
    throw err
  }
  const runner = e.run.bind(e) as ReactiveEffectRunner
  runner.effect = e                                  // runner.effect 便于 stop(runner)
  return runner
}

catch 里 e.stop() 的设计很容易被忽略:如果首次 run 抛错,必须 把 effect 停掉,否则它已经 track 的依赖会残留(虽然 run 的 finally 会 cleanupDeps,但 ACTIVE 仍是 true,可能被后续 trigger 唤醒)。这是一个失败即摘除的防御式设计。


2.4 EffectFlags 位运算体系

2.4.1 枚举定义与二进制展开

ts 复制代码
// effect.ts:41-53
export enum EffectFlags {
  ACTIVE = 1 << 0,          // 0000 0001
  RUNNING = 1 << 1,         // 0000 0010
  TRACKING = 1 << 2,        // 0000 0100
  NOTIFIED = 1 << 3,        // 0000 1000
  DIRTY = 1 << 4,           // 0001 0000
  ALLOW_RECURSE = 1 << 5,   // 0010 0000
  PAUSED = 1 << 6,          // 0100 0000
  EVALUATED = 1 << 7,       // 1000 0000
}

注意 ACTIVE 与 TRACKING 都注释了用途差异,只有 ACTIVE 上方有 /** ReactiveEffect only */ 注释(effect.ts:42-45)------这个"only"很关键:ACTIVE 只有 ReactiveEffect 用 (computed 不用),所以 endBatch 里那句 if (e.flags & EffectFlags.ACTIVE) 注释特别标注 // ACTIVE flag is effect-only(effect.ts:299)。含义是:batchedSub 链上可能混入 computed?不------实际不会,因为 computed 走 batchedComputed 分支;注释是为了提醒读者"这个判定只对 effect 生效,computed 不会走到这里",且 (e as ReactiveEffect).trigger() 的类型断言因此安全。

2.4.2 八个标志位逐一语义化

标志 值 语义 谁置 谁清 读取点
ACTIVE 1 订阅者"存活",可被 trigger 唤醒、可接受订阅 初始值 `ACTIVE TRACKING(effect.ts:101`) stop() flags &= ~ACTIVE(effect.ts:202);构造时 scope 已停则清(effect.ts:128)
RUNNING 2 该 effect 的 fn 正在执行中 run 进入时置(effect.ts:170);refreshComputed 进入时置(effect.ts:405) run 的 finally 清(effect.ts:190);refreshComputed finally 清(effect.ts:428) notify(151-153)------自触发拦截核心
TRACKING 4 是否允许"被订阅"(加入 dep 的 subs 链) 初始置;computed 首次有 sub 时置(dep.ts:214) removeSub 中 computed 无 sub 时清(effect.ts:454) addSub(dep.ts:209)、refreshComputed(378)
NOTIFIED 8 本轮批处理中"已被推入队列",去重标志 batch()(effect.ts:252) endBatch 逐个清(effect.ts:284、296) notify(157)、Dep.notify 里 onTrigger 判定(dep.ts:181)
DIRTY 16 computed 的"值可能已过期,需要重算" computed 初始 DIRTY(computed.ts:78);Dep.trigger 传播时 notify 置(computed.ts:118);addSub 置(dep.ts:214) refreshComputed 进入时清(effect.ts:383) refreshComputed(379)、isDirty 联动
ALLOW_RECURSE 32 允许"执行期间被自己触发"(递归放行) 由 options 设置(经 extend) 无 notify(153)
PAUSED 64 暂停,trigger 时只记账不执行 pause()(effect.ts:134) resume()(effect.ts:139) trigger(207)
EVALUATED 128 computed "已求过值"(用于跳过无 deps 的重算) refreshComputed 求值成功时(effect.ts:417) 无(一旦求过就保留) refreshComputed(400)

一个容易混淆的点 :DIRTY 与 NOTIFIED 是两件事。DIRTY = "值过期了"(数据层面),NOTIFIED = "已经排进本轮队列了"(调度层面)。computed 收到 Dep.notify 时先置 DIRTY (computed.ts:118),再判断是否要 batch(this, true);而 batch 内部再置 NOTIFIED(effect.ts:252)。两者独立,因此存在"脏了但还没排进队列""排进队列了但可能不脏(值相等)"的组合。

2.4.3 组合判定的两种惯用法

整章代码里所有标志判定只有两种写法,记住这两条规则就能读懂所有位运算:

(1)"与"测试某位是否置位 :flags & FLAG 用作布尔

ts 复制代码
if (this.flags & EffectFlags.ACTIVE) { ... }        // effect.ts:195
if (this.flags & EffectFlags.PAUSED) { ... }        // effect.ts:207
if (!(this.flags & EffectFlags.NOTIFIED)) { ... }   // effect.ts:157

(2)"置位 / 清位" :flags |= FLAG 加,flags &= ~FLAG 减

ts 复制代码
this.flags |= EffectFlags.RUNNING        // effect.ts:170   加位
this.flags &= ~EffectFlags.RUNNING       // effect.ts:190   清位
this.flags |= EffectFlags.PAUSED         // effect.ts:134
this.flags &= ~EffectFlags.PAUSED        // effect.ts:139
this.flags &= ~EffectFlags.ACTIVE        // effect.ts:202

组合判定(同时要求多位置位/未置位)用括号 + 逻辑运算符:

ts 复制代码
// effect.ts:151-154 ------ "正在运行 且 不允许递归" → 直接 return
if (
  this.flags & EffectFlags.RUNNING &&
  !(this.flags & EffectFlags.ALLOW_RECURSE)
) {
  return
}

注意运算符优先级陷阱:& 的优先级低于 ==/!== 但高于 &&/||,因此 a & B && c 会被解析为 (a & B) && c。Vue 源码里大量依赖这一点,但复杂表达式一律加括号(如上面),阅读时不要误判。

2.4.4 手动位运算演算:notify() 的四种输入

ReactiveEffect.notify()(effect.ts:150-160)虽短,却浓缩了 RUNNING/ALLOW_RECURSE/NOTIFIED 三个标志的协作。我们手工演算四种典型 flags:

ts 复制代码
notify(): void {
  if (
    this.flags & EffectFlags.RUNNING &&
    !(this.flags & EffectFlags.ALLOW_RECURSE)
  ) {
    return
  }
  if (!(this.flags & EffectFlags.NOTIFIED)) {
    batch(this)
  }
}
场景 flags(二进制,低位→高位) 第一步判定 第二步判定 结果
普通空闲 effect (ACTIVE|TRACKING,即 0b00000101 = 5) RUNNING=0 5 & 2 = 0 → 假 → 继续 5 & 8 = 0 → !0 真 → batch 入队
正在运行的 effect(加 RUNNING → 7) RUNNING=1 7 & 2 = 2 真,7 & 32 = 0,!0=真 → 条件整体真 → return --- 丢弃本次通知(自触发保护)
正在运行 + ALLOW_RECURSE(7 | 32 = 39) RUNNING=1, RECURSE=1 39 & 2 = 2 真,39 & 32 = 32,!32=假 → 假 → 继续 39 & 8 = 0 → 入队 入队(放行递归)
已入队(加 NOTIFIED → 13) NOTIFIED=1 13 & 2 = 0 → 继续 13 & 8 = 8 → !8=假 → 不入队 去重,不重复入队

这张表解释了 2.11 会讲的"自增计数器不递归":count++ 发生在 effect 自身 RUNNING 期间,notify 第一步就 return 了。只有把标志位置成"正在运行且未放行递归",才拦得住。

2.4.5 状态机:标志位随生命周期迁移

stateDiagram-v2 [*] --> Idle: new ReactiveEffect()\nflags = ACTIVE|TRACKING (5) Idle --> Running: run() 开始\nflags |= RUNNING (7) Running --> Idle: run() finally\nflags &= ~RUNNING (5) Running --> Running: fn 内改数据\n到达自身 notify() note right of Running notify() 检测 RUNNING 且非 ALLOW_RECURSE → return (自触发被丢弃) end note Idle --> Notified: batch(this)\nflags |= NOTIFIED (13) Notified --> Idle: endBatch 执行前\nflags &= ~NOTIFIED (5) Idle --> Paused: pause()\nflags |= PAUSED (5|64=69) Paused --> Idle: resume()\nflags &= ~PAUSED,可能立即 trigger Idle --> Stopped: stop()\nflags &= ~ACTIVE (→ TRACKING 残留) Stopped --> [*]

注意 stop() 只清 ACTIVE(effect.ts:202),TRACKING 位不会清------但 TRACKING 位对 effect 来说其实不参与 addSub 判定逻辑(addSub 里判的是 link.sub.flags & TRACKING,effect 恒有该位)。真正的意义在 computed 上(见 2.4.6)。

2.4.6 computed 的 flags 与 effect 的 flags 复用同一套枚举

ComputedRefImpl 的初始 flags 只有 DIRTY(computed.ts:78):

ts 复制代码
flags: EffectFlags = EffectFlags.DIRTY

------没有 ACTIVE !这正是"ACTIVE 只属 effect"的体现:computed 不是"可停可启"的 effect,它靠 TRACKING 位和 deps 是否存在来管理订阅。它的标志流转(见 computed.ts:117-129、effect.ts:376-430):

text 复制代码
new ComputedRefImpl            → flags = DIRTY (16)
get value → refreshComputed    → 清 DIRTY → 置 RUNNING → 求值 → 清 RUNNING
                                 求值成功置 EVALUATED
被某个 dep trigger(notify)    → 置 DIRTY;若 activeSub !== this 且未 NOTIFIED → batch(this, true)
首次被订阅(addSub)           → 置 TRACKING | DIRTY,并级联订阅自己的 deps
无订阅者时(removeSub)        → 清 TRACKING,并软移除所有 deps 的订阅

addSub(dep.ts:207-232)里这段是"computed 懒订阅"的开关:

ts 复制代码
function addSub(link: Link) {
  link.dep.sc++                                   // 订阅计数 +1
  if (link.sub.flags & EffectFlags.TRACKING) {    // computed 有 TRACKING 位才继续
    const computed = link.dep.computed
    if (computed && !link.dep.subs) {             // computed 的第一位订阅者到来
      computed.flags |= EffectFlags.TRACKING | EffectFlags.DIRTY
      for (let l = computed.deps; l; l = l.nextDep) {
        addSub(l)                                 // 级联:把 computed 的 deps 也订阅上
      }
    }
    ... 挂到 dep.subs 链 ...
  }
}

含义:computed 在没有任何外部订阅者时,它的内部 deps 是"软订阅"状态 (不真正挂进 subs 链、不增加下游 trigger 成本);一旦有 effect 读了 computed.value,才把自己的整张依赖图"激活",级联 addSub。

维度 ReactiveEffect ComputedRefImpl
初始 flags `ACTIVE TRACKING`
用 ACTIVE? 是(stop/endBatch 判活) 否
用 DIRTY? 否(effect 改用 isDirty() 实时重算) 是(缓存失效)
有独立 dep(作为源)? 否 是 (computed.dep,computed.ts:55)
有 scheduler? 可传 无(用自己的 notify→batch(, true))

2.4.7 isDirty:与标志位互补的"实时脏检查"

effect 没有 DIRTY 标志位可用(那是 computed 的),取而代之的是 runIfDirty()(effect.ts:219-223)里的 isDirty(this) 现算:

ts 复制代码
export function runIfDirty(): void {
  if (isDirty(this)) {
    this.run()
  }
}

function isDirty(sub: Subscriber): boolean {          // effect.ts:353-370
  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 向后兼容 Pinia 等手动设 _dirty 的库
  if (sub._dirty) return true
  return false
}

isDirty 逐条对比 link.dep.version 与 link.version------前者是"这个 dep 的最新版本",后者是"effect 上次读它时的快照"。只要有一个不等,就说明该依赖改动过 → 脏。

  • link.dep.version !== link.version:普通 dep 被 Dep.trigger 自增过(dep.ts:168)。
  • link.dep.computed && refreshComputed(...):依赖是 computed,先尝试刷新它(refreshComputed 返回 undefined 表示"刷新成功/无需刷新",undefined 是 falsy;只有"刷新失败"才返回真值------见 effect.ts:376 注释 Returning false indicates the refresh failed ,实为返回 undefined 表示成功)。刷新后若 computed 的 dep.version 变了(值真的变了),同样判脏。

为什么 effect 不直接用 DIRTY 标志,而要 isDirty 现算? 因为 effect 的依赖是动态的、可变的 :一个 effect 可能这轮依赖 A、下轮依赖 B。用标志位缓存"脏不脏"需要"每次 dep 变化都精准设位",而链表 + 版本号让"现算"变得便宜(O(#deps) 且可与 refreshComputed 共享)。这是 3.5 的一个重要取舍:用版本快照替代脏标志传播 ,让"不必要的重跑"由 runIfDirty 在最后一刻过滤掉。

性能账本 :假设一个组件渲染 effect 依赖 10 个属性,其中 1 个变化触发 trigger。若用"标志位脏传播",需要在 trigger 时给每个下游 effect 精确设置 DIRTY;3.5 则统一 batch 入队,在 endBatch 实际执行时用 isDirty 把"只读没变(如有 computed 缓存命中)"的 effect 挡掉。代价是多一次 O(#deps) 扫描,收益是免去 trigger 侧的传播复杂度 ,且能利用 computed 的 globalVersion 快路径(effect.ts:387)在扫描中途短路。

2.4.8 小结:位标志法的通用模式

把 2.4 抽象成可复用结论:

  1. 教训一 :当一组布尔状态"总是成组判定"时,用位图比用对象字段更快更省(一个 number vs N 个属性 + N 次取值)。
  2. 教训二 :位标志的正确性依赖"谁置位、谁清位 "的闭环。本章最易错的点就是漏清------如 NOTIFIED 若不在 endBatch 清干净,effect 将永远不再入队("假死")。所以 endBatch 特意用 e.flags &= ~EffectFlags.NOTIFIED(effect.ts:284、296)在两侧都处理。
  3. 教训三 :"运行中"必须单独一个位 ,因为它是唯一"拦截自己改自己"的依据(RUNNING)。
  4. 教训四 :ALLOW_RECURSE 是 RUNNING 的"逃生阀",二者共同决定 notify 是否提前 return,这是 2.11 递归问题的主角。

2.5 track 依赖收集全过程

依赖收集是"读"这一侧的全部。触发它的是 Proxy 的 get trap(baseHandlers.ts:112-114):

ts 复制代码
if (!isReadonly) {
  track(target, TrackOpTypes.GET, key)
}

也就是说:每一次对 reactive 属性的读取,都会走到 track 。本节把 track → Dep.track → Link 创建 → addSub 的完整链路逐行拆开。

2.5.1 track 入口的两道守卫

ts 复制代码
// dep.ts:262-284
export function track(target: object, type: TrackOpTypes, key: unknown): void {
  if (shouldTrack && activeSub) {          // ← 守卫:全局开关 + 有当前订阅者
    let depsMap = targetMap.get(target)
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    let dep = depsMap.get(key)
    if (!dep) {
      depsMap.set(key, (dep = new Dep()))
      dep.map = depsMap
      dep.key = key
    }
    if (__DEV__) {
      dep.track({ target, type, key })
    } else {
      dep.track()
    }
  }
}

守卫一 shouldTrack :全局追踪开关。为 false 时整个收集短路。谁会把它设 false?pauseTracking()(effect.ts:525-528)------典型场景是"我只想读值,不想建立依赖关系"(例如 watch 的 getter 预扫描、EffectScope 的清理)。注意它是全局的,不是 per-effect。

守卫二 activeSub :当前有没有"正在执行的订阅者"(effect 或 computed)在跑。没有的话------比如你在事件回调里读 obj.count------track 直接返回,不会记录任何依赖。这是"响应式只在 effect 内建立订阅"的严格体现:脱离 effect 上下文读数据,就是一个普通的读。

这里的顺序(shouldTrack && activeSub)与 Dep.track 内部判定的顺序(!activeSub || !shouldTrack || ...,dep.ts:109)是双重守卫 ------外层 track 先做廉价判断(不碰 targetMap),内层 Dep.track 再兜底(因为 Dep.track 也可能被 computed 的 get value 直接调用)。冗余但有意义:外层避免为本不该收集的读取创建 Dep 对象。

2.5.2 三层查找与 Dep 的惰性创建

text 复制代码
targetMap.get(target)  ── 没有? ──▶ targetMap.set(target, new Map())   // 第 1 层
       │
       ▼ depsMap.get(key)  ── 没有? ──▶ depsMap.set(key, new Dep())     // 第 2 层
                          并且:dep.map = depsMap; dep.key = key          // 反向指针

dep.map / dep.key(dep.ts:87-89)是反向指针 ,唯一用途是在 removeSub 里"这个 dep 彻底没人订阅了 → 从 depsMap 删掉自己"(dep.ts:463-469):

ts 复制代码
if (!soft && !--dep.sc && dep.map) {
  // #11979 property dep no longer has effect subscribers, delete it
  dep.map.delete(dep.key)
}

注意 dep.sc(subscription count,dep.ts:94)与 --dep.sc 的关系:addSub 每次 link.dep.sc++(dep.ts:208),removeSub 每次 --dep.sc。当 sc 归零且是真实删除(!soft)时,就把这个 key 从 depsMap 里摘掉------这是 3.5.44 针对 issue #11979 的优化:对象在内存里,但只有一部分属性被跟踪 (如 obj 有 100 个属性,effect 只读 obj.a),不读的属性不会污染 depsMap,读过后不再读的也能回收。

2.5.3 Dep.track 的内层判定:activeSub === this.computed

ts 复制代码
// dep.ts:108-112
track(debugInfo?: DebuggerEventExtraInfo): Link | undefined {
  if (!activeSub || !shouldTrack || activeSub === this.computed) {
    return
  }
  ...
}

第三个条件 activeSub === this.computed 是最微妙的一个:当某个 computed 正在求值(activeSub === computed),而它读到的正是它自己的 dep 时,不要订阅自己。 什么时候会这样?很少,但要防御。例如某个 computed 的 getter 内部通过某种方式读到了 computed.value 自己(自引用 computed)。如果不拦,就会在自己身上挂一条自订阅,造成 value 一取就 trigger 自己的死循环。

2.5.4 三个分支:新建 / 复用同步 / 复用搬尾

Dep.track 的核心是这三个分支:

ts 复制代码
let link = this.activeLink
if (link === undefined || link.sub !== activeSub) {
  // ── 分支 A:新建 Link ──  dep.ts:114-126
  link = this.activeLink = new Link(activeSub, this)
  if (!activeSub.deps) {
    activeSub.deps = activeSub.depsTail = link
  } else {
    link.prevDep = activeSub.depsTail
    activeSub.depsTail!.nextDep = link
    activeSub.depsTail = link
  }
  addSub(link)
} else if (link.version === -1) {
  // ── 分支 B:复用旧 Link(本轮首次读到)──  dep.ts:127-151
  link.version = this.version
  if (link.nextDep) { ...搬到尾部... }
}
// else ── 分支 C:本轮已经读过(link.version !== -1 且 link.sub === activeSub)→ 什么都不做

activeLink 是关键缓存 :每个 Dep 只存"最近一次访问它的那个 Link"(dep.ts:71)。所以判断"是否复用"只需比较 this.activeLink 的 sub 是不是当前 activeSub,O(1),不用在 subs 链里搜。多订阅者场景下,activeLink 会被后来的访问者"抢走"------这正是 prepareDeps 里要暂存 prevActiveLink 的原因(2.7 详解)。

分支 触发条件 动作 复杂度
A 新建 activeLink 为空,或其 sub 不是当前 activeSub new Link、尾插到 sub.deps 链、addSub O(1)
B 复用 activeLink.sub === activeSub 且 version === -1(说明废弃重来后本轮首次读到) 同步版本、必要时搬尾 O(1) 或 O(1)(搬尾是常数指针操作)
C 直通 activeLink.sub === activeSub 且 version !== -1(本轮已读过) 仅可能触发 onTrack O(1)

分支 C 意味着:同一个 effect 内重复读同一属性不会重复建立依赖。例如:

js 复制代码
effect(() => {
  console.log(obj.a)   // 第一次:分支 A 或 B
  console.log(obj.a)   // 第二次:分支 C,无新增
})

2.5.5 分支 B 的"搬尾":为什么必须有

ts 复制代码
// dep.ts:134-150
if (link.nextDep) {
  const next = link.nextDep
  next.prevDep = link.prevDep
  if (link.prevDep) {
    link.prevDep.nextDep = next
  }
  link.prevDep = activeSub.depsTail
  link.nextDep = undefined
  activeSub.depsTail!.nextDep = link
  activeSub.depsTail = link
  if (activeSub.deps === link) {
    activeSub.deps = next
  }
}

用一张图理解。假设上一轮 effect 的依赖顺序是 A → B → C,本轮读取顺序变成了 C → A → B:

text 复制代码
上一轮 deps 链:  A ─▶ B ─▶ C        (depsTail = C)
本轮读 C:C 命中"分支 B",但 C 已在尾部 → 无需搬
本轮读 A:A 命中分支 B,A.nextDep = B 存在 → 搬 A 到尾部
          ┌──▶ B ─▶ C ─▶ A   (depsTail = A);deps 头变 B
本轮读 B:B 命中分支 B,B 现在尾部 → 无需搬
最终:    B ─▶ C ─▶ A

搬尾让"链表顺序 = 本轮读取顺序"这一性质始终保持。为什么这条性质重要?

  1. cleanupDeps 从尾部倒扫回收 version === -1 的节点,需要链表是"本轮访问顺序"才能让"头"稳定为"最近访问的未被删除节点"------实际上 cleanupDeps 的 head 就是"倒扫时最后看到的未删除节点",顺序无关紧要;但 depsTail 的维护需要它。
  2. 更本质:避免链表无限增长或顺序错乱 。如果只做"追加 + 回收",复用节点若不搬尾,deps 链会变成"历史顺序",而 depsTail 指向的"尾"就不再是"最近访问"------下一轮的 prepareDeps 仍能从 deps 头建立 activeLink,逻辑上勉强能跑,但 cleanupDeps 依赖"sub.depsTail 是最后一个节点"来做 tail 回退。若不搬尾,depsTail 会指向一个中间被复用的节点,导致 tail 维护错误。所以搬尾是维护 depsTail 语义正确的必要条件。
ts 复制代码
// dep.ts:207-232
function addSub(link: Link) {
  link.dep.sc++                                    // 订阅计数 +1
  if (link.sub.flags & EffectFlags.TRACKING) {     // 该订阅者"允许被订阅"
    const computed = link.dep.computed
    if (computed && !link.dep.subs) {              // computed 的第一位订阅者
      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                // DEV:记录头部,供 onTrigger 正序
    }
    link.dep.subs = link                       // 尾指针前移(新节点成为尾)
  }
}

要点:

  1. dep.subs 是尾指针,不是头 。新 Link 一律挂到尾(subs = link),这与 deps(头)+depsTail(尾)双指针的设计不对称 :dep 侧只维护尾(因为遍历订阅者用 prevSub 从尾往前,见 2.6),effect 侧才需要头尾都在(因为 isDirty 从头扫、track 从尾插)。
  2. subsHead 是 DEV-only (dep.ts:83 注释 DEV only, for invoking onTrigger hooks in correct order ),用 __DEV__ 保护(dep.ts:226),生产构建里被摇掉,零成本。
  3. computed 的"惰性激活"发生在 addSub :computed && !link.dep.subs 表示"这个 dep 属于一个 computed,而且它此前无人订阅",于是把 computed 标 TRACKING | DIRTY 并级联 addSub(l) 它的所有 deps 。这一步把"computed 内部依赖"从"软订阅"(sc 计数在但有 TRACKING 位未置,不进 subs 链)变成"硬订阅"。

易错点 :addSub 中 link.dep.sc++ 与 if (link.sub.flags & TRACKING) 是独立 的------sc 无条件加,但只有 TRACKING 位置位时才真正挂链。这解释了 removeSub 里"--dep.sc 归零才删 dep.map"为何成立:sc 记录的是"引用次数",包含那些没进链的"软订阅"。

2.5.7 track 的完整时序图

以 effect(() => { console.log(obj.a) }) 首次执行为例:

sequenceDiagram autonumber participant U as effect.run() participant BE as baseHandlers.get participant T as dep.track() participant DT as Dep.track() participant LK as Link U->>U: activeSub = this; shouldTrack = true U->>BE: fn() 读 obj.a BE->>T: track(obj, 'get', 'a') T->>T: shouldTrack && activeSub 通过 T->>T: targetMap.get(obj) → 无 → new Map() T->>T: depsMap.get('a') → 无 → new Dep(); dep.map / dep.key 赋值 T->>DT: dep.track({target,type:'get',key:'a'}) DT->>DT: activeSub 有且 ≠ this.computed → 通过 DT->>DT: activeLink 为 undefined DT->>LK: new Link(activeSub, dep) version = dep.version DT->>DT: activeSub.deps? 无 → deps = depsTail = link DT->>DT: addSub(link) DT->>DT: addSub: sc++、挂到 dep.subs、DEV 记 subsHead DT-->>U: 返回 link(DEV 里还会调 onTrack) U->>BE: 返回 res(值) U->>U: finally: cleanupDeps → 无未用依赖

关键观察:首轮没有"回收"动作 (cleanupDeps 扫描时所有 link 的 version !== -1,无事发生),因为 run 开头 prepareDeps 遍历的是空的 sub.deps (首次运行链表为空,prepareDeps 循环体一次都不执行)。

2.5.8 shouldTrack:全局追踪开关的语义与栈式恢复

ts 复制代码
// effect.ts:519-544
export let shouldTrack = true
const trackStack: boolean[] = []

export function pauseTracking(): void {
  trackStack.push(shouldTrack)
  shouldTrack = false
}

export function enableTracking(): void {
  trackStack.push(shouldTrack)
  shouldTrack = true
}

export function resetTracking(): void {
  const last = trackStack.pop()
  shouldTrack = last === undefined ? true : last
}

语义要点:

  • shouldTrack 是模块级全局 ,不是 per-effect。这意味着"暂停追踪"会影响所有 正在跑的 effect,因此必须"配对使用"(pauseTracking 后一定要 resetTracking)。
  • 三个函数都成对压栈/弹栈 。resetTracking 弹栈恢复上次的值;栈空时默认 true。
  • enableTracking 与 pauseTracking 对称------注意它们都先 push 当前值 ,所以 enableTracking 之后也必须 resetTracking(而不是直接 shouldTrack = true 完事)。

典型 1:pauseTracking 明确不要的依赖。 假设你有一个 effect 只想读"快照",不希望被后续变化唤醒:

js 复制代码
import { effect, reactive, pauseTracking, resetTracking } from 'vue'
const state = reactive({ a: 1, b: 2 })
effect(() => {
  console.log('effect run, a =', state.a)   // 只订阅 a
  pauseTracking()
  console.log('snapshot b =', state.b)       // 读 b,但不订阅 b
  resetTracking()
})
state.b = 20   // 不会重跑 effect
state.a = 10   // 会重跑

预期输出:

text 复制代码
effect run, a = 1
snapshot b = 2
effect run, a = 10

典型 2:run() 内对 shouldTrack 的处理。 run() 会强制 把 shouldTrack 置 true 并保存旧值(effect.ts:176、189):

ts 复制代码
const prevShouldTrack = shouldTrack
...
shouldTrack = true        // 进入 effect:无论外面是否暂停,effect 内部都恢复追踪
try { return this.fn() }
finally { shouldTrack = prevShouldTrack }   // 退出时还原

为什么进入时要 = true?因为外面可能处于 pauseTracking 状态(例如 watch 正在做 traverse),但一旦开始跑用户 fn,就得正常收集。这个"进入强制开、退出还原"的模式与 activeSub 的保存/恢复完全同构。

2.5.9 逐行注释版:track + Dep.track

把两个函数合并注释一遍(行号对应 dep.ts):

ts 复制代码
export function track(target, type, key): void {              // 262
  if (shouldTrack && activeSub) {                             // 263  双守卫
    let depsMap = targetMap.get(target)                       // 264  第1层
    if (!depsMap) targetMap.set(target, (depsMap = new Map()))// 265-267  惰建
    let dep = depsMap.get(key)                                // 268  第2层
    if (!dep) {                                               // 269
      depsMap.set(key, (dep = new Dep()))                     // 270  惰建 Dep
      dep.map = depsMap                                       // 271  反向指针(回收用)
      dep.key = key                                           // 272
    }
    if (__DEV__) dep.track({ target, type, key })             // 274-279  DEV 带上下文
    else dep.track()                                          // 280-282
  }
}

// Dep 方法:
track(debugInfo?) {                                           // 108
  if (!activeSub || !shouldTrack || activeSub === this.computed) return  // 109-111 三重守卫
  let link = this.activeLink                                  // 113  取缓存
  if (link === undefined || link.sub !== activeSub) {         // 114  分支A:新建
    link = this.activeLink = new Link(activeSub, this)        // 115
    if (!activeSub.deps) {                                    // 118
      activeSub.deps = activeSub.depsTail = link              // 119  首依赖
    } else {                                                  // 120
      link.prevDep = activeSub.depsTail                       // 121  O(1) 尾插
      activeSub.depsTail!.nextDep = link                      // 122
      activeSub.depsTail = link                               // 123
    }
    addSub(link)                                              // 126  挂订阅链
  } else if (link.version === -1) {                           // 127  分支B:复用
    link.version = this.version                               // 129  同步版本
    if (link.nextDep) { ...搬到尾部... }                        // 134-150
  }                                                           //    分支C:直通
  if (__DEV__ && activeSub.onTrack) { ... }                    // 153-162  钩子
  return link                                                 // 164
}

一句话记忆 :track 负责"找到/建好 Dep 对象",Dep.track 负责"把当前 Subscriber 与这个 Dep 的关系固化成 Link,并接到两条链上"。


2.6 trigger 触发全过程

触发是"写"这一侧的全部。它由 Proxy 的 set / deleteProperty trap 发起(baseHandlers.ts 的 set 分支),最终落在 dep.ts:294 的 trigger 上。与"读一次只收集一条"不同,写一次可能触发多条依赖 (如 Map.set 同时触发 key dep 与 ITERATE_KEY),因此本节的重点是"调度表 "与"通知顺序"。

2.6.1 trigger 入口与"从未被追踪"旁路

ts 复制代码
// dep.ts:294-307
export function trigger(target, type, key?, newValue?, oldValue?, oldTarget?): void {
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    // never been tracked
    globalVersion++
    return
  }
  ...
}

depsMap 不存在,意味着这个 target 从来没被任何 effect 读过 (注意是"读"不是"写")。此时触发自然无效果,但必须 globalVersion++ :因为全局版本号是 computed 的"世界是否变化"标志(effect.ts:387-389),如果某 computed 的缓存依赖 "globalVersion 不变" 这个前提,那么任何一次响应式写都必须让全局版本前进,否则 computed 会错误地走快路径认为"世界没变"。

易错点 :这里 globalVersion++ 只在"depsMap 不存在"时加。如果 target 被读过但不能写 (例如只读过 Map.has),后续的 Dep.trigger 也会 globalVersion++(dep.ts:169)。二选一,不会重复计数,因为一旦 depsMap 存在就走正常路径。

2.6.2 run 闭包与类型调度表

ts 复制代码
const run = (dep: Dep | undefined) => {          // dep.ts:309-324
  if (dep) {
    if (__DEV__) {
      dep.trigger({ target, type, key, newValue, oldValue, oldTarget })
    } else {
      dep.trigger()
    }
  }
}

startBatch()                                     // dep.ts:326

if (type === TriggerOpTypes.CLEAR) {             // dep.ts:328-331
  depsMap.forEach(run)                           // 集合清空:触发 target 上所有 dep
} else {
  const targetIsArray = isArray(target)          // dep.ts:333
  const isArrayIndex = targetIsArray && isIntegerKey(key)  // dep.ts:334

  if (targetIsArray && key === 'length') {       // dep.ts:336-346
    const newLength = Number(newValue)
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key === ARRAY_ITERATE_KEY ||
          (!isSymbol(key) && key >= newLength)) {
        run(dep)
      }
    })
  } else {
    if (key !== void 0 || depsMap.has(void 0)) { // dep.ts:349-351
      run(depsMap.get(key))                      // 主键 dep
    }
    if (isArrayIndex) {                          // dep.ts:354-356
      run(depsMap.get(ARRAY_ITERATE_KEY))        // 数组索引变化 → 额外触发数组迭代依赖
    }
    switch (type) {                              // dep.ts:359-384
      case TriggerOpTypes.ADD:
        if (!targetIsArray) {
          run(depsMap.get(ITERATE_KEY))          // 对象新增 → 触发 Object iterate
          if (isMap(target)) run(depsMap.get(MAP_KEY_ITERATE_KEY))
        } else if (isArrayIndex) {
          run(depsMap.get('length'))             // 数组新增索引 → 隐式改变 length
        }
        break
      case TriggerOpTypes.DELETE:
        if (!targetIsArray) {
          run(depsMap.get(ITERATE_KEY))
          if (isMap(target)) run(depsMap.get(MAP_KEY_ITERATE_KEY))
        }
        break
      case TriggerOpTypes.SET:
        if (isMap(target)) run(depsMap.get(ITERATE_KEY))  // Map.set 改了 value,迭代结果变
        break
    }
  }
}

endBatch()                                       // dep.ts:388

把这张"操作类型 → 触发哪些 dep"整理成表(key 用 k,迭代键用符号):

操作 主键 dep (k) length ARRAY_ITERATE_KEY ITERATE_KEY MAP_KEY_ITERATE_KEY 备注
对象 SET k ✔ --- --- --- --- 只改值
对象 ADD k ✔ --- --- ✔ --- 键集合变了
对象 DELETE k ---(k 未定义则跳过) --- --- ✔ --- 见 key !== void 0 判定
数组 SET 索引 ✔ --- ✔ --- --- 索引变化影响迭代顺序
数组 SET length ✔(length) ✔ ✔(若 >= newLength 也含索引键) --- --- 遍历 depsMap 精选
数组 ADD 索引 ✔ ✔ ✔ --- --- 新增索引 → length 变化
Map/Set SET ✔ --- --- ✔(Map) --- Map value 变影响迭代
Map/Set ADD ✔ --- --- ✔(Map) ✔(Map) 键集合变了
Map/Set DELETE --- --- --- ✔(Map) ✔(Map)
任意 CLEAR 全部 全部 全部 全部 全部 depsMap.forEach(run)

三个 ITERATE_KEY 符号在 dep.ts:242-250 定义:

ts 复制代码
export const ITERATE_KEY = Symbol(__DEV__ ? 'Object iterate' : '')
export const MAP_KEY_ITERATE_KEY = Symbol(__DEV__ ? 'Map keys iterate' : '')
export const ARRAY_ITERATE_KEY = Symbol(__DEV__ ? 'Array iterate' : '')

它们分别是"遍历对象键"、"遍历 Map 键"、"遍历数组"的依赖锚点------用户代码里的 Object.keys(obj)、for (const k of map.keys())、for (const v of arr) 会 track 到这些符号键上,再由上面的调度表决定何时触发。

为什么数组 length 变化要"精选"而不是全触发?

ts 复制代码
if (targetIsArray && key === 'length') {
  const newLength = Number(newValue)
  depsMap.forEach((dep, key) => {
    if (key === 'length' || key === ARRAY_ITERATE_KEY ||
        (!isSymbol(key) && key >= newLength)) {
      run(dep)
    }
  })
}

假设有一个大数组 10000 个元素,effect 只依赖 arr[9999]。此时执行 arr.length = 10(截断)。只有 arr[9999](9999 >= 10)、length、以及数组迭代依赖需要被触发;arr[0](0 >= 10 为假)不受影响。若不做精选而全触发,会把 9999 个无关订阅者全部唤醒------这是 Vue 3 针对大数组 length 修改的经典优化。

设计动机(性能账本) :length 修改的精选扫描是 O(#tracked keys),而"全触发"也是 O(#tracked keys)。区别在于被唤醒的订阅者数量 ------精选让无关订阅者免于 isDirty 扫描与入队,在稀疏大数组场景收益显著。

2.6.3 Dep.trigger → Dep.notify 的分层

ts 复制代码
// dep.ts:167-171
trigger(debugInfo?: DebuggerEventExtraInfo): void {
  this.version++          // 1) dep 版本自增:使所有 link.version 快照失效
  globalVersion++         // 2) 全局版本自增:computed 快路径失效
  this.notify(debugInfo)  // 3) 进入通知
}

两个自增各有用途:

  • this.version++:让 isDirty 里 link.dep.version !== link.version 判定为真(2.4.7)。这是"脏"的判据。
  • globalVersion++:让 computed 的 computed.globalVersion === globalVersion 快路径(effect.ts:387)失效,迫使它至少走一次 isDirty 检查。

然后是 notify(dep.ts:173-204):

ts 复制代码
notify(debugInfo?: DebuggerEventExtraInfo): void {
  startBatch()
  try {
    if (__DEV__) {
      // 逆序通知、正序执行的整体约定下,onTrigger 仍按原始顺序调用
      for (let head = this.subsHead; head; head = head.nextSub) {
        if (head.sub.onTrigger && !(head.sub.flags & EffectFlags.NOTIFIED)) {
          head.sub.onTrigger(extend({ effect: head.sub }, debugInfo))
        }
      }
    }
    for (let link = this.subs; link; link = link.prevSub) {   // ← 从尾往前
      if (link.sub.notify()) {
        // 返回 true 说明是 computed,额外通知它的 dep
        ;(link.sub as ComputedRefImpl).dep.notify()
      }
    }
  } finally {
    endBatch()
  }
}

三个必须记住的点:

  1. 从尾往前遍历 (link = this.subs,link = link.prevSub)。this.subs 是尾指针(addSub 里 subs = link),所以从"最新订阅者"开始,倒序走到"最早订阅者"。
  2. if (link.sub.notify()) 的返回值 true 触发级联 :computed 的 notify 返回 true(computed.ts:124),于是这里 (而非 computed 内部)调用 computed.dep.notify() 继续向下传播。注释解释了为什么放这里:"it's called here instead of inside computed's notify in order to reduce call stack depth" (dep.ts:196-197)------减少调用栈深度,避免"computed 通知自己的 dep,再被 dep 通知回来"的深层递归。
  3. startBatch / endBatch 包住整个遍历 (加上外层 trigger 里的 startBatch/endBatch)。这构成批处理的嵌套(batchDepth 计数),确保所有遍历做完才统一执行(2.9 详解)。

2.6.4 为什么"从尾往前"通知,执行时却是"正序"

batch 用头插 把订阅者压进 batchedSub(effect.ts:258):先被通知的在前、后通知的反而在链表头。endBatch 从链表头开始取(let e = batchedSub),于是取出的顺序 = 通知顺序的逆序 = 原始订阅顺序。

text 复制代码
通知顺序(Dep.notify 从 subs 尾往前):  最新订阅者 ... 最早订阅者
batch 头插后 batchedSub 链:            最早订阅者 → ... → 最新订阅者
endBatch 从头取:                       最早订阅者 ... 最新订阅者  ← 正序执行

这就是 dep.ts:177-179 注释所说的 "subs are notified and batched in reverse-order and then invoked in original order at the end of the batch"。

为什么要让执行顺序 = 原始订阅顺序? 因为"最早订阅的 effect"通常是外层/更基础的计算,先执行它能让下游 effect 拿到最新值;而通知时之所以倒序,是因为 Dep.subs 只维护尾指针,从尾往前是最自然的遍历方向(若维护头指针则需另存 Head,成本更高)。用一个反转的链表就同时解决了"遍历方向自然"与"执行顺序正确"两个问题------这是把数据结构特性榨到极致的例子。

2.6.5 batchedComputed:computed 的特殊处理

endBatch 对两类队列的处理顺序与时机不同 (effect.ts:273-310):

ts 复制代码
export function endBatch(): void {
  if (--batchDepth > 0) return                    // 还有更外层批,先不执行

  // 1) 先处理 computed:只清 NOTIFIED,不执行
  if (batchedComputed) {
    let e: Subscriber | undefined = batchedComputed
    batchedComputed = undefined
    while (e) {
      const next: Subscriber | undefined = e.next
      e.next = undefined
      e.flags &= ~EffectFlags.NOTIFIED
      e = next
    }
  }

  // 2) 再处理普通 effect:清 NOTIFIED 并 trigger()
  let error: unknown
  while (batchedSub) {
    let e: Subscriber | undefined = batchedSub
    batchedSub = undefined
    while (e) {
      const next: Subscriber | undefined = e.next
      e.next = undefined
      e.flags &= ~EffectFlags.NOTIFIED
      if (e.flags & EffectFlags.ACTIVE) {          // ACTIVE flag is effect-only
        try {
          ;(e as ReactiveEffect).trigger()
        } catch (err) {
          if (!error) error = err                   // 只保留第一个错误
        }
      }
      e = next
    }
  }

  if (error) throw error
}

为什么 computed 队列只清标志、不执行? computed 是惰性 的:它不需要"主动跑",只需要"标记为脏"(DIRTY 在 computed.ts:118 已置)。真正求值发生在有人读 computed.value 时 的 refreshComputed。所以 batchedComputed 的作用只是"在批处理结束时清掉 NOTIFIED,让下一轮还能再次入队"------是一个去重账本,不是执行队列。

易错点 :如果不清 batchedComputed 里的 NOTIFIED,computed 会永远认为"我已经在本轮通知过了",从而无法在后续变化中再次入队。endBatch 必须在 batchedSub 之前先 处理 batchedComputed,保证 computed 的 NOTIFIED 状态在 effect 执行(可能读 computed → refreshComputed)之前就干净。

错误聚合 :endBatch 用 let error 收集"第一个抛出的错误",继续跑完剩下的 effect ,最后 if (error) throw error。这样一次批处理中某个 effect 抛错不会阻断其他 effect 的执行------与 Vue 渲染器的"错误不阻断更新队列"一脉相承。

2.6.6 完整触发时序图

以 obj.a 被两个 effect 订阅(E1 早、E2 晚),执行 obj.a++ 为例:

sequenceDiagram autonumber participant U as 用户写 obj.a++ participant BH as baseHandlers.set participant TR as dep.trigger() participant RT as run(=Dep.trigger) participant DN as Dep.notify() participant B as batch/endBatch participant E1 as Effect E1(早订阅) participant E2 as Effect E2(晚订阅) U->>BH: obj.a = 2 BH->>TR: trigger(obj, 'set', 'a', 2, 1) TR->>TR: targetMap.get(obj) 存在 TR->>TR: startBatch() batchDepth = 1 TR->>RT: run(depsMap.get('a')) RT->>DN: dep.trigger() DN->>DN: version++ / globalVersion++ DN->>DN: startBatch() batchDepth = 2 DN->>DN: DEV: 正序 onTrigger DN->>E2: notify()(subs 尾 = E2,先访问) E2->>B: batch(E2) E2.flags |= NOTIFIED;头插 batchedSub DN->>E1: notify()(prevSub) E1->>B: batch(E1) 头插 → batchedSub = E1 → E2 DN->>DN: endBatch() batchDepth-- → 1,>0 不执行 RT-->>TR: 返回 TR->>TR: endBatch() batchDepth-- → 0 → 执行 TR->>B: 清 batchedComputed(NOTIFIED)(若有) B->>E1: 清 NOTIFIED → trigger() → runIfDirty → isDirty 真 → run() B->>E2: 清 NOTIFIED → trigger() → runIfDirty → isDirty 真 → run() Note over E1,E2: 执行顺序 = E1 → E2 = 原始订阅顺序

2.6.7 逐行走读:trigger 的五个阶段

把 dep.ts:294-389 分成五个阶段:

阶段 0:入口(294-307) --- 取 depsMap;不存在则 globalVersion++ 返回。

阶段 1:准备(309-326) --- 定义 run 闭包(封装 DEV 信息与 dep.trigger());startBatch() 开启外层批。

阶段 2:分派(328-385) --- 按 CLEAR / length / 普通字段三种情形,用上面的调度表决定 run 哪些 dep。

阶段 3:逐 dep 通知(由 run → Dep.trigger → Dep.notify 展开) --- 每个被 run 的 dep 先 version++/globalVersion++,再遍历 subs(尾部往前),对每个 link.sub.notify();computed 返回 true 则级联 computed.dep.notify()。

阶段 4:收尾(388) --- endBatch() 结束外层批,batchDepth 归零则统一执行。

一个常被忽略的细节 :trigger 里 run(depsMap.get(key)) 这个 depsMap.get(key) 可能返回 undefined(该键没被读过),run 内部有 if (dep) 守卫(dep.ts:310),不会崩。key !== void 0 || depsMap.has(void 0) 这行的含义是:"key 不是 undefined 时就 run(undefined) 会无害跳过;但如果 key 恰好是 undefined(数组某些操作),要确认 depsMap 里真有 undefined 键才 run"------避免无谓的 get。

2.6.8 trigger 与 track 的对称性对照

维度 track(读) trigger(写)
触发源 Proxy get(baseHandlers.ts:113) Proxy set/delete(baseHandlers.ts)
核心对象 targetMap.get(target).get(key): Dep 同左,但按操作类型可能取多个 dep
入口守卫 shouldTrack && activeSub depsMap 是否存在
建立/消费 Dep.track 建 Link Dep.notify 遍历 Link
双向链用途 link.prevDep/nextDep 串成 sub 的 deps 链 link.prevSub/nextSub 串成 dep 的 subs 链
版本号 link.version = dep.version(快照) dep.version++(使快照失效)
批处理 无 startBatch/endBatch 包裹
性能关注 O(1) 尾插、去重 精选触发、逆序通知、NOTIFIED 去重

读懂这张表,"读建立关系、写消费关系"的闭环就完整了。


2.5 与 2.6 分别讲了"读"和"写",但真正让 3.5 区别于 3.4 的,是**"每轮如何淘汰不再读的旧依赖"**这套机制。本节把它彻底讲清。

再次强调 Link 的六指针结构(dep.ts:32-62):

text 复制代码
                            ┌─────────────── dep 的 subs 链(订阅者反向链)───────────────┐
                            │                                                            │
        deps 链(effect 读过的 dep,正序)                                             │
                            │                                                            │
   sub.deps ──▶ Link₁ ──▶ Link₂ ──▶ Link₃ ──▶ ... ──▶ sub.depsTail                  │
                │          │          │                                          │
   dep A ◀──────┘          │          │                                          │
   dep B ◀─────────────────┘          │                                          │
   dep C ◀────────────────────────────┘                                          │
                                                                                  │
   A.subs ──▶ (也是 Link₁) ──prevSub──▶ ... ──▶ A.subs(尾)                        │
                            └──────────────────────────────────────────────────────┘

同一个 Link 对象同时是:

  • sub.deps 链 的一个节点:靠 prevDep / nextDep 串联,代表"这个 effect 读过哪些数据";
  • dep.subs 链 的一个节点:靠 prevSub / nextSub 串联,代表"这个数据被哪些 effect 读过"。

这两条链的维护函数是分离的:

操作 函数 影响的链 行号
挂到 dep 的 subs 链 addSub(link) dep.subs dep.ts:207-232
从 dep 的 subs 链摘除 removeSub(link, soft?) dep.subs effect.ts:432-470
挂到 sub 的 deps 链 Dep.track 内联尾插 sub.deps/depsTail dep.ts:118-124
从 sub 的 deps 链摘除 removeDep(link) sub.deps/depsTail effect.ts:472-482

注意 removeSub 与 removeDep 分别定义在 effect.ts 而不是 dep.ts------因为 removeSub 需要访问 EffectFlags(effect.ts:454),且这两个函数只服务于 ReactiveEffect/ComputedRefImpl 的 run 流程,放在 effect.ts 更内聚。

2.7.2 prepareDeps:把旧依赖"设为待验证"

ts 复制代码
// effect.ts:312-322
function prepareDeps(sub: Subscriber) {
  // Prepare deps for tracking, starting from the head
  for (let link = sub.deps; link; link = link.nextDep) {
    // 1) 旧依赖版本置 -1,标记"本轮尚未被读取"
    link.version = -1
    // 2) 把该 dep 的 activeLink 抢过来,暂存原值
    link.prevActiveLink = link.dep.activeLink
    link.dep.activeLink = link
  }
}

它在 run() 里、cleanupEffect 之后、fn() 之前调用(effect.ts:171-172、178)。做的事情有两件:

(1)版本置 -1:这是"本轮没读过"的标记。 之后 fn 每读到一个 dep,Dep.track 走分支 B 会把 link.version 同步回 dep.version(dep.ts:129)。于是:

text 复制代码
run 开始:  所有旧 link.version = -1
fn 执行中: 被读到的 link.version = dep.version(≥ 0)
run 结束:  version 仍为 -1 的 link = 本轮没读 → 应淘汰

这是一个只要"读"就自动清除标记 的巧妙设计------不需要显式记录"读过什么",标记的清除本身就是记录的副作用。

(2)抢 activeLink:这是"复用"的关键。 Dep.activeLink 是每个 dep 唯一的"最近访问缓存"。prepareDeps 主动把本 sub 的所有旧 link 认领为对应 dep 的 activeLink,于是接下来 fn 再次读到同一个 dep 时,Dep.track 的 link = this.activeLink 正好命中自己这个 link,走进分支 B(复用)而非分支 A(新建)。

原值与 prevActiveLink 暂存起来,cleanupDeps 结束时还原(effect.ts:344-345)。这就是"交换式复用(swap)"这个名字的由来:

text 复制代码
run 前:  dep.activeLink = 别的 sub 的 link   →  prevActiveLink 存下它
          dep.activeLink = 我的 link           →  抢占
run 后:  dep.activeLink = prevActiveLink      →  归还

为什么必须归还? 因为 activeLink 是"共享缓存":如果 effect A 跑完不归还,effect B 下次跑到同一 dep 时,activeLink 仍指向 A 的 link,B 就无法命中自己的复用,只能新建 link------依赖关系会退化成"每轮都新建",内存与时间双重浪费。更重要的是:dep 侧 activeLink 若指向一个已停止/已删除的 sub 的 link,会导致错误的复用。

情形 prepareDeps 后跑 fn 时 命中分支 结果
同一 sub 重跑,dep 只有它一个订阅者 activeLink 被自己抢占 B 复用,version 同步
嵌套 effect:子 effect 也读同一 dep 子 effect run 时 prepareDeps 抢 activeLink,退出时归还父的 B(各自) 两个 sub 各自命中自己的 link(因为 activeLink 随 run 切换)
已停止 sub 的残留 link 曾占 activeLink prepareDeps 覆盖为新 link,原值存 prevActiveLink A/B 安全覆盖,跑完归还

第二行解释了为什么 prevActiveLink 是必须 的:嵌套 effect 场景下,父 effect 抢占 activeLink → 子 effect 跑前又抢占(此时把父的 link 存进子 link 的 prevActiveLink)→ 子跑完归还父的 → 父跑完归还更外层。整条链就是一个"入栈/出栈"过程,prevActiveLink 是每个节点保存的"上一层"指针。这正是"用链表指针替代显式栈"的又一例证 (与 2.3.9 删 parent 同理)。

2.7.4 track 的"交换式复用":搬尾是复用的一部分

复用分支(dep.ts:127-151)两步走:

ts 复制代码
} else if (link.version === -1) {
  // reused from last run - already a sub, just sync version
  link.version = this.version

  // If this dep has a next, it means it's not at the tail - move it to the
  // tail. This ensures the effect's dep list is in the order they are
  // accessed during evaluation.
  if (link.nextDep) {
    const next = link.nextDep
    next.prevDep = link.prevDep
    if (link.prevDep) {
      link.prevDep.nextDep = next
    }
    link.prevDep = activeSub.depsTail
    link.nextDep = undefined
    activeSub.depsTail!.nextDep = link
    activeSub.depsTail = link
    if (activeSub.deps === link) {
      activeSub.deps = next
    }
  }
}
  • 若 link 已在尾部(link.nextDep 为 undefined),只同步版本,其余不动 ------ 这是最常见路径,几乎是零成本;
  • 若 link 不在尾部,说明本轮读取顺序变了,需要搬尾(上面 2.5.5 讲过的指针重接)。

"复用"的收益账本 :假设 effect 每轮依赖 20 个 dep,且顺序不变(最常见)。3.4 的做法是"每轮 effect.deps.length = 0 清空 + 重新 push 20 次 + dep.add 判断是否已在 Set";3.5 的做法是"prepareDeps 遍历 20 个节点置 -1(O(20) 简单赋值)+ 读时逐个复用同步(O(20) 赋值)",零对象分配、零数组扩容、零 Set 查找。在 60fps 的渲染 effect 上,这是每帧都能省下的常数级开销。

2.7.5 cleanupDeps:从尾倒扫,回收"未读"节点

ts 复制代码
// effect.ts:324-351
function cleanupDeps(sub: Subscriber) {
  let head
  let tail = sub.depsTail
  let link = tail                        // 从尾开始
  while (link) {
    const prev = link.prevDep
    if (link.version === -1) {           // 本轮没读过
      if (link === tail) tail = prev     // 若删的是尾,tail 回退
      removeSub(link)                    // 从 dep 的 subs 链摘除
      removeDep(link)                    // 从 sub 的 deps 链摘除
    } else {
      head = link                        // 倒扫中最后见到的"存活"节点 = 新的头
    }
    link.dep.activeLink = link.prevActiveLink   // 归还 activeLink
    link.prevActiveLink = undefined
    link = prev
  }
  sub.deps = head
  sub.depsTail = tail
}

逐步理解:

  1. 从尾往前扫 (link = sub.depsTail,link = prev)。为什么从尾不从头?因为要维护 tail:删尾部节点时 tail 回退到 prev 最自然;而 head 可以"倒扫过程中最后见到的存活节点",天然得到。
  2. version === -1 → 删 :removeSub + removeDep 双摘。注意顺序:先摘 dep 侧、再摘 sub 侧(两者独立,顺序不影响正确性)。
  3. tail 回退规则 :只有当被删节点恰好是当前 tail 时才回退。若删的是中间节点,tail 不动。
  4. head 的推导 :倒扫时每遇到一个存活节点就更新 head。由于是倒扫,最终 head 会是最靠近头部的一个存活节点(最后一次赋值覆盖)。逻辑正确。
  5. 归还 activeLink :无论节点删不删,都要 link.dep.activeLink = link.prevActiveLink(effect.ts:344)。这是 2.7.2 抢占的对称操作。
  6. 最后重设 sub.deps = head、sub.depsTail = tail。若全部被删(head 为 undefined),deps 变 undefined,effect 就变成"零依赖"。

一个边界条件 :head 初始为 undefined。如果整个 deps 链全部被删(例如 effect 本轮什么都没读),head 保持 undefined,sub.deps = undefined。这是正确的------零依赖 effect 不应有 deps 链。

2.7.6 图解:一轮"依赖增删"的完整轨迹

设上一轮 deps 链为 A → B → C(depsTail = C),本轮 fn 只读 C 和 D(新依赖):

text 复制代码
【run 前 prepareDeps】
  A.version = -1, B.version = -1, C.version = -1
  且 A.dep.activeLink = linkA;B.dep.activeLink = linkB;C.dep.activeLink = linkC
  链: A ─▶ B ─▶ C          (deps = A, depsTail = C)

【fn 执行:读 C】
  Dep(C).track: activeLink === linkC(自己) 且 version === -1 → 分支B
  C.version = C.dep.version(同步)
  链: A ─▶ B ─▶ C          (C.nextDep = undefined,无需搬尾)
  链: A ─▶ B ─▶ C          (depsTail 仍 = C)

【fn 执行:读 D(新 dep)】
  Dep(D).track: activeLink 为 undefined → 分支A → new Link(D)
  尾插到 C 后;addSub
  链: A ─▶ B ─▶ C ─▶ D     (depsTail = D)

【run 后 cleanupDeps:从 D 倒扫】
  link = D: version !== -1 → head = D;归还 D.activeLink
  link = C: version !== -1 → head = C;归还 C.activeLink
  link = B: version === -1 → 删(不是 tail,tail 不动);removeSub(B)+removeDep(B);归还
  link = A: version === -1 → 删(不是 tail);removeSub(A)+removeDep(A);归还
  结束后: sub.deps = head = C(注意:head 最后一次被赋值是 C,因为倒扫顺序 D→C 时 C 覆盖 D?)

等一下,这里有个细节:倒扫顺序是 D → C → B → A,head 的赋值依次是 D、C,最终 head = C。但正确的头应该是 C (因为 A、B 被删,C 是新的第一个存活节点)。✅ 正确。head 是"倒扫过程中最后看到的存活节点",由于倒扫从尾到头,最后看到的(在顺序上最靠前的)就是新的头。这是一个不需要额外寻找头、只靠遍历自然得出的技巧。

删除后新链:C ─▶ D(deps = C,depsTail = D)。

2.7.7 removeSub:从 dep 的订阅者链摘除(含 computed 级联)

ts 复制代码
// effect.ts:432-470
function removeSub(link: Link, soft = false) {
  const { dep, prevSub, nextSub } = link
  if (prevSub) {
    prevSub.nextSub = nextSub      // 前驱跨过自己
    link.prevSub = undefined
  }
  if (nextSub) {
    nextSub.prevSub = prevSub      // 后继回指前驱
    link.nextSub = undefined
  }
  if (__DEV__ && dep.subsHead === link) {
    dep.subsHead = nextSub         // DEV 头指针前移
  }

  if (dep.subs === link) {          // 自己是尾
    dep.subs = prevSub
    if (!prevSub && dep.computed) {
      // computed 的最后一位订阅者没了 → 取消自己的全部订阅,便于 GC
      dep.computed.flags &= ~EffectFlags.TRACKING
      for (let l = dep.computed.deps; l; l = l.nextDep) {
        removeSub(l, true)          // soft 删除
      }
    }
  }

  if (!soft && !--dep.sc && dep.map) {
    // #11979 dep 无订阅者 → 从 depsMap 删除
    dep.map.delete(dep.key)
  }
}

要点:

  1. 标准双向链摘除 :前驱 nextSub 指向后继,后继 prevSub 指向前驱。注意 dep.subs 是尾指针 ,所以"自己是尾"的判定是 dep.subs === link,回退 dep.subs = prevSub。
  2. __DEV__ 的 subsHead 维护 (effect.ts:442-445):只在开发模式维护头指针,用于 onTrigger 正序。生产构建下 subsHead 不存在,这段被 __DEV__ 包裹的代码不生成。
  3. computed 的级联取消订阅 (effect.ts:451-460):当 prevSub 为空(自己既是尾又是唯一订阅者)且这个 dep 属于 computed 时,说明"下游没人依赖这个 computed 了",于是 clear TRACKING 并把 computed 的所有 deps 用 soft=true 摘除。为什么 soft? 因为 computed 自己仍然持有 这些 link(computed.deps 链不删),只是不再挂在 dep 的 subs 链上。这样今后 computed 再次被订阅时,可以靠 addSub 的级联快速恢复(dep.ts:213-218),且不减少 dep.sc (soft 跳过 --dep.sc),保证 dep 对象不被误删。
  4. !soft && !--dep.sc && dep.map → 删除 dep :真实删除才减计数;计数归零则从 depsMap 摘除这个 key(#11979 优化)。

2.7.8 removeDep:从 sub 的 deps 链摘除

ts 复制代码
// effect.ts:472-482
function removeDep(link: Link) {
  const { prevDep, nextDep } = link
  if (prevDep) {
    prevDep.nextDep = nextDep
    link.prevDep = undefined
  }
  if (nextDep) {
    nextDep.prevDep = prevDep
    link.nextDep = undefined
  }
}

同样是标准双向链摘除。注意它不 维护 sub.deps / sub.depsTail 的端点------端点由调用方 cleanupDeps 统一在扫完后重设(effect.ts:349-350)。

为什么要拆成"逐个 removeDep + 最后统一重设端点"? 因为 cleanupDeps 是从尾倒扫,倒扫过程中直接改 depsTail 会让"当前 link 的 prev 是谁"变得混乱。把端点维护集中到最后一次赋值,逻辑更清晰,也更快(只写一次 deps/depsTail)。

2.7.9 为什么不用数组 / Set:内存与分配账本

把 3.4 与 3.5 的依赖存储逐项对比:

维度 Vue 3.4(数组 + Set) Vue 3.5(双链表 + 版本号)
一个依赖关系的存储 effect.deps: Dep[] 一个数组槽 + dep: Set<ReactiveEffect> 一个条目 一个 Link 对象(6 指针 + 1 number)
建依赖 deps.push(dep) + dep.add(effect) new Link 或复用已有 link
淘汰依赖 deps.length = 0 清空后全量重建 版本号标记 + 倒扫回收,只有变化的部分动
去重 Set 天然去重 + 每轮 trackId 比较 activeLink O(1) 判定 + 版本号
遍历订阅者 遍历 Set(插入序) 遍历 subs 链(尾→头)
内存增长 每轮可能新建 Dep/Set 条目 复用;dep 无人订阅即回收
GC 压力 每轮 flatten/重建产生垃圾 接近零分配(稳态下)

性能小结(可直接引用的"账本"):

  • 稳态零分配 :一个持续存在的 effect,在"依赖集合不变"的稳态下,每轮 run 产生的对象分配为 0 (全部复用 link)。3.4 每轮至少 deps.length = 0 后重新 push + Set.add,虽不新建对象但涉及数组扩容与哈希。
  • O(1) 插入/删除 :链表增删无移位成本,数组 splice 是 O(n)。
  • 端点语义清晰 :depsTail 让"最近访问"成为显式语义,版本号让"本轮是否读过"成为可判定状态。
  • GC 友好 :dep.map.delete(dep.ts:468)与 computed 软取消(effect.ts:451-460)让"对象常驻但属性不再跟踪"的典型场景不再泄漏 Dep/Link。

反直觉点 :双链表节点当前不能 在删除后被池化复用(Link 没有对象池),因为 Link 被 dep 与 sub 双向强引用,池化会引入悬垂指针风险。3.5 的选择是"尽量不删、只在真正没读到时才删",用复用 替代池化 ------复用发生在 prepareDeps/track 的稳态路径上,比池化更安全。

2.7.10 用打印验证依赖关系(可运行 demo)

Vue 3 内置了打印依赖的注释版函数 (effect.ts:233-245,源码里被注释掉):

ts 复制代码
// function printDeps(sub: Subscriber) {
//   let d = sub.deps
//   let ds = []
//   while (d) {
//     ds.push(d)
//     d = d.nextDep
//   }
//   return ds.map(d => ({ id: d.id, prev: d.prevDep?.id, next: d.nextDep?.id }))
// }

(注:Link 在 3.5.44 已无 id 字段,这段是早期版本残留;实际调试可用下面的自写版本。)

我们写一个不依赖内部字段、只用 onTrack/onTrigger 观测的 demo:

js 复制代码
import { effect, reactive } from 'vue'

const state = reactive({ a: 1, b: 2, useB: true })

const runner = effect(
  () => {
    // 条件依赖:useB 决定是否读 b
    const sum = state.useB ? state.a + state.b : state.a
    console.log('run, sum =', sum)
  },
  {
    onTrack(e) {
      console.log('[track]', e.type, e.key)
    },
    onTrigger(e) {
      console.log('[trigger]', e.type, e.key)
    },
  },
)
// 预期:
// [track] get a
// [track] get useB
// [track] get b
// run, sum = 3

state.b = 20
// 预期:
// [trigger] set b
// [track] get a      ← 重跑时重新收集(复用旧 link)
// [track] get useB
// [track] get b
// run, sum = 21

state.useB = false        // 关闭对 b 的依赖
// 预期:
// [trigger] set useB
// [track] get a
// [track] get useB
// run, sum = 1
// (本轮不再 track b,cleanupDeps 会回收 b 的 link)

state.b = 30             // 不再触发!
// 预期:(无输出)

这个 demo 演示了三件事:

  1. onTrack 的触发顺序 = 读取顺序(a、useB、b),印证 deps 链顺序语义;
  2. 条件依赖切换(useB: true → false)后,b 的 link 被 cleanupDeps 回收,state.b 再改不再触发------这就是"动态依赖 + 版本号回收"的直观效果;
  3. 首轮没有 trigger 输出 ,因为那时 activeSub 尚未建立对 b 的订阅(useB 先被读,若 useB 为 true 才读 b)。

如果你想打印真实链表结构,可以在 dev 下临时 patch:

js 复制代码
// 在浏览器控制台里,通过断点进入 Dep.track,或临时改造源码加日志
// 观察 sub.deps 链的 version 变化:run 前全为 -1,读到的变回 dep.version

2.8 嵌套 effect 与"effect 栈"

2.8.1 run() 逐行走读:本地变量就是"栈"

完整看 run()(effect.ts:162-192):

ts 复制代码
run(): T {
  // TODO cleanupEffect

  if (!(this.flags & EffectFlags.ACTIVE)) {
    // stopped during cleanup
    return this.fn()
  }

  this.flags |= EffectFlags.RUNNING
  cleanupEffect(this)
  prepareDeps(this)
  const prevEffect = activeSub          // ← 保存"父"订阅者
  const prevShouldTrack = shouldTrack
  activeSub = this                      // ← 自己成为当前订阅者
  shouldTrack = true

  try {
    return this.fn()
  } finally {
    if (__DEV__ && activeSub !== this) {
      warn(
        'Active effect was not restored correctly - ' +
          'this is likely a Vue internal bug.',
      )
    }
    cleanupDeps(this)
    activeSub = prevEffect              // ← 恢复父订阅者
    shouldTrack = prevShouldTrack
    this.flags &= ~EffectFlags.RUNNING
  }
}

七个关键步骤:

步骤 代码 目的
① 判活 if (!(flags & ACTIVE)) return this.fn() 已 stop 的 effect 再被误调用时,只执行 fn、不建立任何依赖 (因为 activeSub 不会设为自己)
② 置 RUNNING `flags = RUNNING`
③ 执行 cleanup cleanupEffect(this) 先清旧副作用(如上次注册的事件监听)
④ 预置依赖 prepareDeps(this) 把旧 link 版本置 -1、抢 activeLink
⑤ 保存 + 接管 prevEffect = activeSub; activeSub = this 在栈帧局部变量里保存父订阅者,接管全局当前订阅者
⑥ 执行 fn try { return this.fn() } 真正跑用户逻辑,期间所有读都会 track 到 this
⑦ finally 恢复 cleanupDeps + activeSub = prevEffect + 清 RUNNING 无论成功/抛错都恢复,必须放在 finally

"栈"体现在哪? ⑤ 的 prevEffect 存在当前 JS 调用栈帧 里;嵌套 effect 时,父 effect 的 run() 尚未返回,其栈帧还活着,所以它的 prevEffect(祖父订阅者)也被自然保留。⑦ 恢复时把 activeSub 还原为父------JS 调用栈本身就是 effect 栈 ,无需额外的 effectStack: ReactiveEffect[] 数组。这是 3.5 相对 3.4 的简化之一。

2.8.2 为什么 activeSub 必须在 finally 里恢复

activeSub 是全局单变量 (effect.ts:39)。它是"当前谁在读数据"的唯一真相来源。任何一条路径忘了恢复它,后果是灾难性的:

  • 若 fn 抛错而不恢复:activeSub 会永远停留在抛错的 effect 上,之后所有不在 effect 里的读操作都会被错误地订阅到这个 effect(内存泄漏 + 幽灵更新)。
  • 若正常路径恢复、错误路径不恢复:行为随错误出现而变,属于最难排查的"偶发"bug。

因此 finally 是唯一正确 的位置。同理,shouldTrack 与 flags &= ~RUNNING 也在 finally。

run() 的 DEV 检查(effect.ts:181-186)正是为这个不变量设的守门人:

ts 复制代码
if (__DEV__ && activeSub !== this) {
  warn('Active effect was not restored correctly - this is likely a Vue internal bug.')
}

在 fn 返回后、activeSub 还没被恢复前(effect.ts:181 在 cleanupDeps 之前),断言 activeSub 仍是 this。如果用户的 fn 内部有没恢复 activeSub 的自定义 effect 逻辑 (例如手动改 activeSub、或异步 await 期间跑了别的 effect 却不还原),这里就会报警。注意:这个警告只会出现在开发模式,且理论上只会因 Vue 内部 bug 或第三方库直接操作内部变量而触发。

注意异步边界 :async effect(如 <script setup> 里 await 后再用响应式)无法在同一个 finally 里跨越 await 恢复------因为 fn() 返回的是 Promise,finally 会在第一个 await 处 就执行,此时 effect 实际上"提前结束"了。这正是 withAsyncContext() / __restore() 与 2.10 里"构造时 scope 已停止"那段防御代码要解决的问题。

2.8.3 嵌套 effect 示例:activeSub 的完整轨迹

js 复制代码
import { effect, reactive } from 'vue'
const state = reactive({ outer: 1, inner: 10 })

const outerRunner = effect(() => {
  console.log('outer run, outer =', state.outer)   // 读 state.outer
  const inner = effect(() => {
    console.log('  inner run, inner =', state.inner) // 读 state.inner
  })
})

执行轨迹(用 (activeSub) 表示全局变量值):

text 复制代码
外层 effect() 调用
  outerRunner.run():
    prevEffect = undefined          ← 保存
    activeSub = outerEffect         ← 接管
    fn():
      console.log outer → track(state.outer) → Dep(outer).activeSub 是 outer effect
      内层 effect() 调用
        innerEffect.run():
          prevEffect = outerEffect  ← 保存(栈帧局部变量)
          activeSub = innerEffect   ← 接管
          fn():
            console.log inner → track(state.inner) → Dep(inner) 订阅 inner effect
          finally:
            cleanupDeps(innerEffect)
            activeSub = outerEffect ← 恢复为父
            flags &= ~RUNNING
      内层 effect() 返回 → 继续外层 fn
    finally:
      cleanupDeps(outerEffect)
      activeSub = undefined         ← 恢复为最外层(undefined)

结果依赖关系:

text 复制代码
Dep(state.outer).subs  → [outerEffect]
Dep(state.inner).subs  → [innerEffect]

现在 state.inner = 11:只有 innerEffect 被触发,outerEffect 不会 重跑(因为外层没有读 state.inner)。这就是"嵌套 effect 的依赖是各自独立的"。

但有个陷阱 :如果内层 effect 是在外层每次重跑时都新建 的,那么每次外层重跑都会创建一个新 inner effect,旧的不销毁 → 泄漏。正确写法是用 effectScope(见 2.8.4):

js 复制代码
const scope = effectScope()
scope.run(() => {
  effect(() => { /* ... */ })   // 被 scope 捕获,scope.stop() 时统一停止
})

在 Vue 组件里,这个 scope 就是组件的 instance.scope------所以组件内的 watch/computed/渲染 effect 都会随组件卸载而被停止。

2.8.4 effectScope 如何接管生命周期

EffectScope(effectScope.ts:6-196)与 activeEffectScope(effectScope.ts:4)是 effect 生命周期的容器。关键的 ReactiveEffect 构造函数(effect.ts:116-131):

ts 复制代码
constructor(public fn: () => T) {
  if (activeEffectScope) {
    if (activeEffectScope.active) {
      activeEffectScope.effects.push(this)      // 被当前活动 scope 收养
    } else {
      // 活动 scope 已停止:本 effect 会成为孤儿 → 直接清 ACTIVE
      this.flags &= ~EffectFlags.ACTIVE
    }
  }
}

"构造即收养" :new 一个 ReactiveEffect 时,如果此刻有活动的 activeEffectScope,就把自己 push 进它的 effects 数组,于是未来 scope.stop() 会调用每个 effect.stop()(effectScope.ts:166-169)。

else 分支(effect.ts:120-129)是 3.5 新增的防御,注释解释了场景:"组件 setup 在顶层 await 之后被恢复(通过编译器生成的 __restore()),但组件在 <Suspense> 下 pending 期间被卸载" 。此时 activeEffectScope 指向一个已经 stop 的 scope 。若不拦,这个 effect 会变成孤儿(不被任何 scope 持有,无法通过 scope 链停止),却仍能订阅响应式依赖并永远存活。所以直接清掉 ACTIVE,让它"生来就是死的"。

EffectScope.run()(effectScope.ts:106-118)与 on()/off()(effectScope.ts:125-160)分别用"切换 activeEffectScope + try/finally 恢复"的同一套模式维护作用域栈------与 activeSub 的处理如出一辙:

ts 复制代码
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.`)
  }
}
全局变量 所属模块 保存/恢复位置 作用
activeSub effect.ts:39 ReactiveEffect.run / refreshComputed 的 finally 当前依赖收集目标
activeEffectScope effectScope.ts:4 EffectScope.run / on-off 的 finally 当前 effect 容器

二者互相独立 :一个 effect 的 activeSub 生命周期是"单次 run",activeEffectScope 生命周期是"某段作用域代码块"。嵌套 effect 只影响 activeSub;effectScope().run() 只影响 activeEffectScope。

2.8.5 3.4 → 3.5:effectStack / parent 的移除再对照

维度 Vue 3.4 Vue 3.5.44
记录"父 effect" 模块级 effectStack: ReactiveEffect[],push/pop 无数组 ,靠 run() 的栈帧局部 prevEffect
ReactiveEffect.parent 有(指向栈中上一个) 无
嵌套恢复 effectStack.pop(); activeEffect = effectStack.at(-1) activeSub = prevEffect(直接赋值)
内存 一个数组 + 每个 effect 一个 parent 指针 零额外结构
失效点 数组操作与 effect 生命周期解耦,易漏 pop 依赖 finally,编译器保证

结论:3.5 把"显式栈"降级为"隐式调用栈 + 局部变量"------同样的语义、更少的字段、更少的状态维护 。这是贯穿 3.5 响应式重构的一条主线:用语言自身的机制(调用栈、闭包、位运算、链表指针)取代框架自建的运行时结构。


2.9 批处理机制:startBatch / endBatch / NOTIFIED

2.9.1 为什么需要批处理:三个 effect 的单次写放大

先看"没有批处理会怎样"。设:

js 复制代码
const state = reactive({ a: 1, b: 1 })
effect(() => { console.log('E1', state.a + state.b) })  // 依赖 a、b
effect(() => { console.log('E2', state.a) })            // 依赖 a

执行 state.a = 2:

  • Dep(a) 的 subs 链里有 E1、E2,会分别 notify;
  • 若每个 notify 都立即 run(),则 E1 重跑、E2 重跑。
  • 但 E1 同时依赖 a 和 b,如果紧接着 state.b = 2(两次写),E1 会跑两次。

更糟的是三角依赖:

js 复制代码
const state = reactive({ a: 1, b: 2, c: 3 })
effect(() => { console.log('sum', state.a + state.b + state.c) })
// 一次"批量"修改 a、b、c 三者:
state.a = 10; state.b = 20; state.c = 30

没有批处理时,这一 effect 会被 trigger 三次、run 三次------渲染同一 DOM 三次。批处理的目标就是:"把同一轮同步修改里对一个 effect 的多次通知,压缩成一次执行。"

2.9.2 三个模块级变量与两个函数

ts 复制代码
// effect.ts:247-249
let batchDepth = 0
let batchedSub: Subscriber | undefined
let batchedComputed: Subscriber | undefined

// 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
}

// effect.ts:265-267
export function startBatch(): void { batchDepth++ }

// effect.ts:273-310
export function endBatch(): void {
  if (--batchDepth > 0) return                      // 深层批:先不动
  ... // 处理 batchedComputed(清 NOTIFIED)→ 处理 batchedSub(清 NOTIFIED + trigger)
}

设计要点:

  1. batchDepth 计数而非布尔 :trigger 自己 startBatch,Dep.notify 又 startBatch,形成嵌套。只有最外层 endBatch 让计数归零时才真正执行。这保证了"所有 dep 遍历完毕、所有订阅者都已入队"之后才统一跑------彻底避免"跑到一半又被通知"的递归。
  2. batchedSub 头插单链表 :sub.next 只有在批处理期间才有意义(2.3.5)。
  3. NOTIFIED 是去重的关键 :batch 置位;同一条 sub 第二次被 notify 时,notify 里 !(flags & NOTIFIED) 为假 → 不再 batch(effect.ts:157)。所以同一 effect 在同一批里最多入队一次。
  4. batchedComputed 与 batchedSub 分离 :computed 只清标志(2.6.5),effect 才 trigger。

2.9.3 三角依赖的"重复执行"如何被消除

回到 state.a = 10 的完整过程(假设 E1 依赖 a、b,E2 依赖 a):

text 复制代码
用户:state.a = 10
  baseHandlers.set → trigger(state, 'set', 'a', 10, 1)
    targetMap 存在
    startBatch()                     batchDepth: 0 → 1
    run(Dep(a)):
      Dep(a).trigger():
        version++, globalVersion++
        notify():
          startBatch()                batchDepth: 1 → 2
          从 subs 尾往前:E2(晚)、E1(早)
          E2.notify(): NOTIFIED 未置 → batch(E2)   batchedSub = E2
          E1.notify(): NOTIFIED 未置 → batch(E1)   batchedSub = E1 → E2
          endBatch()                  batchDepth: 2 → 1,>0 不执行
    endBatch()                        batchDepth: 1 → 0 → 执行
      清 batchedComputed
      batchedSub = E1 → E2:
        取 E1:清 NOTIFIED;ACTIVE → trigger() → runIfDirty → isDirty 真 → run()
               E1 重跑:读 a、b(本轮 a 已变)→ 输出一次
        取 E2:清 NOTIFIED;ACTIVE → trigger() → runIfDirty → ... → run()
               E2 重跑

E1 只跑一次 。即使 E1 依赖 a 和 b、而本次只改了 a,Dep(a) 的通知也只让 E1 入队一次(NOTIFIED 挡住后续重复)。

再看连改 a、b 两次:

text 复制代码
state.a = 10   → 上面过程 → 批结束后 E1、E2 各跑一次(E1 读到 a=10)
state.b = 20   → trigger(b) → 只有 E1 依赖 b → E1 入队 → E1 再跑一次

这里有两次独立的批 ,中间 E1 确实跑了两轮------这是符合预期的 :两次同步写之间没有 await,Vue 的 effect(无 scheduler)是同步的,所以每次写都触发。若要"合并两次写",需要 watchEffect 配合 flush: 'post' / nextTick(属于第 14 章的调度器)。

关键区分 :本节讲的 batch/endBatch 是一次 trigger 内部的微批 (把所有 dep 派生出的通知合并),不是"跨多个用户写操作的宏批"。宏批由 runtime-core 的 queueJob/flushJobs 负责(第 14 章)。不要混淆。

2.9.4 batchedComputed 的必要性:清标志防"假死"

前面 2.6.5 已讲,这里补一个反面示例说明为什么要专门清它。

假设有 computed C(依赖 a),effect E 读 C.value:

text 复制代码
state.a = 1
  → Dep(a).trigger → notify → C.notify()
      C.flags |= DIRTY
      activeSub !== C 且 !NOTIFIED → batch(C, true)    batchedComputed = C
  → 同时 E 也在 Dep(a) 吗?不,E 依赖的是 C.dep,不是 a.dep。
    但 C 作为 Subscriber 在 Dep(a) 里,E 在 C.dep 里。
  → C 的 batch(, true) 返回 true → Dep.notify 里额外调 C.dep.notify()
      → 遍历 C.dep 的 subs(E)→ E.notify() → batch(E)   batchedSub = E
  → endBatch():
      先清 batchedComputed 中 C 的 NOTIFIED,再处理 E → E.run() → 读 C.value → refreshComputed(C)

如果不先清 C 的 NOTIFIED :E.run() 里读 C.value → refreshComputed 会先检查 C.flags & TRACKING && !(C.flags & DIRTY)(effect.ts:377-381)------DIRTY 已在 notify 时置位,所以能重算;但 NOTIFIED 残留会影响到下一次 state.a 变化时 C.notify() 里的 !(flags & NOTIFIED) 判定------那次会让 C 不再入队 ,E 也就不再被通知。于是 C 和 E 会"假死"。所以 endBatch 必须在处理 batchedSub 之前 先清 batchedComputed 的 NOTIFIED(effect.ts:278-287)。

2.9.5 pauseTracking / enableTracking / resetTracking

除 2.5.8 讲的"全局追踪开关"外,这三个函数还常与批处理配合,用于读取但不订阅 的场景。源码见 effect.ts:519-544(2.5.8 已列)。三种典型用法:

js 复制代码
// 用法 1:读快照不订阅
pauseTracking(); const snap = state.big; resetTracking()

// 用法 2:在 scope cleanup 里读值不订阅(避免 cleanup 里制造新依赖)
// 用法 3:DEVELOPMENT 下对已有依赖的 effect 做 traverse(watch 的 deep)

易错点:

  • 必须成对 。pauseTracking() 不配对 resetTracking() 会导致之后所有 effect 都不收集依赖(大面积"响应式失效")。
  • enableTracking() 也是 push 当前值再置 true,所以它同样需要 resetTracking() 收尾 ,不能只写 enableTracking()。

2.9.6 批处理与"嵌套 trigger"的边界

批处理只保证同一 trigger 调用内的去重。考虑这个情形:

js 复制代码
effect(() => {
  state.a            // E 读 a
})
effect(() => {
  state.a = state.a + 1    // F 读 a(作为右值),也写 a
})

state.a 在 F 里既读又写。F 的读让 F 订阅 Dep(a);F 的写 trigger(a):

  • 若 F 正在运行(RUNNING)→ F.notify() 会被自触发拦截(RUNNING && !ALLOW_RECURSE,2.4.4)→ F 不会因为自己写自己而重跑。
  • E 不在运行 → E 入队 → 批结束后 E 跑。

这正是 allowRecurse(ALLOW_RECURSE)存在的场景 :某些内部 effect(如 Vue 的 render effect 在特定情况下)需要"允许自己触发自己",做法是给 options 传 allowRecurse: true。但要极其谨慎,否则会陷入"改数据 → 触发自己 → 又改数据"的无限递归。默认禁止递归是安全的选择。


2.10 生命周期:stop / pause / resume / onStop

2.10.1 stop():摘除全部订阅 + 执行 cleanup

ts 复制代码
// effect.ts:194-204
stop(): void {
  if (this.flags & EffectFlags.ACTIVE) {
    for (let link = this.deps; link; link = link.nextDep) {
      removeSub(link)                 // ① 从每个 dep 的 subs 链摘除自己
    }
    this.deps = this.depsTail = undefined   // ② 清空自己的 deps 链
    cleanupEffect(this)               // ③ 执行注册的 cleanup
    this.onStop && this.onStop()      // ④ 通知外部
    this.flags &= ~EffectFlags.ACTIVE // ⑤ 置为非活
  }
}

五步的含义:

  1. 遍历 deps 逐个 removeSub :注意这里只 removeSub,不 removeDep------因为下一步就把整条 deps 链丢弃了(this.deps = depsTail = undefined),逐个摘 dep 侧指针就够了。这比"两个链都逐个摘"更快。
  2. 清空 deps 链 :让 Link 对象失去 sub 的强引用。注意 removeSub 里的 computed 级联(effect.ts:451-460)与 dep.sc 递减(effect.ts:463)保证 dep 若无人订阅会被从 depsMap 回收。
  3. cleanupEffect:执行最后一次 cleanup(例如移除 event listener)。
  4. onStop:用户回调,用于额外释放(关闭连接、清理定时器)。
  5. 清 ACTIVE :之后 trigger 即便遍历到它,endBatch 里 if (e.flags & ACTIVE)(effect.ts:297)也不会执行它------已停止的 effect 即使残留入队也会被跳过 ,这是 run() 里 if (!(flags & ACTIVE)) return this.fn() 之外的第二道保险。

注意 :stop() 是幂等 的(外层 if (ACTIVE) 守卫)。重复 stop() 不会重复执行 onStop/cleanup。这也让 EffectScope.stop() 里对每个 effect 调用 stop()(effectScope.ts:166-169)变得安全。

stop(runner)(effect.ts:512-514)只是 runner.effect.stop() 的语法糖。runner 的 effect 属性在 effect() 工厂里绑定(effect.ts:503)。

2.10.2 pause() / resume():暂停与补跑

ts 复制代码
// 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()                 // 暂停期间被通知过 → 立即补跑
    }
  }
}

与 PAUSED 标志配合的是模块级 pausedQueueEffects: WeakSet<ReactiveEffect>(effect.ts:85):

阶段 行为
pause() 置 PAUSED。之后该 effect 被 Dep 通知时,trigger() 走 PAUSED 分支:pausedQueueEffects.add(this)(effect.ts:207-208),不执行,只记账
resume() 清 PAUSED;若 pausedQueueEffects 里有它,删掉并立即 trigger() 补跑一次(effect.ts:140-143)

设计动机 :pause/resume 用于"临时冻结"(如 EffectScope.pause() 冻结整棵 scope,effectScope.ts:68-104)。冻结期间数据可能变了很多次,但 effect 不需要逐个响应------只需"恢复时跑一次拿到最新值"。WeakSet 保证"记账"不阻止 effect 被 GC。

EffectScope.pause()(effectScope.ts:68-82)递归 pause 所有子 scope 与 effect;resume()(effectScope.ts:87-104)用 slice() 拷贝再遍历,防御遍历中数组被修改(例如 resume 触发的重跑又创建了新 effect)。这是遍历可变集合时的标准写法。

2.10.3 onEffectCleanup:注册 cleanup 的唯一入口

ts 复制代码
// effect.ts:558-567
export function onEffectCleanup(fn: () => void, failSilently = false): void {
  if (activeSub instanceof ReactiveEffect) {
    activeSub.cleanup = fn            // 注:直接覆盖,不是追加
  } else if (__DEV__ && !failSilently) {
    warn(`onEffectCleanup() was called when there was no active effect to associate with.`)
  }
}

注意 activeSub.cleanup = fn 是赋值覆盖 ,不是 array.push。也就是说一个 effect 同时只能有一个 cleanup ------后注册的会覆盖先注册的。(这与 onScopeDispose 不同,后者用 cleanups.push 支持多个,effectScope.ts:227-229。)

failSilently 参数(默认 false)让"明知没有 active effect 也要注册"的内部调用方(如 watch 内部)可以静默跳过,不刷警告。这是 3.5 新增的便利参数。

2.10.4 EffectScope 如何驱动 stop

EffectScope.stop()(effectScope.ts:162-195)的流程:

text 复制代码
stop(fromParent?)
  if (_active):
    _active = false                                   // 先置非活,防重入
    effects[i].stop()  × N   然后 effects.length = 0   // 停掉所有被收养的 effect
    cleanups[i]()      × N   然后 cleanups.length = 0  // 执行 onScopeDispose 回调
    scopes[i].stop(true) × N 然后 scopes.length = 0     // 递归停子 scope(fromParent=true)
    若非 detached 且有 parent:从 parent.scopes 里 O(1) 摘除自己  // 避免泄漏
    parent = undefined

三个细节:

  1. _active = false 先于一切 :这样即使 stop 过程中有人往 scope 里 push 新 effect(构造时检查 activeEffectScope.active),也会被 effect.ts:118-129 的防御分支拒绝。
  2. scopes[i].stop(true) 的 fromParent=true :告知子 scope"是父在删你,不要再反向去父的 scopes 数组里摘自己"(否则父正在 length = 0,会重复操作)。
  3. parent.scopes 的 O(1) 摘除 (effectScope.ts:185-192):用 pop() 取最后一个元素顶替自己在 index 位置,再更新被顶替者的 index------数组 O(1) 删除的经典技巧 (避免 splice 的 O(n) 移位)。

组件里的实际链路 :组件 setup() 在一个 EffectScope 中运行(activeEffectScope = instance.scope),组件内创建的渲染 effect、watch、computed 的 effect 都被收养。组件卸载时 unmountComponent 调 instance.scope.stop() → 所有 effect stop → 依赖全摘 → 无泄漏。这就是"响应式与组件生命周期绑定"的机制。

2.10.5 cleanup 的意义:与"依赖回收"的区别

初学者常把 cleanup 和 cleanupDeps 混淆,这里澄清:

名称 位置 作用对象 目的
cleanup()(effect.cleanup) cleanupEffect(effect.ts:569) 用户注册的副作用(定时器、监听、异步取消) 防止上一次 effect 执行的"外部副作用"泄漏
cleanupDeps(sub) effect.ts:324 依赖链 Link 节点 防"本轮不再读的 dep"仍触发 effect(响应式正确性)

二者互相独立、时机不同 :cleanup 在 run() 开头(effect.ts:171)与 stop()(effect.ts:200)执行;cleanupDeps 在 run() 的 finally(effect.ts:187)。一个管"副作用",一个管"订阅"。


2.11 递归自触发与常见陷阱

2.11.1 自增计数器为什么只触发一次

js 复制代码
const count = ref(0)
watchEffect(() => {
  console.log('run', count.value)
  if (count.value < 5) count.value++   // 在 effect 内部改自己依赖的数据
})

count.value++ 发生在 activeSub 正在执行的 effect 内。时序:

text 复制代码
run() 开始 → flags |= RUNNING
fn():
  console.log count.value    → track,订阅 count
  count.value++              → Dep.trigger → count 的 subs 里就是当前 effect
    → effect.notify()
        RUNNING 置位 且 未置 ALLOW_RECURSE → **直接 return**(effect.ts:151-155)
    于是:本次自触发被丢弃,effect 不入队
fn() 结束 → finally 清 RUNNING,cleanupDeps

结果:count.value++ 不会让 effect 立即重跑 ,控制台只输出一次。只有当外部 再次修改 count(此时 effect 不在 RUNNING)时,它才会被正常触发。

这是 Vue 与 React 的显著差异:React 的 setState 在 effect 内会排队重渲染;Vue 的 effect 内自改依赖默认被丢弃。若你确实需要"写完立刻重跑",要么在外部写,要么给 effect 传 allowRecurse: true(慎用,见 2.11.4)。

2.11.2 activeSub === this 判定:另一道自触发防线

除了 effect 侧的 RUNNING 拦截,computed 侧 有独立的防线(computed.ts:117-129):

ts 复制代码
notify(): true | void {
  this.flags |= EffectFlags.DIRTY
  if (
    !(this.flags & EffectFlags.NOTIFIED) &&
    activeSub !== this                // ← 防"自己读自己"导致的自递归
  ) {
    batch(this, true)
    return true
  } else if (__DEV__) {
    // TODO warn
  }
}

activeSub !== this 的含义:如果这个 computed 正在求值(activeSub === computed),那么它内部的某个 dep 变化触发了它自己的 notify------不要把自己入队 (否则会"求值中又被要求重新求值")。注意 computed 没有 RUNNING 位的拦截,它只有 activeSub !== this 这一道,因此这个判定对 computed 至关重要。(refreshComputed 里确实会置 RUNNING(effect.ts:405),但 computed.notify 不检查 RUNNING,所以 activeSub 判定是主防线。)

而 Dep.track 里的 activeSub === this.computed(dep.ts:109)是同一思想的读侧版本:computed 求值时读到自己的 dep,不订阅。

2.11.3 常见陷阱清单

陷阱 现象 根因 规避
effect 内自改依赖 只跑一次 / 不更新 RUNNING 拦截自触发 外部改,或用 allowRecurse / nextTick
异步 effect 依赖断链 await 之后的读不订阅 finally 在第一个 await 处已恢复 activeSub 用 withAsyncContext(Vue 内部)或重新进入 effect
pauseTracking 不配对 之后所有响应式"失效" 全局 shouldTrack 卡在 false 必须 resetTracking
依赖被 GC 提前回收 偶发不触发 条件分支切换后 cleanupDeps 真删了 link 属正常语义;用 computed 缓存中间值
无限递归(stack overflow) Maximum call stack effect A 改 B、B 的 effect 改 A allowRecurse 关闭 + 拆分依赖
watchEffect 里读大对象 性能差 深读触发大量 track shallowRef / markRaw / pauseTracking
computed 误当 reactive 改不动 / 不生效 computed.value 只读(无 setter 时 computed.ts:150-153 警告) 用可写 computed 或 ref

2.11.4 allowRecurse 的正确用法与风险

allowRecurse 只应被框架内部在明确知道"递归是有限且必要"的场景使用(历史实现里渲染 effect 在某些调度场合需要它)。用户层几乎不需要:

js 复制代码
// 反面教材:会导致 A 改 B、B 改 A 的无限递归
const a = ref(0), b = ref(0)
effect(() => { b.value = a.value + 1 }, { allowRecurse: true })
effect(() => { a.value = b.value + 1 }, { allowRecurse: true })
// → RangeError: Maximum call stack size exceeded

结论 :allowRecurse 是"逃生舱",不是"快捷键"。默认的"禁止递归"保护能挡掉 99% 的意外循环,滥用它等于自己拆掉安全带。


2.12 调试与观测

2.12.1 可用的观测入口

入口 位置 可获取信息
activeSub effect.ts:39 当前正在收集依赖的 Subscriber。内部变量 ,3.5.44 未以 getCurrentEffect 之名导出(公开 API 只有 getCurrentScope),调试时需经断点或临时改造源码观察
getCurrentScope() effectScope.ts:216 当前活动 EffectScope(用于判断 effect 归属哪个组件/作用域)
getCurrentWatcher() watch.ts(index.ts:85 导出) 当前 watcher 实例,是"当前 effect"在 watch 场景下的公开替身
onTrack / onTrigger effect.ts:113-114、DebuggerOptions(effect.ts:23-26) 每次收集/触发的 { target, type, key, newValue?, oldValue? }
toRaw / isReactive / isReadonly reactive.ts:391/327/346 判断一个对象是否被代理、代理类型

版本差异提醒 :任务资料里常见的 getCurrentEffect() 取自早期/其他分支;在 3.5.44 里,等价的公开入口是 getCurrentScope()(index.ts:78)与 getCurrentWatcher()(index.ts:85),activeSub 本身仍是内部符号。

2.12.2 用 onTrack/onTrigger 观测依赖

js 复制代码
import { reactive, watchEffect } from 'vue'
const state = reactive({ count: 0 })

watchEffect(() => console.log('count =', state.count), {
  onTrack(e) { console.log('[track]', e.key, '←', e.type) },
  onTrigger(e) { console.log('[trigger]', e.key, e.oldValue, '→', e.newValue) },
})
// 输出:
// [track] count ← get
// count = 0
state.count++
// 输出:
// [trigger] count 0 → 1
// [track] count ← get
// count = 1

onTrack 打印的顺序 = fn 内读取顺序 (对应 deps 链顺序),可用于确认"这个 effect 到底依赖了哪些字段"------排查漏依赖/多依赖的第一工具。

2.12.3 effect 泄漏排查

泄漏的典型症状:组件卸载后仍然后台更新、内存持续上涨。排查步骤:

  1. 确认 effect 是否被 scope 收养 :若在组件外 effect()(activeEffectScope 为 undefined),它不会被任何 scope 管理(effect.ts:117 的 if (activeEffectScope) 为假)------必须手动 stop(runner)。组件内创建的才会挂到 instance.scope。
  2. 确认嵌套 effect 有没有重复新建 :外层 effect 每次重跑都 effect(...) 新建内层 → 每轮泄漏一个。修法:把内层包进 effectScope 或提到外层只建一次。
  3. 确认 stop 是否真的摘干净 :stop() 遍历 deps 逐个 removeSub(effect.ts:196),若某 effect 停止后其依赖仍被引用,通常是"有人直接持有了 runner 的闭包变量"。
  4. DevTools 观测 :在 EffectScope.stop() 与 stop() 打断点,或对目标对象用 getDepFromReactive(obj, key)(dep.ts:391)拿到 Dep,检查其 subs 链是否仍挂着已卸载组件的 effect。

2.12.4 reactive 误当 readonly 的两类坑

坑一:对 readonly 对象调用 reactive() 不生效。 reactive() 开头就有短路(reactive.ts:89-91):

ts 复制代码
if (isReadonly(target)) return target   // 只读对象原样返回

所以 reactive(readonly({})) 返回的是 readonly 代理,赋值会触发"只读"警告而非报错。排查时用 isReactive / isReadonly(reactive.ts:327、346)区分。

坑二:isReactive(readonly(reactive({}))) 为 true。 isReactive 会先剥离 readonly(reactive.ts:328-330):

ts 复制代码
export function isReactive(value) {
  if (isReadonly(value)) return isReactive((value as Target)[ReactiveFlags.RAW])
  return !!(value && (value as Target)[ReactiveFlags.IS_REACTIVE])
}

所以"readonly 包裹 reactive"仍被判定为 reactive------这是刻意设计(只读是"视图层"限制,底层数据仍是响应式)。易错点 :不要用 isReactive 判断"能否写",要用 isReadonly。

ReactiveFlags(constants.ts:17-24)是这些判定的底层契约:IS_REACTIVE='__v_isReactive'、IS_READONLY='__v_isReadonly'、RAW='__v_raw'、SKIP='__v_skip'、IS_REF='__v_isRef'、IS_SHALLOW='__v_isShallow'。它们只是普通字符串键 (constants.ts:1-2 注释:using literal strings instead of numbers so that it's easier to inspect debugger events)------用字符串而非数字,正是为了调试时一眼可读。


2.13 本章要点回顾

  1. 三条响应式路线的取舍:defineProperty(初始化劫持、拦不住新增/集合)、编译期信号(零运行时、强耦合编译器)、Proxy(对象级拦截、动态收集、透明)------Vue 3 选 Proxy,用链表与位运算把运行时开销压到最低。
  2. 闭环架构 :reactive 代理 → get trap → track → targetMap→Dep→Link → effect.run() 重跑重新 track;set trap → trigger → Dep.notify → 遍历 subs → batch → endBatch → runIfDirty。
  3. ReactiveEffect 字段 :fn、deps、depsTail、flags、next、cleanup、scheduler、onStop、onTrack、onTrigger。3.4 的 active/dirtyLevel/allowRecurse/trackId/parent 已折叠进 flags 或删除。
  4. EffectFlags 八位 :ACTIVE(1)、RUNNING(2)、TRACKING(4)、NOTIFIED(8)、DIRTY(16)、ALLOW_RECURSE(32)、PAUSED(64)、EVALUATED(128)。ACTIVE 仅 effect 用;NOTIFIED 负责批去重;RUNNING\|ALLOW_RECURSE 决定自触发是否被拦。
  5. track 三步 :shouldTrack && activeSub 守卫 → Dep.track 三分支(新建/复用同步并搬尾/直通)→ addSub 挂 subs 链。activeLink 是 O(1) 复用缓存。
  6. trigger 调度表 :按 set/add/delete/clear/length 决定触发主键、length、三个 ITERATE_KEY;Dep.notify 从 subs 尾往前 遍历,batch 头插 ,endBatch 从头取 → 执行顺序 = 原始订阅顺序。
  7. 双向链表 :一个 Link 同时在 sub.deps 与 dep.subs 两条链上。prepareDeps 置 version=-1 并抢 activeLink;cleanupDeps 从尾倒扫回收未读节点并归还 activeLink;稳态零分配。
  8. effect 栈的消失 :activeSub + run() 的 try/finally + 栈帧局部 prevEffect 取代 3.4 的 effectStack;activeEffectScope 由 EffectScope.run/on/off 同样方式维护。
  9. 批处理 :startBatch/endBatch 用 batchDepth 计数,NOTIFIED 去重,batchedComputed 先清标志再执行 batchedSub;pauseTracking/resetTracking 控制全局收集开关(必须成对)。
  10. 生命周期 :stop() 摘除依赖 + cleanup + onStop + 清 ACTIVE(幂等);pause/resume 用 pausedQueueEffects 记账补跑;EffectScope.stop() 递归停 effect 与子 scope。
  11. 递归陷阱 :effect 内自改依赖被 RUNNING 拦下(只跑一次);computed 靠 activeSub !== this 自保;allowRecurse 是内部逃生舱,用户慎用。
  12. 调试 :onTrack/onTrigger 观测依赖与触发;getCurrentScope/getCurrentWatcher 定位归属;toRaw/isReactive/isReadonly 辨别代理类型。

下一章预告 :本章只讲了"effect 与依赖追踪"这一层机制。下一章(第 3 章)将拆开 reactive.ts、ref.ts、baseHandlers.ts、collectionHandlers.ts,讲清 Proxy 的 get/set/has/deleteProperty/ownKeys 各 trap 如何调用本章的 track/trigger,以及数组 length、Map/Set 迭代键、ref 自动解包等代理陷阱的完整实现。

相关推荐
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 06 章 编译器内幕(二):`transform` 管线与结构指令
前端·vue.js·开源
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 09 章 编译器内幕(五):compiler-sfc的SFC解析与<script setup> 编译
前端·vue.js·开源
码银1 小时前
【web】基于HTML/CSS,JavaScript + Three.js做3D流程图
前端·css·html
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 17 章 运行时内幕(五):`runtime-dom`、自定义元素与内置组件
前端·vue.js·开源
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 13 章 运行时组件系统:实例、props、emit、slots 与生命周期
前端·vue.js·开源
Highcharts.js1 小时前
Highcharts React v5 正式发布:支持 ESM、更简洁的组件、更出色的开发体验
前端·react.js·前端框架·数据可视化·highcharts·前端可视化·esm
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 10 章 compiler-sfc(二)
前端·vue.js·开源
will_net1 小时前
Vue IIS部署 api 访问当前站点 代理跳转真实API 问题
前端·javascript·vue.js
weixin_449173651 小时前
react 的useEffect用法
前端·javascript·react.js