主题
实现 effect 与依赖收集
本节对标 Vue 3 源码
@vue/reactivity中的effect.ts
effect 的定位
effect 是 Vue 3 响应式系统的核心驱动器。在 Vue 3 中,几乎所有的"响应式消费"都通过 effect 实现:
- 组件渲染:每个组件的 render 函数被包裹在一个 effect 中
- computed:内部依赖 effect 实现惰性计算
- watch / watchEffect:本质上也是创建了 effect
effect 的职责:
- 执行传入的副作用函数
- 在执行过程中自动收集依赖(track)
- 当依赖变化时自动重新执行(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 的引用。
使用栈结构:
- 执行外层 effect → push 外层 →
activeEffect = 外层 - 执行内层 effect → push 内层 →
activeEffect = 内层 - 内层执行完 → pop 内层 →
activeEffect = 外层(恢复) - 继续执行外层中
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 后:
- 从所有 dep 中移除该 effect
- 标记
active = false - 后续数据变化不再触发该 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 effect | React useEffect |
|---|---|---|
| 依赖收集 | 自动(Proxy 拦截) | 手动(deps 数组) |
| 执行时机 | 同步 | 异步(commit 后) |
| 清理机制 | 自动清理旧依赖 | return cleanup 函数 |
| 嵌套支持 | effectStack 支持嵌套 | 不允许嵌套调用 hooks |
本节小结
- ReactiveEffect 类 — 封装副作用函数,管理依赖和生命周期
- effectStack — 栈结构解决嵌套 effect 问题
- cleanupEffect — 每次执行前清理旧依赖,处理分支切换
- scheduler — 为 computed / watch / 组件更新提供自定义调度入口
- lazy / stop — 控制 effect 的执行时机和生命周期
下一节实现 ref 和 computed。