先记住这个答案
effectScope(true) 创建 detached 作用域,它不会被创建时的当前父作用域收集,因此父 scope 或所属组件结束时不会自动停止它。它适合生命周期确实独立于单个消费者的共享响应式服务,但必须有明确的 owner 和 stop 时机。若只是组件内部效果,使用分离作用域会让监听在组件离开后继续存在;模块级单例在 SSR 中还可能跨请求共享状态,应按应用实例或请求隔离。
- detached 不参与父作用域级联停止
- 独立生命周期必须配套明确 owner
- SSR 中避免跨请求共享可变单例
true 参数切断父子回收关系
默认在活动 scope 内创建的子作用域会成为它的后代。传 true 后,新 scope 不登记到父级;这使它能够在局部调用者结束后继续提供共享状态,也意味着上级无法替它完成清理。
如果每次组件挂载都创建一个 detached scope,却从不保存或停止,离开页面后效果仍可能持有依赖和外部资源。重复进入页面时观察次数不断增长,是这类所有权遗漏的典型现象。
用可观察记录验证独立存活
示例在父作用域的 run 中同时创建默认子作用域和分离子作用域。停止父级后修改源,默认子作用域不再记录,分离子作用域仍然记录,直到单独调用 detached.stop。
这种行为是 API 设计目的,不能把它本身叫作框架泄漏。只有应用失去停止路径、效果寿命超出业务需求时才构成资源问题;评审重点应放在 owner 和终止事件是否具体。
import { effectScope, ref, watchEffect } from 'vue'
const source = ref(0)
const normal: number[] = []
const separate: number[] = []
const parent = effectScope()
let detached!: ReturnType<typeof effectScope>
parent.run(() => {
effectScope().run(() => watchEffect(() => normal.push(source.value)))
detached = effectScope(true)
detached.run(() => watchEffect(() => separate.push(source.value)))
})父级停止并把 source 改为一后,normal 保持初值零,separate 会出现一。单独停止 detached 再改为二,两边都不再增长,从而精确展示分离关系。
共享不等于进程全局
浏览器单页应用可以按 app 实例创建共享服务,在最后一个消费者释放或应用卸载时停止。引用计数必须处理重复释放与异常路径,避免计数降为负数或某个组件失败后永久占用。
服务端渲染中,模块顶层的可变单例可能同时服务多个请求。涉及用户数据、语言或权限时,应为每次应用创建独立容器并通过上下文传递,不能把 detached 当成跨请求缓存的默认方案。
容易答错的地方
- 以为组件卸载总会停止所有 scope
- 组件只能停止归属自身作用域树的效果。detached 明确切断这条树关系,除非业务另有停止路径,否则组件卸载不能替它决定共享服务何时结束。
- 为了避免重复初始化就永不销毁
- 共享实例可以延迟初始化并复用,但仍应定义登出、应用关闭、最后消费者离开或测试结束时的释放事件。永久存在只是一个需证明合理的生命周期决策。
面试官还会怎么问?
普通子 scope 可以手动提前 stop 吗?
可以。子级能够在父级结束前独立停止;之后父级 stop 不应让已结束效果重新运行。提前结束适合功能局部关闭,同时父级仍负责其余效果。
detached scope 适合全局鼠标位置吗?
可以作为一种实现,但要让多个消费者共享同一监听、在最后消费者离开时移除全局事件,并处理 SSR 没有 window 的环境。是否分离取决于服务寿命,而非数据看起来是否全局。
测试中为什么要主动 stop?
独立作用域可能跨用例保留观察和状态,使后续断言受前一用例影响。每个测试结束时停止并重置共享容器,能够验证真实释放路径,也保证测试隔离。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。