Vue 3 响应式核心:effect、scheduler、cleanup 与双向依赖设计

zijieLeo
53 分钟
9 次浏览
Vue 3 响应式核心:effect、scheduler、cleanup 与双向依赖设计

前言

上一篇我把 Vue 3 响应式的主线——从 Proxy 到 track/trigger,从 targetMap 三层结构到四种 effect 的调度分叉——完整串了一遍。

但写完之后我发现,effect 内部有几个设计,单独拿出来都懂,合在一起就容易乱。比如:

  • key.depeffect.deps 到底什么关系?为什么互相指着?
  • scheduler 到底怎么决定执行策略?分叉点在哪里?
  • cleanup 为什么必须做?不做会怎样?

这篇文章专讲这三个问题。它们不是三个独立的知识点,而是一套互相咬合的设计——scheduler 决定“什么时候执行”,cleanup 确保“只依赖该依赖的”,双向记录为 cleanup 提供“快速找到自己”的能力。


一、effect:不只是“要跑的函数”,它是一个完整的上下文对象

1.1 effect 的直觉理解

很多人(包括以前的我)会把 effect 简单理解成“数据变了要重新跑的函数”。这个理解不算错,但太粗糙。

更准确地说:effect 是一个“带依赖追踪和调度能力的副作用上下文”。

class ReactiveEffect {
  fn: Function          // 要执行的函数(副作用本身)
  scheduler?: () => void // 调度器(决定怎么执行)
  deps: Dep[] = []      // 记录自己被哪些 Dep 收集了(双向记录)
  parent?: ReactiveEffect  // 指向外层 effect(嵌套时用)

  run() {
    // 执行 fn,并在执行期间把自己设为 activeEffect
    // 这样 fn 里读到的所有响应式数据,都能通过 track 找到自己
  }
}

一个 effect 在生命周期里经历三个阶段:

  1. 创建:决定它是哪种类型(配什么 scheduler)
  2. 收集run() 时把自己挂成 activeEffect,读数据时被 track 收录
  3. 触发:数据变了,trigger 找到它,有 scheduler 就调 scheduler,没有就调 run

1.2 activeEffect:为什么需要一个全局变量

这是很多人卡住的第一个点——“正在执行的 effect”为什么要用全局变量标记?

答案很简单:Proxy 的 get trap 是自动触发的,不是手动传参调用的。

// 假设没有 activeEffect,你只能这样传:
function track(target, key, effect) { ... }

// 但 Proxy get 的签名是固定的,没地方传 effect:
get(target, key, receiver) {
  track(target, key)  // 谁来调?谁在读?track 怎么知道?
}

所以必须有一个全局变量作为“副作用通道”:

let activeEffect = null

class ReactiveEffect {
  run() {
    activeEffect = this  // 执行前,告诉全世界“现在是我在跑”
    const result = this.fn()
    activeEffect = null  // 执行完,恢复
    return result
  }
}

function track(target, key) {
  if (!activeEffect) return  // 不是 effect 触发的读,不收集
  // 现在 track 就知道是谁在读这个数据了
  dep.add(activeEffect)
}

activeEffect 就是“谁在用这个数据”的运行时上下文。

嵌套 effect 时,用 parent 指针+栈式结构保证内外层不混淆:

run() {
  // 保存外层
  this.parent = activeEffect
  // 把自己设为当前
  activeEffect = this
  // ...
  // 恢复外层
  activeEffect = this.parent
}

二、双向依赖:dep → effect 和 effect → deps

2.1 什么是双向记录

先看一个具体例子:

const person = reactive({ name: '张三', age: 18 })

effect(() => {
  console.log(person.name, person.age)
})  // 假设这个 effect 叫 effect1

此时数据结构长这样:

targetMap (WeakMap):
  person → Map {
    'name' → Set { effect1 },   ← 这就是 name.dep
    'age'  → Set { effect1 }    ← 这就是 age.dep
  }

effect1.deps:
  [ name.dep, age.dep ]

dep 知道“谁依赖我”,effect 知道“我依赖谁”——互相指着,这就是双向记录。

