Skip to content

08|effectScope:给一组副作用同一个结束按钮 ​

第三章:响应式系统纵览 / 前置:watchers / 下一篇:数组与集合

组合式函数可能同时创建 computed、多个 watcher、定时器和外部订阅。逐个返回 stop 函数容易漏;effectScope 捕获作用域中同步创建的响应式 effect,onScopeDispose 把非响应式资源也接到同一生命周期。

运行上下文 ​

effectScope 是 Vue 3 高级响应式 API,可在组件外和 Node 中运行。组件的 setup() 本身已经处于一个由组件管理的 effect scope;普通组件逻辑通常依赖自动卸载即可。独立服务、可重启功能和库级组合式函数更需要显式 scope。

scope 捕获什么 ​

ts
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的项目目录运行:

js
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 ​

ts
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 ​

text
source roomId
   ↓
computed endpoint               (纯派生值)
   ↓
watch endpoint → openChannel()  (watch 由 scope 捕获)
                         └──────(channel 由 onScopeDispose 清理)

scope.stop()
   ├─ 停止 watch,消费者不再自动发布
   └─ 执行已登记的 dispose callback

scope 管的是“谁一起结束”,watcher cleanup 管的是“每次重跑前如何作废上一轮”。一个频道 watcher 每次 roomId 改变时仍要关闭旧连接;整个功能停止时,scope 再关闭最后一条连接。

嵌套与 detached 作用域 ​

默认创建的子 scope 会加入当前父 scope:父停止,子也停止。若 effectScope(true) 创建 detached scope,它不受当前父 scope 管理,创建者必须保存并调用 stop()。

ts
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内登记清理。完整可运行服务在本篇答案中。

ts
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
每次启动回调数量翻倍没有停止上一 scopestart 前 stop,断言只有一个活动实例

练习:实现可启动、可停止的价格服务 ​

实现 createPriceService(source):

  • source 是数字 ref,derived 为含税价。
  • start() 创建一个 scope,监听含税价并写入 published 数组;重复 start 前必须停止旧 scope。
  • 用 onScopeDispose 模拟关闭一个外部连接,并记录关闭次数。
  • stop() 后修改 source,不得再发布;重复 stop 不得重复关闭。
  • 用严格断言覆盖首次启动、重启、停止后修改和幂等停止。

查看第 08 题参考答案。

用心记录每一步成长