Skip to content

实现 effect 与依赖收集

本节对标 Vue 3 源码 @vue/reactivity 中的 effect.ts

effect 的定位

effect 是 Vue 3 响应式系统的核心驱动器。在 Vue 3 中,几乎所有的"响应式消费"都通过 effect 实现:

  • 组件渲染:每个组件的 render 函数被包裹在一个 effect 中
  • computed:内部依赖 effect 实现惰性计算
  • watch / watchEffect:本质上也是创建了 effect

effect 的职责:

  1. 执行传入的副作用函数
  2. 在执行过程中自动收集依赖(track)
  3. 当依赖变化时自动重新执行(trigger)

ReactiveEffect 类

ts
// 当前正在执行的 effect 实例,用于 track 时建立依赖关系
let activeEffect: ReactiveEffect | undefined
// effect 执行栈,用于处理嵌套 effect 场景,确保 activeEffect 指向正确的外层 effect
const effectStack: ReactiveEffect[] = []

export class ReactiveEffect<T = any> {
  active = true // 标记该 effect 是否处于激活状态,stop() 后会设为 false
  deps: Dep[] = [] // 存储该 effect 被收集到了哪些 dep 中,用于后续清理依赖

  constructor(
    public fn: () => T, // 副作用函数,即需要在依赖变化时重新执行的函数
    public scheduler?: () => void, // 可选的调度器,如果提供则 trigger 时调用 scheduler 而非直接 run
  ) {}

  run() {
    // 如果 effect 已被停止,直接执行 fn 但不进行依赖收集
    if (!this.active) {
      return this.fn()
    }

    // 防止同一个 effect 递归触发自身,避免无限循环
    if (!effectStack.includes(this)) {
      try {
        // 将当前 effect 压入栈,并设为 activeEffect,这样 fn 执行时 track 能收集到正确的 effect
        effectStack.push(this)
        activeEffect = this

        // 执行前清理旧依赖:确保每次执行都重新收集最新的依赖关系,处理分支切换场景
        cleanupEffect(this)

        // 执行副作用函数,函数内部访问响应式数据时会触发 track 收集依赖
        return this.fn()
      } finally {
        // 无论 fn 是否抛出异常,都要恢复 effectStack 和 activeEffect
        effectStack.pop() // 将当前 effect 出栈
        activeEffect = effectStack[effectStack.length - 1] // 恢复 activeEffect 为外层 effect(如果有的话)
      }
    }
  }

  // 停止该 effect 的响应式追踪
  stop() {
    if (this.active) {
      cleanupEffect(this) // 从所有 dep 中移除该 effect,断开依赖关系
      this.active = false // 标记为非激活,后续 trigger 不再执行该 effect
    }
  }
}

effectStack 的作用 —— 解决嵌套 effect

考虑以下场景:

ts
effect(() => {
  // 外层 effect
  console.log(obj.a)

  effect(() => {
    // 内层 effect
    console.log(obj.b)
  })

  // 这里访问 obj.c 时,activeEffect 应该是外层 effect
  console.log(obj.c)
})

如果只用一个 activeEffect 变量,内层 effect 执行完后 activeEffect 会丢失外层 effect 的引用。

使用栈结构:

  1. 执行外层 effect → push 外层 → activeEffect = 外层
  2. 执行内层 effect → push 内层 → activeEffect = 内层
  3. 内层执行完 → pop 内层 → activeEffect = 外层(恢复)
  4. 继续执行外层中 obj.c 的访问 → 正确收集到外层 effect

cleanupEffect —— 每次执行前清理旧依赖

ts
// 清理 effect 的所有旧依赖,使其在下次执行时能重新收集最新的依赖关系
function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  // 遍历该 effect 被收集到的所有 dep 集合,将自己从中移除
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect) // 从每个 dep(Set<ReactiveEffect>)中移除当前 effect
  }
  deps.length = 0 // 清空 deps 数组,后续 run() 中会重新收集
}

为什么要清理?考虑分支切换场景:

ts
const obj = reactive({ ok: true, text: 'hello' })

effect(() => {
  // 当 ok 为 true 时,依赖 ok 和 text
  // 当 ok 为 false 时,只依赖 ok
  console.log(obj.ok ? obj.text : 'not')
})

obj.ok = false   // 触发重新执行,此时不再访问 text
obj.text = 'hi'  // 理想情况:不应该触发 effect

如果不清理旧依赖,修改 obj.text 仍然会触发 effect(因为第一次执行时收集了 text 的依赖)。

每次执行前清理,重新收集,就能确保依赖始终是最新的。

effect 函数

ts
// effect 函数的配置项接口
export interface ReactiveEffectOptions {
  scheduler?: () => void // 自定义调度器,控制 effect 的触发方式
  lazy?: boolean // 是否延迟执行,为 true 时 effect 不会立即运行(computed 需要此特性)
}

export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
  // 创建 ReactiveEffect 实例,将副作用函数和可选的调度器传入
  const _effect = new ReactiveEffect(fn, options?.scheduler)

  // 如果没有设置 lazy,则立即执行一次 effect,完成首次依赖收集
  if (!options?.lazy) {
    _effect.run()
  }

  // 将 effect.run 方法绑定到 effect 实例上,作为 runner 返回
  // 这样外部可以通过 runner() 手动触发 effect 重新执行
  const runner = _effect.run.bind(_effect) as any
  runner.effect = _effect // 将 effect 实例挂载到 runner 上,方便外部访问(如 stop(runner) 时需要用到)
  return runner
}

lazy 选项

默认 effect 会立即执行一次(收集依赖)。设置 lazy: true 则不会立即执行,由调用方自行决定执行时机。computed 就利用了这个特性。

