obsidian-computer
前端识记mini-vue-reading

01-reactivity-effect与依赖收集

第 1 章 · reactivity(上):effect 与依赖收集

最后更新 2026年8月29日

第 1 章 · reactivity(上):effect 与依赖收集

本章是全书最重要的一章。 effect 是 Vue 响应式的心脏,后面的 reactive、ref、computed、组件更新、nextTick,全都是围绕它的封装。 mini-vue 的 effect.ts 最终形态约 120 行,但它是分 6 步演化出来的。我们按演化顺序重写一遍——每一步都由一个失败的测试驱动。


1.0 核心命题:响应式到底在解决什么

先把"响应式"三个字翻译成一个具体的编程问题:

let price = 10, quantity = 2
let total = price * quantity   // total === 20
price = 20                     // total 还是 20 —— JS 的赋值是一次性的

我们想要的是:total 的计算逻辑在 price 变化后自动重新执行。拆开看,需要两个能力:

  1. 写这条 total = price * quantity 时,系统得知道"这段计算读了 price 和 quantity" —— 依赖收集(track);
  2. price = 20 执行时,系统得知道"price 变了",并找出第 1 步记录的那些计算重新跑 —— 触发更新(trigger)。

能力 1 要求"读"可被拦截,能力 2 要求"写"可被拦截——这是 Proxy 的活,第 2 章讲。 本章只关心:拦截到读/写之后,用什么数据结构记录、怎么通知。 这才是响应式系统的设计核心;Proxy 只是耳目。


1.1 第一步:最裸的 effect + track + trigger

失败用例

// effect.spec.ts
it('happy path', () => {
  const user = reactive({ age: 10 })
  let nextAge
  effect(() => {
    nextAge = user.age + 1      // 读 → 应该被记录
  })
  expect(nextAge).toBe(11)      // effect 首次立即执行
  user.age++                    // 写 → 应该触发上面的函数重跑
  expect(nextAge).toBe(12)
})

注意测试里已经写死了两个 API 约定:effect 接收一个函数并立即执行一次(否则没有"读"的过程,无从收集依赖);写之后函数自动重跑。

实现

// effect.ts —— 版本 1
let activeEffect            // ★ 当前正在执行的 effect(全局唯一)

class ReactiveEffect {
  constructor(public fn) {}
  run() {
    activeEffect = this     // 执行前:把自己挂到全局
    this.fn()               // 执行用户函数 → 触发 get → track 会读到 activeEffect
  }
}

export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()             // 立即执行一次
}

// ---------- 依赖存储 ----------
const targetMap = new WeakMap()   // ★ 全局仓库

export function track(target, key) {
  // 三层查找,没有就建:target → key → Set<effect>
  let depsMap = targetMap.get(target)
  if (!depsMap) targetMap.set(target, (depsMap = new Map()))
  let dep = depsMap.get(key)
  if (!dep) depsMap.set(key, (dep = new Set()))
  dep.add(activeEffect)
}

export function trigger(target, key) {
  const depsMap = targetMap.get(target)
  const dep = depsMap.get(key)
  for (const effect of dep) {
    effect.run()
  }
}

逐段解释

为什么需要 activeEffect 全局变量? 这是初读者最大的困惑点,值得掰开:

track 在 Proxy 的 get 拦截器里被调用,此刻它知道 target 和 key("谁的哪个属性被读了"),但它不知道是谁在读——调用栈上并没有把 effect 传进来,用户代码 user.age 也不可能带上这个信息。而"是谁在读"恰恰是依赖收集要记的东西。

解法:effect 在执行 fn() 之前把自己写到全局变量上,fn() 内部的任何一次属性读取,其 get → track 里读到的 activeEffect 就一定是它。用"执行的时间窗口"代替"参数传递"来传递信息——这是响应式框架(Vue/MobX/Solid/React hooks 的 currentlyRenderingFiber)共同的招数。第一次见觉得 hack,见多了会认出这是一个模式:隐式调用上下文。

为什么 targetMap 是三层结构? 一层不行吗?把结构画出来:

targetMap (WeakMap)
 └─ user 对象 → depsMap (Map)
                 ├─ "age"  → dep (Set) { effect1, effect2 }
                 └─ "name" → dep (Set) { effect3 }
  • 最外层按对象分:trigger 时拿着 target 直接定位,不用全局扫描;
  • 第二层按 key 分:改 user.age 不应该触发只读了 user.name 的 effect3——粒度必须到属性级,否则就退化成"任何修改全量重跑";
  • 最内层用 Set:同一个 effect 里读同一个属性两次(user.age + user.age),track 会跑两次,Set 天然去重,否则 trigger 时重复执行。

为什么最外层是 WeakMap 而不是 Map? 键是对象且是弱引用:当 user 对象本身没人引用了,它在 targetMap 里的整条记录可以被 GC 回收。用 Map 的话 targetMap 会永远攥着所有响应式对象不放——内存泄漏。看到源码里 WeakMap,条件反射应该是"作者在管理生命周期"。


1.2 第二步:runner —— effect 返回一个可手动调用的函数

失败用例

it('should return runner', () => {
  let foo = 10
  const runner = effect(() => {
    foo++
    return 'foo'
  })
  expect(foo).toBe(11)
  const r = runner()            // 手动再执行一次
  expect(foo).toBe(12)
  expect(r).toBe('foo')         // 还能拿到 fn 的返回值
})

为什么需要这个能力?往后看:组件的"强制更新"、watchEffect 返回的 stop 句柄、以及 scheduler 场景里"我自己决定什么时候跑",都需要一个指向这次 effect 的引用。

改动

class ReactiveEffect {
  run() {
    activeEffect = this
    return this.fn()          // ① 透传返回值
  }
}

export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
  const runner = _effect.run.bind(_effect)   // ② 绑定 this
  runner.effect = _effect                    // ③ 反向挂引用(stop 要用)
  return runner
}

②的 bind 为什么必须有:runner() 是裸调用,this 会丢(严格模式下是 undefined),this.fn 直接爆炸。这是 JS 的 this 规则在源码里最常见的出没点。 ③是个伏笔:把 effect 实例挂在 runner 函数上,后面 stop(runner) 才能从 runner 找回实例。函数是对象、可以挂属性——源码里常用这招做"公开一个函数、私下携带元数据"。


1.3 第三步:scheduler —— 整个响应式系统最重要的一个参数

失败用例

it('scheduler', () => {
  let dummy
  let run: any
  const scheduler = vi.fn(() => { run = runner })
  const obj = reactive({ foo: 1 })
  const runner = effect(() => { dummy = obj.foo }, { scheduler })

  expect(scheduler).not.toHaveBeenCalled()
  expect(dummy).toBe(1)          // 首次执行:走的还是 fn 本身
  obj.foo++
  expect(scheduler).toHaveBeenCalledTimes(1)   // ★ 触发更新时:不跑 fn,跑 scheduler
  expect(dummy).toBe(1)          // fn 没跑,dummy 没变
  run()                          // 我自己决定什么时候跑
  expect(dummy).toBe(2)
})

读这个测试要慢下来,它定义了一个不太直觉的约定:

传了 scheduler 之后,"依赖变化"不再等于"重新执行"。变化发生时系统只调用 scheduler 通知你,执行权完全交给你。

改动(小得不成比例)

class ReactiveEffect {
  constructor(public fn, public scheduler?) {}   // 多存一个
}

export function trigger(target, key) {
  const dep = targetMap.get(target)?.get(key)
  for (const effect of dep) {
    if (effect.scheduler) {
      effect.scheduler()        // ★ 有 scheduler:只通知,不执行
    } else {
      effect.run()
    }
  }
}

为什么说它最重要

代码 4 行,但它把响应式系统从"变化 → 立刻同步重跑"解耦成"变化时刻"和"执行时刻"两个独立的点。数一数它的下游客户:

客户scheduler 里做什么达成的效果
computed(第 2 章)只把 _dirty 置 true懒计算 + 缓存:依赖变了不重算,下次读才算
组件更新(第 3 章)把更新函数 push 进队列同步改 100 次数据只 render 一次
nextTick(第 5 章)队列在微任务里刷新"更新后拿最新 DOM"有了挂载点
watch把回调排进不同 flush 时机pre/post/sync 三种时机