2.2 正向:dep → effect,给 trigger 用

// 数据变了,要通知依赖者
person.name = '李四'

// trigger 要做的事:
function trigger(target, key) {
  const dep = targetMap.get(target)?.get(key)  // 通过 target+key 找到 dep
  if (!dep) return

  dep.forEach(effect => {
    // dep 里直接存着所有依赖这个 key 的 effect
    // O(1) 找到,O(n) 遍历触发
    if (effect.scheduler) {
      effect.scheduler()
    } else {
      effect.run()
    }
  })
}

有了 dep → effect,trigger 时不用遍历所有 effect 去找谁依赖了这个 key。

2.3 反向:effect → deps,给 cleanup 用

为什么需要反向记录?因为依赖不是静态的。

effect(() => {
  if (person.flag) {
    console.log(person.name)   // 依赖 name
  } else {
    console.log(person.age)    // 依赖 age
  }
})

第一次执行(flag = true)

person.name.dep = Set { effect }
person.age.dep  = Set { }           // 没读,空的
effect.deps     = [ person.name.dep ]

flag 变成 false,第二次执行——此时必须把 effect 从 person.name.dep 里删掉:

function cleanupEffect(effect) {
  // 遍历 effect.deps,从每个 dep 中删除自己
  for (const dep of effect.deps) {
    dep.delete(effect)        // 从 dep(Set) 中移除
  }
  effect.deps.length = 0      // 清空 deps
}

在这个例子里,cleanup 只遍历了 effect.deps = [person.name.dep],直接找到 person.name.dep 这个 Set,把自己删掉。O(n),n 是当前 effect 依赖的 key 数量,通常很小。

2.4 如果没有反向记录

如果只有 dep → effect,没有 effect → deps,cleanup 会变成什么?

// 想清理旧依赖,你只能:
// 遍历 targetMap 里的所有 WeakMap key(所有响应式对象)
//   → 遍历每个 key 的 Map(对象的所有属性)
//     → 遍历每个 dep Set
//       → 检查 "这个 effect 在不在里面"
// 这就不是 O(n) 了,是 O(所有响应式对象 × 所有属性 × 所有依赖)

双向记录的本质,是给两个查找方向各建一条索引:

  • dep → effect:trigger 时,知道“这个 key 变了要通知谁”
  • effect → deps:cleanup 时,知道“我之前存在哪些 dep 里,该从哪里删自己”

所以这不是双向链表,是两个独立的数据结构各自存一份引用。dep 是 Set 的一个成员关系,effect.deps 是数组里的一个元素,它们之间没有 prev/next 指针,也不构成链表结构。


三、cleanup:每次重新收集,确保依赖永不过期

3.1 完整的执行流程

把 cleanup 放进 effect.run() 的完整流程:

class ReactiveEffect {
  run() {
    // 第一步:清理旧依赖
    cleanupEffect(this)
    // 此时:effect 不再存在于任何 dep 中,effect.deps = []

    // 第二步:标记自己为当前 effect
    this.parent = activeEffect
    activeEffect = this

    // 第三步:执行 fn,重新建立依赖
    const result = this.fn()
    // fn 里读到 person.name
    //   → track → person.name.dep.add(this)
    //   → this.deps.push(person.name.dep)

    // 第四步:恢复
    activeEffect = this.parent
    return result
  }
}

每一次 effect.run() 都是一次完整的“清旧—建新”循环。 执行完以后,effect.deps 里只包含“这一次执行真正读到的 key 的 dep”。

3.2 一个完整的分支依赖例子

const state = reactive({ flag: true, a: 'a', b: 'b' })

const myEffect = new ReactiveEffect(() => {
  if (state.flag) {
    console.log(state.a)
  } else {
    console.log(state.b)
  }
})

// === 第一次执行:flag = true ===
effect.run()
// cleanupEffect: effect.deps = [],没什么可清
// activeEffect = effect
// 执行 fn → 读 state.flag → track,state.flag.dep 加入 effect
//          读 state.a    → track,state.a.dep 加入 effect
// 结果:
//   state.flag.dep = Set { effect }
//   state.a.dep    = Set { effect }
//   effect.deps    = [ state.flag.dep, state.a.dep ]