runner

effect 返回一个 runner 函数,调用 runner 可以手动触发 effect 重新执行。runner 上还挂载了 effect 实例引用,方便外部访问。

完整的依赖收集 - 触发更新流程

                    ┌──────────────┐
                    │   effect()   │
                    └──────┬───────┘
                           │ 执行 fn()

                    ┌──────────────┐
                    │  访问 obj.x  │
                    └──────┬───────┘
                           │ Proxy get 拦截

                    ┌──────────────┐
                    │   track()    │  收集 activeEffect 到 dep
                    └──────────────┘

          ═══════════ 数据变更 ═══════════

                    ┌──────────────┐
                    │  obj.x = 新值 │
                    └──────┬───────┘
                           │ Proxy set 拦截

                    ┌──────────────┐
                    │  trigger()   │  从 dep 中取出所有 effect 执行
                    └──────┬───────┘


                    ┌──────────────┐
                    │  effect.run()│  重新执行副作用函数
                    └──────────────┘

stop —— 停止 effect 响应

ts
// 停止一个 effect 的响应式追踪,通过 runner 函数上挂载的 effect 实例调用 stop 方法
export function stop(runner: any) {
  runner.effect.stop()
}

调用 stop 后:

  1. 从所有 dep 中移除该 effect
  2. 标记 active = false
  3. 后续数据变化不再触发该 effect

使用场景:组件卸载时需要停止组件 effect,避免内存泄漏。

测试用例

ts
describe('effect', () => {
  // 测试 effect 创建后会立即执行一次传入的副作用函数
  it('should run the passed function once', () => {
    const fn = vi.fn(() => {}) // 使用 vitest 的 mock 函数来追踪调用次数
    effect(fn)
    expect(fn).toHaveBeenCalledTimes(1) // 断言 fn 被调用了恰好 1 次
  })

  // 测试 effect 能正确追踪响应式属性的变化,并自动重新执行
  it('should observe basic properties', () => {
    const obj = reactive({ foo: 1 })
    let dummy: number
    effect(() => {
      dummy = obj.foo // effect 执行时访问 obj.foo,触发 track 收集依赖
    })
    expect(dummy!).toBe(1) // 首次执行后,dummy 应该为 1
    obj.foo = 2 // 修改 obj.foo 触发 trigger,effect 重新执行
    expect(dummy!).toBe(2) // effect 重新执行后,dummy 应该更新为 2
  })

  // 测试嵌套 effect 场景:内层 effect 执行完后,外层 effect 能正确恢复依赖收集
  it('should handle nested effects', () => {
    const obj = reactive({ a: 1, b: 2, c: 3 })
    const calls: string[] = [] // 记录 effect 的执行顺序

    effect(() => {
      calls.push('outer')
      obj.a // 外层 effect 依赖 obj.a
      effect(() => {
        calls.push('inner')
        obj.b // 内层 effect 依赖 obj.b
      })
      obj.c // 外层 effect 依赖 obj.c(在内层 effect 之后访问,验证 activeEffect 恢复是否正确)
    })

    // 首次执行:外层执行一次,内层也执行一次
    expect(calls).toEqual(['outer', 'inner'])

    obj.c = 4 // 修改 obj.c 应该触发外层 effect 重新执行,外层又会创建新的内层 effect
    expect(calls).toEqual(['outer', 'inner', 'outer', 'inner'])
  })

  // 测试依赖清理机制:当条件分支改变后,不再使用的属性变化不应触发 effect
  it('should cleanup deps on re-run', () => {
    const obj = reactive({ ok: true, text: 'hello' })
    let dummy: string
    const fn = vi.fn(() => {
      dummy = obj.ok ? obj.text : 'not' // 当 ok 为 true 时依赖 ok 和 text,为 false 时只依赖 ok
    })
    effect(fn)
    expect(dummy!).toBe('hello') // 首次执行:ok 为 true,读取 text
    expect(fn).toHaveBeenCalledTimes(1)

    obj.ok = false // 触发 effect 重新执行,此时不再访问 text
    expect(dummy!).toBe('not')
    expect(fn).toHaveBeenCalledTimes(2)

    // text 变化不应该触发 effect,因为上次执行时已清理了 text 的依赖
    obj.text = 'world'
    expect(fn).toHaveBeenCalledTimes(2) // 调用次数仍然是 2,说明 text 变化未触发 effect
  })

  // 测试 stop 功能:停止后的 effect 不再响应数据变化
  it('should support stop', () => {
    const obj = reactive({ foo: 1 })
    let dummy: number
    const runner = effect(() => {
      dummy = obj.foo
    })
    expect(dummy!).toBe(1)

    stop(runner) // 停止 effect 的响应式追踪
    obj.foo = 2 // 修改数据后不应再触发 effect
    expect(dummy!).toBe(1) // dummy 应该保持为 1,说明 effect 未被重新执行
  })
})

对比 React

维度Vue 3 effectReact useEffect
依赖收集自动(Proxy 拦截)手动(deps 数组)
执行时机同步异步(commit 后)
清理机制自动清理旧依赖return cleanup 函数
嵌套支持effectStack 支持嵌套不允许嵌套调用 hooks

本节小结

  1. ReactiveEffect 类 — 封装副作用函数,管理依赖和生命周期
  2. effectStack — 栈结构解决嵌套 effect 问题
  3. cleanupEffect — 每次执行前清理旧依赖,处理分支切换
  4. scheduler — 为 computed / watch / 组件更新提供自定义调度入口
  5. lazy / stop — 控制 effect 的执行时机和生命周期

下一节实现 refcomputed

用心学习,用代码说话 💻