读源码的一个通用嗅觉:当你看到一个 API 接受"回调的回调"(不直接执行你,而是问你想怎么执行),作者在做控制反转,后面必有多个行为迥异的消费者。 顺着"谁传了 scheduler"搜索,能把半个框架串起来。


1.4 第四步:stop 与双向记录 —— 为了删除,必须记住自己被谁记住

失败用例

it('stop', () => {
  let dummy
  const obj = reactive({ prop: 1 })
  const runner = effect(() => { dummy = obj.prop })
  obj.prop = 2
  expect(dummy).toBe(2)
  stop(runner)                  // ★ 停止响应
  obj.prop = 3
  expect(dummy).toBe(2)         // 不再自动更新
  runner()                      // 但手动调用仍然有效
  expect(dummy).toBe(3)
})

现实需求:组件卸载时,它的渲染 effect 必须停止响应,否则卸载后的组件还在偷偷重渲染——内存泄漏加幽灵更新。

问题在哪

stop 要做的事是"把这个 effect 从所有依赖它的 dep Set 里删掉"。但现在的数据结构是单向的:targetMap 能从 target.key 查到 effects,没法从 effect 反查它进过哪些 Set。全局遍历 targetMap?可以,但每次 stop 都全表扫描,不可接受。

解法:收集时双向记录

class ReactiveEffect {
  deps = []            // ★ 反向表:我被哪些 dep Set 收集了
  active = true        // 防止重复 stop
  onStop?: () => void

  stop() {
    if (this.active) {
      cleanupEffect(this)
      this.onStop?.()          // 停止时的钩子(清理副作用用)
      this.active = false
    }
  }
}

function cleanupEffect(effect) {
  effect.deps.forEach(dep => dep.delete(effect))  // 从每个 Set 里删掉自己
  effect.deps.length = 0
}

export function track(target, key) {
  // ...三层查找同前...
  dep.add(activeEffect)
  activeEffect.deps.push(dep)   // ★ 收集的同时,把 dep 也记到 effect 身上
}

export function stop(runner) {
  runner.effect.stop()          // 1.2 步埋的伏笔在这里兑现
}

这是一个值得抽象记住的模式:凡是"注册"类的数据结构,如果将来需要"注销",注册那一刻就要双向记录。 DOM 的 addEventListener/removeEventListener、订阅发布的 unsubscribe、React 的 useEffect cleanup,全是同一个问题。mini-vue 在这里用 effect.deps 数组存的是对 Set 的引用(不是拷贝),所以 dep.delete(effect) 删的就是 targetMap 里的那个 Set——引用共享让双向表零成本同步。

这一步引出的新 bug:stop 之后又被收集回来了

上面的测试有个阴险的变体(真实 mini-vue 测试里有):

stop(runner)
obj.prop++          // 注意:++ 是 先读 再写!
expect(dummy).toBe(2)   // ❌ 挂了

obj.prop++ 等价于 obj.prop = obj.prop + 1。右边的读触发 get → track → 已经被清空依赖的 effect 又被收集回去了;紧接着的写就 trigger 它。stop 白做了。

修复:加一个全局开关 shouldTrack,只有"通过 run() 正常执行"期间才允许收集:

let shouldTrack = false

class ReactiveEffect {
  run() {
    if (!this.active) {
      return this.fn()          // stop 过的:直接跑 fn,不开收集
    }
    shouldTrack = true
    activeEffect = this
    const result = this.fn()
    shouldTrack = false         // 跑完立刻关窗
    return result
  }
}

export function track(target, key) {
  if (!isTracking()) return     // ★ 不在收集窗口内,直接跳过
  // ...
}

export function isTracking() {
  return shouldTrack && activeEffect !== undefined
}

想清楚这个修复的含义:依赖收集只应该发生在"effect 正在通过正规渠道执行"的时间窗口内。窗口外的任何读(stop 后的读、effect 之外的普通业务代码的读)都不该留下记录。isTracking() 里 activeEffect !== undefined 那半个条件,挡的就是"根本没有 effect 在跑、纯业务代码读了响应式对象"的场景——没有它,track 里 activeEffect.deps.push 直接空指针。

读源码时看到成对出现的全局布尔开关(shouldTrack/pauseTracking/resetTracking),基本都是在圈定某种"合法时间窗口"。 Vue 3 真源码里它甚至是个栈(trackStack),因为窗口会嵌套。