// === 修改 state.flag = false ===
state.flag = false
// trigger(state, 'flag')
//   → 找到 state.flag.dep = Set { effect }
//   → effect.scheduler 不存在,直接 effect.run()

effect.run()
// 1. cleanupEffect: 遍历 effect.deps = [state.flag.dep, state.a.dep]
//    → state.flag.dep.delete(effect)  → state.flag.dep = Set { }
//    → state.a.dep.delete(effect)     → state.a.dep    = Set { }
//    → effect.deps = []
// 2. activeEffect = effect
// 3. 执行 fn → 读 state.flag → track,state.flag.dep 再次加入 effect
//              读 state.b    → track,state.b.dep 加入 effect
// 4. 恢复
// 结果:
//   state.flag.dep = Set { effect }
//   state.a.dep    = Set { }           ← 空!不再被 effect2 依赖
//   state.b.dep    = Set { effect }    ← 新依赖
//   effect.deps    = [ state.flag.dep, state.b.dep ]

现在 state.a 变了,这个 effect 不会被触发——因为它现在根本不读 a 了。

3.3 不 clean 会怎样

上面的例子,如果不清:

state.a.dep = Set { effect }  ← 旧依赖还在

之后 state.a 每次修改都会触发这个 effect 重新执行,但它进去走 else 分支读 state.bstate.a 的值根本用不到。

结果:多余的重复执行 + 依赖集合越来越大 = 性能和语义双重错误。


四、scheduler:trigger 之后的分叉口

4.1 四种 effect 的区分——不是在运行时识别,而是在创建时配好

Vue 3 没有给 effect 加一个 type 字段,不存在 if (effect.type === 'component') 这种判断。

区分四种 effect 的方式只有一个:创建时配了不同的 scheduler。

// 四种创建方式
// 1. 普通 effect
new ReactiveEffect(fn)  // scheduler = undefined

// 2. 组件渲染 effect
const e = new ReactiveEffect(componentUpdateFn)
e.scheduler = () => queueJob(e)  // 放异步队列

// 3. computed effect
const e = new ReactiveEffect(getter)
e.scheduler = () => { _dirty = true; trigger(...) }  // 标记脏

// 4. watch effect
const e = new ReactiveEffect(getter)
e.scheduler = () => { callback(effect.run()) }  // 读新值 + 调回调

trigger 的逻辑从来没变过:

function trigger(target, key) {
  const dep = targetMap.get(target)?.get(key)
  if (!dep) return

  const effects = new Set(dep)  // 拷贝一份,避免遍历时结构变化
  effects.forEach(effect => {
    if (effect.scheduler) {
      effect.scheduler()  // 有调度器就交给你
    } else {
      effect.run()        // 没调度器就直接跑
    }
  })
}

分叉点在创建 effect 的地方——四个 API,四种 scheduler。

4.2 四种 scheduler 的完整对比

trigger(target, key)
  │
  ▼
找到 dep,遍历其中的 effect
  │
  ├─ 没有 scheduler(普通 effect)
  │     effect.run()
  │     同步立即执行 fn
  │     场景:用户直接调用 effect()
  │     特点:改一次跑一次,没有任何中间层
  │
  ├─ 组件渲染 scheduler
  │     queueJob(effect)
  │     把 effect 放进异步队列,去重,微任务批量执行
  │     场景:组件模板渲染
  │     特点:连续改三次,只渲染最后一次
  │
  ├─ computed scheduler
  │     _dirty = true
  │     trigger(computedRef, 'value')
  │     只标记脏,不重新计算
  │     场景:computed
  │     特点:等读取 value 时才懒计算,依赖不变直接返回缓存
  │
  └─ watch scheduler
        const newVal = effect.run()  // 重新读值
        const oldVal = ...
        callback(newVal, oldVal)     // 调用户的回调
        场景:watch / watchEffect
        特点:按 flush 策略决定执行时机

4.3 为什么调度器是灵魂

