08|effectScope:给一组副作用同一个结束按钮
第三章:响应式系统纵览 / 前置:watchers / 下一篇:数组与集合
组合式函数可能同时创建 computed、多个 watcher、定时器和外部订阅。逐个返回 stop 函数容易漏;effectScope 捕获作用域中同步创建的响应式 effect,onScopeDispose 把非响应式资源也接到同一生命周期。
运行上下文
effectScope 是 Vue 3 高级响应式 API,可在组件外和 Node 中运行。组件的 setup() 本身已经处于一个由组件管理的 effect scope;普通组件逻辑通常依赖自动卸载即可。独立服务、可重启功能和库级组合式函数更需要显式 scope。
scope 捕获什么
import { computed, effectScope, ref, watch, watchEffect } from 'vue'
const source = ref(1)
const scope = effectScope()
scope.run(() => {
const doubled = computed(() => source.value * 2)
watch(source, () => console.log('source changed'))
watchEffect(() => console.log('derived', doubled.value))
})
scope.stop()scope.stop() 停止作用域里同步创建的watchers,并执行登记的清理。它不是把所有返回值冻结:在本书Vue 3.5基线下,外部仍持有的computed在后续读取时可以继续求值。它不会猜测普通的 setInterval、WebSocket 或事件监听器;这些资源必须显式登记清理。
停止发布与停止计算的区别
保存为 scope-boundary.mjs 在已安装Vue的项目目录运行:
import assert from 'node:assert/strict'
import { computed, effectScope, ref, watch } from 'vue'
const price = ref(10)
const scope = effectScope()
const published = []
const total = scope.run(() => {
const value = computed(() => price.value * 2)
watch(value, next => published.push(next), { immediate: true, flush: 'sync' })
return value
})
assert.equal(total.value, 20)
scope.stop()
price.value = 15
assert.deepEqual(published, [20])
assert.equal(total.value, 30)published 保持一项,证明对外发布的watcher已停止;total.value仍为30,证明纯计算的引用不是生命周期锁。若业务规定服务停止后禁止任何读取,应在服务接口检查运行状态,不能靠scope实现权限。旧教学实现常把computed建成一个可停止的ReactiveEffect;不要把该实现结构当成本书3.5的停止合同。
把外部资源接入 onScopeDispose
import { onScopeDispose, watch, type Ref } from 'vue'
// 本段用日志模拟外部连接,替换真实SDK时仍须保持close合同。
function openChannel(id: string) {
console.log('open', id)
return { close: () => console.log('close', id) }
}
function useChannel(roomId: Ref<string>) {
let connection: { close(): void } | undefined
const stopWatch = watch(roomId, (id) => {
connection?.close()
connection = openChannel(id)
}, { immediate: true })
onScopeDispose(() => {
stopWatch()
connection?.close()
})
}若 useChannel() 在组件 setup 或 scope.run() 内同步调用,清理会注册到当前 scope。若没有活动 scope,onScopeDispose 会在开发环境警告;库函数可以先用 getCurrentScope() 检查,并在合同中说明调用位置。
source → derived → effect → dispose
source roomId
↓
computed endpoint (纯派生值)
↓
watch endpoint → openChannel() (watch 由 scope 捕获)
└──────(channel 由 onScopeDispose 清理)
scope.stop()
├─ 停止 watch,消费者不再自动发布
└─ 执行已登记的 dispose callbackscope 管的是“谁一起结束”,watcher cleanup 管的是“每次重跑前如何作废上一轮”。一个频道 watcher 每次 roomId 改变时仍要关闭旧连接;整个功能停止时,scope 再关闭最后一条连接。
嵌套与 detached 作用域
默认创建的子 scope 会加入当前父 scope:父停止,子也停止。若 effectScope(true) 创建 detached scope,它不受当前父 scope 管理,创建者必须保存并调用 stop()。
import { effectScope, type EffectScope } from 'vue'
const parent = effectScope()
let child: EffectScope
let detached: EffectScope
parent.run(() => {
child = effectScope()
detached = effectScope(true)
})detached 适合明确跨组件生存的共享服务,不适合“为了不被卸载”随手开启。谁创建 detached scope,谁必须拥有它的关闭入口。
可重启服务:每次只有一个活动 scope
这里是接到已定义 roomId、useChannel 和 usePresenceHeartbeat 的服务局部片段;后者代表调用方自己的心跳函数,也必须在活动scope内登记清理。完整可运行服务在本篇答案中。
let activeScope: EffectScope | undefined
function startFeature() {
activeScope?.stop()
activeScope = effectScope()
activeScope.run(() => {
useChannel(roomId)
usePresenceHeartbeat()
})
}
function stopFeature() {
activeScope?.stop()
activeScope = undefined
}先停旧 scope 再建新 scope,能把“重复启动产生两份订阅”变成可验证的所有权规则。
常见失败
| 现象 | 根因 | 修复 |
|---|---|---|
scope.stop() 后计时器仍在跑 | timer 不是响应式 effect | 用 onScopeDispose 清除 |
| 异步回调里创建的 watcher 没被捕获 | 创建时已离开 scope.run() 同步上下文 | 同步创建 watcher,让回调内部等待数据 |
| 父 scope 停止后服务仍活着 | 使用了 detached scope | 保存明确 stop,或改回附着 scope |
| 每次启动回调数量翻倍 | 没有停止上一 scope | start 前 stop,断言只有一个活动实例 |
练习:实现可启动、可停止的价格服务
实现 createPriceService(source):
source是数字 ref,derived 为含税价。start()创建一个 scope,监听含税价并写入published数组;重复 start 前必须停止旧 scope。- 用
onScopeDispose模拟关闭一个外部连接,并记录关闭次数。 stop()后修改 source,不得再发布;重复 stop 不得重复关闭。- 用严格断言覆盖首次启动、重启、停止后修改和幂等停止。