08-effectScope批量管理
本章目标:深入理解如何优雅地管理多个 effect 的生命周期
从一个真实场景说起
假设你正在开发一个数据大屏组件,需要同时监听多个数据源并实时更新图表:
import { ref, watch, watchEffect, computed, onUnmounted } from 'vue'
export function useDashboard() {
const salesData = ref([])
const userStats = ref({})
const systemMetrics = ref({})
// 监听销售数据变化,更新图表
const stopSalesWatch = watch(salesData, (data) => {
updateSalesChart(data)
})
// 监听用户统计,计算转化率
const conversionRate = computed(() => {
return userStats.value.conversions / userStats.value.visits
})
// 监听系统指标,超阈值报警
const stopMetricsWatch = watchEffect(() => {
if (systemMetrics.value.cpu > 90) {
sendAlert('CPU 使用率过高')
}
})
// WebSocket 连接
const ws = new WebSocket('wss://api.example.com/realtime')
ws.onmessage = (e) => {
const { type, data } = JSON.parse(e.data)
if (type === 'sales') salesData.value = data
if (type === 'users') userStats.value = data
if (type === 'metrics') systemMetrics.value = data
}
// 组件卸载时清理
onUnmounted(() => {
stopSalesWatch()
stopMetricsWatch()
ws.close()
// 如果忘记某个,就会造成内存泄漏...
})
return { salesData, userStats, conversionRate }
}
这段代码有一个明显的问题:手动管理多个副作用繁琐且容易遗漏。随着功能增加,需要清理的东西越来越多,漏掉任何一个都可能导致内存泄漏。
这正是 effectScope 要解决的问题。
effectScope 在响应式系统中的位置
flowchart TB
subgraph 响应式数据层
A["reactive/ref"]
end
subgraph 副作用层
B["ReactiveEffect"]
end
subgraph 管理层
C["EffectScope"]
end
subgraph 应用层
D["组件实例"]
E["Pinia Store"]
F["VueUse Composables"]
end
A -->|"track/trigger"| B
B -->|"注册到"| C
C -->|"管理"| D
C -->|"管理"| E
C -->|"管理"| F
C -->|"stop()"| G["批量清理所有副作用"]
核心定位:effectScope 是副作用的"容器",它收集在其作用域内创建的所有 ReactiveEffect,提供批量管理能力。调用 scope.stop() 即可一次性清理所有副作用。
用 effectScope 重构
让我们用 effectScope 重构开头的例子:
import { ref, watch, watchEffect, computed, effectScope, onScopeDispose } from 'vue'
export function useDashboard() {
const scope = effectScope()
return scope.run(() => {
const salesData = ref([])
const userStats = ref({})
const systemMetrics = ref({})
// 所有副作用自动注册到 scope
watch(salesData, (data) => {
updateSalesChart(data)
})
const conversionRate = computed(() => {
return userStats.value.conversions / userStats.value.visits
})
watchEffect(() => {
if (systemMetrics.value.cpu > 90) {
sendAlert('CPU 使用率过高')
}
})
// WebSocket 也可以通过 onScopeDispose 管理
const ws = new WebSocket('wss://api.example.com/realtime')
ws.onmessage = (e) => { /* ... */ }
onScopeDispose(() => {
ws.close() // scope.stop() 时自动执行
})
return { salesData, userStats, conversionRate, stop: () => scope.stop() }
})
}
// 使用时
const dashboard = useDashboard()
// ... 使用 dashboard
dashboard.stop() // 一次性清理所有副作用
核心优势:
- 不需要手动保存每个 stop 函数
- 不会遗漏任何副作用
- 代码更简洁,意图更清晰
EffectScope 的核心实现
EffectScope 的核心是维护一个 effects 数组,收集在其作用域内创建的所有 ReactiveEffect:
classDiagram
class EffectScope {
-_active: boolean
-_isPaused: boolean
+effects: ReactiveEffect[]
+cleanups: Function[]
+scopes: EffectScope[]
+parent: EffectScope
+run(fn): T
+stop(): void
+pause(): void
+resume(): void
}
class ReactiveEffect {
+deps: Link
+flags: EffectFlags
+run(): T
+stop(): void
}
EffectScope "1" --> "*" ReactiveEffect : 收集 effects
EffectScope "1" --> "*" EffectScope : 收集子 scopes
来看源码中的实现:
// 文件: packages/reactivity/src/effectScope.ts
export let activeEffectScope: EffectScope | undefined
export class EffectScope {
// 当前作用域是否仍然有效
private _active = true
// 记录 on() 被调用的次数,允许多次嵌套调用
private _on = 0
// 本作用域内创建的所有 ReactiveEffect
effects: ReactiveEffect[] = []
// 本作用域注册的清理函数(在 stop 时调用)
cleanups: (() => void)[] = []
private _isPaused = false
// 指向父作用域(仅在非 detached 模式下)
parent: EffectScope | undefined
// 记录当前作用域下的子作用域
scopes: EffectScope[] | undefined
// 记录在父作用域 scopes 数组中的下标,用于 O(1) 移除
private index: number | undefined
constructor(public detached = false) {
this.parent = activeEffectScope
// 非 detached 模式下,自动注册到父 scope
if (!detached && activeEffectScope) {
this.index =
(activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(
this,
) - 1
}
}
get active(): boolean {
return this._active
}
run<T>(fn: () => T): T | undefined {
if (this._active) {
const currentEffectScope = activeEffectScope
try {
activeEffectScope = this // 设置当前活跃的 scope
return fn() // 在此期间创建的 effect 会自动注册到 this.effects
} finally {
activeEffectScope = currentEffectScope // 恢复之前的 scope
}
}
}
}
关键设计:当调用 scope.run(fn) 时,activeEffectScope 被设置为当前 scope。在 fn 执行期间创建的任何 ReactiveEffect 都会检查 activeEffectScope,并将自己注册进去。
stop:级联清理的实现
stop 方法负责清理 scope 内的所有资源,包括 effects、cleanups 和子 scopes:
flowchart TB
A["scope.stop()"] --> B["停止所有 effects"]
B --> C["执行所有 cleanups"]
C --> D["递归停止子 scopes"]
D --> E["从父 scope 移除自己"]
subgraph 级联清理示例
F["parentScope"]
G["childScope1"]
H["childScope2"]
I["effect1"]
J["effect2"]
K["cleanup1"]
F --> G
F --> H
G --> I
G --> K
H --> J
end
// 文件: packages/reactivity/src/effectScope.ts
stop(fromParent?: boolean): void {
if (this._active) {
this._active = false
let i, l
// 1. 停止所有 effect
for (i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].stop()
}
this.effects.length = 0
// 2. 执行清理回调
for (i = 0, l = this.cleanups.length; i < l; i++) {
this.cleanups[i]()
}
this.cleanups.length = 0
// 3. 递归停止子 scope
if (this.scopes) {
for (i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].stop(true) // 传入 true 表示由父 scope 触发
}
this.scopes.length = 0
}
// 4. 从父 scope 中移除自己(O(1) 优化)
if (!this.detached && this.parent && !fromParent) {
const last = this.parent.scopes!.pop()
if (last && last !== this) {
this.parent.scopes![this.index!] = last
last.index = this.index!
}
}
this.parent = undefined
}
}
优化细节:从父 scope 的 scopes 数组中移除自己时,不是用 splice(O(n)),而是将数组最后一个元素移到当前位置,然后 pop(O(1))。这在有大量子 scope 时性能差异显著。
detached 选项:独立作用域
默认情况下,scope 会自动注册到父 scope,形成树形结构。但有时我们需要创建独立的 scope,不受父 scope 影响:
flowchart TB
subgraph 默认行为
A["parentScope"] --> B["childScope"]
A --> C["childScope2"]
A -.->|"stop() 级联"| B
A -.->|"stop() 级联"| C
end
subgraph detached模式
D["parentScope"] --> E["normalChild"]
F["detachedScope(独立)"]
D -.->|"stop() 级联"| E
D -.->|"不影响"| F
end
// 默认:子 scope 随父 scope 一起停止
const parentScope = effectScope()
parentScope.run(() => {
const childScope = effectScope() // 自动注册到 parentScope
childScope.run(() => {
watchEffect(() => console.log('child effect'))
})
})
parentScope.stop() // childScope 也会被停止
// detached:独立生命周期
const parentScope = effectScope()
parentScope.run(() => {
const detachedScope = effectScope(true) // 不注册到 parentScope
detachedScope.run(() => {
watchEffect(() => console.log('detached effect'))
})
})
parentScope.stop() // detachedScope 不受影响,需要手动停止
典型使用场景:跨组件共享的状态管理。比如 Pinia store 的 scope 就是 detached 的,因为 store 的生命周期独立于任何组件。
pause/resume:暂停与恢复
EffectScope 支持暂停和恢复,这在 KeepAlive 等场景非常有用——组件被缓存时暂停副作用,重新激活时恢复:
stateDiagram-v2
[*] --> Active: effectScope() 创建
Active --> Paused: pause()
Paused --> Active: resume()
Active --> Stopped: stop()
Paused --> Stopped: stop()
Stopped --> [*]
note right of Paused: 暂停但保留状态<br/>可随时恢复
note right of Stopped: 永久停止<br/>无法恢复
// 文件: packages/reactivity/src/effectScope.ts
pause(): void {
if (this._active) {
this._isPaused = true
let i, l
// 暂停所有子 scope
if (this.scopes) {
for (i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].pause()
}
}
// 暂停所有 effect
for (i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].pause()
}
}
}
resume(): void {
if (this._active) {
if (this._isPaused) {
this._isPaused = false
let i, l
// 恢复所有子 scope
if (this.scopes) {
for (i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].resume()
}
}
// 恢复所有 effect
for (i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].resume()
}
}
}
}
pause vs stop 的区别:
- pause:暂停响应,scope 仍然是 active 的,可以随时 resume 恢复
- stop:永久停止,清理所有资源,无法恢复
这个区别在 KeepAlive 场景中至关重要:组件被缓存时应该 pause(保留状态),而不是 stop(销毁状态)。
on/off:手动控制作用域
除了 run 方法,EffectScope 还提供了 on/off 方法来手动控制作用域的激活状态:
// 文件: packages/reactivity/src/effectScope.ts
prevScope: EffectScope | undefined
on(): void {
if (++this._on === 1) {
this.prevScope = activeEffectScope
activeEffectScope = this
}
}
off(): void {
if (this._on > 0 && --this._on === 0) {
activeEffectScope = this.prevScope
this.prevScope = undefined
}
}
关键设计:on/off 使用引用计数(_on),支持嵌套调用。这在某些高级场景下很有用:
const scope = effectScope()
// 场景:在多个不连续的代码块中向同一个 scope 添加 effect
scope.on()
watchEffect(() => console.log('effect 1'))
scope.off()
// ... 其他代码 ...
scope.on()
watchEffect(() => console.log('effect 2'))
scope.off()
// 两个 effect 都注册到了同一个 scope
scope.stop() // 一次性清理
on/off vs run 的区别:
- run:执行一个函数,函数执行期间 scope 激活
- on/off:手动控制 scope 的激活状态,可以跨越多个代码块
辅助函数
Vue 提供了几个辅助函数来配合 EffectScope 使用:
// 文件: packages/reactivity/src/effectScope.ts
// 创建 scope 的工厂函数
export function effectScope(detached?: boolean): EffectScope {
return new EffectScope(detached)
}
// 获取当前活跃的 scope
export function getCurrentScope(): EffectScope | undefined {
return activeEffectScope
}
// 注册清理回调(类似组件的 onUnmounted)
export function onScopeDispose(fn: () => void, failSilently = false): void {
if (activeEffectScope) {
activeEffectScope.cleanups.push(fn)
}
}
onScopeDispose 特别有用,它让你可以在 scope 停止时执行任意清理逻辑:
function useEventListener(target, event, handler) {
target.addEventListener(event, handler)
// scope.stop() 时自动移除监听器
onScopeDispose(() => {
target.removeEventListener(event, handler)
})
}
谁在使用 effectScope?
effectScope 在 Vue 内部和生态中广泛使用:
flowchart TB
subgraph Vue内部
A["组件实例<br/>每个组件都有自己的 scope"]
B["KeepAlive<br/>使用 pause/resume"]
end
subgraph 状态管理
C["Pinia<br/>每个 store 一个 scope"]
end
subgraph 工具库
D["VueUse<br/>composables 内部管理"]
end
subgraph 底层
E["EffectScope"]
end
A --> E
B --> E
C --> E
D --> E
- 组件实例:每个组件都有自己的 effectScope,组件卸载时自动清理所有副作用
- KeepAlive:使用 pause/resume 管理缓存组件的副作用状态
- Pinia:每个 store 都有自己的 detached scope,生命周期独立于组件
- VueUse:很多 composable 内部使用 effectScope 管理副作用
总结
effectScope 的核心设计可以概括为:
flowchart TB
subgraph 创建阶段
A["effectScope()"] --> B["设置 parent"]
B --> C["注册到父 scope"]
end
subgraph 运行阶段
D["scope.run(fn)"] --> E["设置 activeEffectScope"]
E --> F["执行 fn"]
F --> G["effect 自动注册"]
end
subgraph 清理阶段
H["scope.stop()"] --> I["停止所有 effects"]
I --> J["执行 cleanups"]
J --> K["递归停止子 scopes"]
K --> L["从父 scope 移除"]
end
核心机制回顾:
- activeEffectScope:全局变量,追踪当前活跃的 scope
- effects 数组:收集 scope 内创建的所有 effect
- scopes 数组:收集子 scope,实现级联管理
- cleanups 数组:收集清理回调,stop 时执行
- detached 选项:创建独立于父 scope 的作用域
- pause/resume:支持暂停和恢复,适用于 KeepAlive 场景
effectScope 让副作用的生命周期管理变得简单可控,是 Vue 响应式系统中不可或缺的一部分。
🤔 面试常问 & 易错点
Q1: detached scope 的典型应用场景是什么?
答:detached scope 用于需要独立于组件生命周期的副作用管理。典型场景:
- Pinia store:store 的生命周期独立于任何组件,不应该随组件卸载而销毁
- 全局事件监听:某些全局事件需要在整个应用生命周期内存在
- 跨组件共享的 composable:当多个组件共享同一个 composable 实例时
// Pinia 内部实现(简化)
function createPinia() {
const scope = effectScope(true) // detached!
// store 的副作用不会随任何组件卸载而停止
}
Q2: pause/resume 和 stop/重新创建有什么区别?
答:
| 特性 | pause/resume | stop/重新创建 |
|---|---|---|
| 状态保留 | 保留所有状态 | 状态丢失 |
| 性能开销 | 低(只是标记) | 高(重新创建 effect) |
| 依赖关系 | 保留 | 需要重新收集 |
| 适用场景 | KeepAlive 缓存 | 真正需要销毁重建 |
在 KeepAlive 场景中,组件被缓存时应该 pause(保留状态和依赖),而不是 stop(销毁一切)。
Q3: O(1) 移除优化在什么情况下特别重要?
答:当有大量子 scope 时,这个优化非常重要。
// 假设有 1000 个子 scope
const parent = effectScope()
parent.run(() => {
for (let i = 0; i < 1000; i++) {
effectScope() // 创建子 scope
}
})
// 如果用 splice 移除:O(n) × n = O(n²)
// 用"交换后 pop":O(1) × n = O(n)
副作用:这种优化会改变数组中元素的顺序,但对于 scope 管理来说,顺序并不重要。
Q4: 为什么 on/off 使用引用计数?
答:引用计数支持嵌套调用,避免内层 off 意外关闭外层的 scope:
scope.on() // _on = 1
scope.on() // _on = 2
scope.off() // _on = 1,scope 仍然激活
scope.off() // _on = 0,scope 关闭
如果不用引用计数,内层的 off 就会直接关闭 scope,导致外层代码出问题。
本章涉及源码:
packages/reactivity/src/effectScope.ts- EffectScope、effectScope()、getCurrentScope()、onScopeDispose()
effectScope 让我们能够优雅地管理副作用的生命周期。下一章,我们将从实战角度出发,探讨如何调试响应式问题、如何避免性能陷阱,并通过手写一个迷你版响应式系统来巩固理解。