没有这套调度系统:

  • 一个循环改 100 次数据,组件就渲染 100 次
  • computed 每次 get 都要重新计算,没有缓存
  • watch 的 flush: 'post' 根本没法实现(不知道什么时候组件渲染完)
  • 所有 effect 行为完全一样,无法区分使用场景

Proxy + track/trigger 是骨架,scheduler 是血肉,cleanup 是免疫系统。 三者缺一不可。


五、一张完整结构图

const targetMap = new WeakMap()  // 全局依赖容器
let activeEffect = null          // 全局上下文标记

// === 结构 ===
targetMap:
  target1 (WeakMap key) → Map {
    key1 → Set [ effectA, effectB ]     ← dep (key1.dep)
    key2 → Set [ effectB, effectC ]     ← dep (key2.dep)
  }
  target2 (WeakMap key) → Map {
    key3 → Set [ effectA, effectC ]     ← dep (key3.dep)
  }

effectB:
  fn: () => { ... }
  scheduler: () => { ... }  (可选)
  deps: [ key1.dep, key2.dep ]  ← 反向记录,指向它依赖的 dep

// === 运行时流程 ===
effectB.run()
  ├─ cleanupEffect(this)
  │    for dep of this.deps:
  │      dep.delete(this)        // 从旧 dep 中移除自己
  │    this.deps = []
  │
  ├─ activeEffect = this         // 标记
  ├─ fn()
  │    └─ 读 target1.key1 → get → track(target1, key1)
  │         ├─ dep.add(this)           // dep → effect
  │         └─ this.deps.push(dep)    // effect → dep(双向记录完成)
  │
  └─ activeEffect = null(或恢复外层)

trigger(target1, key1)
  ├─ 找到 dep = Set [ effectA, effectB ]
  └─ 遍历 dep,每个 effect:
       ├─ 有 scheduler → scheduler()
       │    ├─ 组件:queueJob(effect)
       │    ├─ computed: dirty = true
       │    └─ watch: callback(run())
       └─ 没有 scheduler → effect.run()(回到上面的 run 流程)

六、常见误解清单

1. “核心数据结构是双向链表”WeakMap<target, Map<key, Set<effect>>> 三层映射,加上 effect.deps 数组做反向记录。dep 是 Set,deps 是数组,不存在 prev/next 指针,不构成链表。

2. “Vue 3 能识别 effect 类型” 不是识别,是不同 API(effect()/computed()/watch()/组件渲染)在创建 effect 时,各自配了不同 scheduler。ReactiveEffect 类本身没有 type 字段。

3. “watch 初始化会执行 callback” 初始化执行的是 effect.run(),里面跑的是 getter(读数据建依赖),不是 callback。除非配了 immediate: true

4. “组件渲染会自动合并更新” 不是自动,是 scheduler + queueJob 去重实现的。同一个 effect 多次进队列只留一份,微任务统一执行。

5. “dep → effect 就够了,不需要 effect → deps” 没有反向记录,cleanup 就没法做——你必须遍历所有响应式对象的所有 dep 去找哪个包含当前 effect,时间复杂度爆炸。

6. “双向记录 = 双向链表” “双向记录”指 dep 持有 effect 引用、effect 持有 dep 引用,是逻辑上的互相指向。“双向链表”是特定数据结构,节点有 prev/next 指针串联。两者完全不同。


七、总结:三个设计咬合在一起

scheduler、cleanup、双向记录不是三个独立的功能,而是一套互相依赖的设计:

  • 双向记录为 cleanup 提供了“从哪个 dep 删自己”的快速索引
  • cleanup 确保每次执行后依赖都是精确的,不多不少
  • scheduler 在 cleanup 和 track 之后决定“怎么执行、什么时候执行”

合在一起,回答了一个核心问题:数据变了,怎么精确、高效地让该更新的东西更新——不多跑、不漏跑、不立刻跑。

这三块搞明白了,Vue 3 响应式系统就不再有黑盒。代码量不大,设计却极其精妙。

评论

登录后即可发表评论

暂无评论,快来发表第一条评论吧!