1.5 遗留难题:嵌套 effect(mini-vue 没做,但你必须知道)

组件是会嵌套的,而每个组件是一个 effect,所以 effect 必然嵌套:

effect(() => {          // effect1(父组件)
  effect(() => {        // effect2(子组件)
    dummy2 = obj.bar
  })
  dummy1 = obj.foo      // ★ 此刻 activeEffect 还是 effect2!
})                       //    obj.foo 被错记到 effect2 头上

单变量 activeEffect 在内层 effect 跑完后没有恢复成外层。修复思路两种:

  • 栈(Vue 3 早期):run 前 push,run 后 pop,activeEffect = 栈顶;
  • 保存现场(Vue 3 现行):run 里先 const last = activeEffect,finally 里 activeEffect = last——本质还是栈,只是栈存在了调用栈里。

mini-vue 为了教学简洁没处理。这正是"mini 实现 → 真源码"的 diff 阅读法的绝佳素材:拿这个问题去读 vuejs/core 的 effect.ts,你会看到 try { ... } finally { activeEffect = this.parent } 结构,一眼认出它在干嘛。


1.6 本章代码全景(最终形态,约 90 行)

把 6 步演化拼起来,这是你应该能默写的版本:

let activeEffect
let shouldTrack = false
const targetMap = new WeakMap()

export class ReactiveEffect {
  deps = []
  active = true
  onStop?: () => void
  constructor(public fn, public scheduler?) {}

  run() {
    if (!this.active) return this.fn()
    shouldTrack = true
    activeEffect = this
    const result = this.fn()
    shouldTrack = false
    return result
  }

  stop() {
    if (this.active) {
      cleanupEffect(this)
      this.onStop?.()
      this.active = false
    }
  }
}

function cleanupEffect(effect) {
  effect.deps.forEach(dep => dep.delete(effect))
  effect.deps.length = 0
}

export function isTracking() {
  return shouldTrack && activeEffect !== undefined
}

export function track(target, key) {
  if (!isTracking()) return
  let depsMap = targetMap.get(target)
  if (!depsMap) targetMap.set(target, (depsMap = new Map()))
  let dep = depsMap.get(key)
  if (!dep) depsMap.set(key, (dep = new Set()))
  trackEffects(dep)
}

export function trackEffects(dep) {         // 拆出来是因为 ref 也要用(第 2 章)
  if (dep.has(activeEffect)) return
  dep.add(activeEffect)
  activeEffect.deps.push(dep)
}

export function trigger(target, key) {
  const dep = targetMap.get(target)?.get(key)
  if (dep) triggerEffects(dep)
}

export function triggerEffects(dep) {       // 同上,ref 复用
  for (const effect of dep) {
    if (effect.scheduler) effect.scheduler()
    else effect.run()
  }
}

export function effect(fn, options: any = {}) {
  const _effect = new ReactiveEffect(fn, options.scheduler)
  Object.assign(_effect, options)           // onStop 等选项直接拍上去
  _effect.run()
  const runner: any = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}

export function stop(runner) {
  runner.effect.stop()
}

注意 trackEffects / triggerEffects 被从 track/trigger 里拆出来了——这是为第 2 章的 ref 准备的:ref 只有一个 value,不需要 targetMap 三层查找,直接自带一个 dep Set,复用后半段逻辑即可。源码里"一个函数被拦腰拆成两半导出",几乎总意味着有另一个调用方只需要后半段。


自测

  1. activeEffect 传递的是什么信息?为什么不能用参数传?
  2. targetMap 三层各解决什么问题?为什么最外层必须 WeakMap?
  3. 传入 scheduler 后 trigger 的行为怎么变?列出至少三个 scheduler 的下游用户。
  4. obj.prop++ 为什么会破坏 stop?shouldTrack 圈定的"合法窗口"是什么?
  5. (手撕)合上书,从空文件写出 effect + track + trigger + scheduler + stop,跑通本章 4 个测试。

反向链接(1)

评论

关系图谱

00-总览与阅读路线01-reactivity-effect与依赖收集02-reactivity-reactive-ref-computed03-runtime-挂载流程04-runtime-组件系统05-runtime-更新与diff06-compiler-三段式07-方法论-如何啃大型开源项目README

On this page