先记住这个答案
在活动父 scope 的 run 中创建普通 effectScope,子 scope 会被父级收集。调用父级 stop 时,父级效果、默认子 scope 及更深后代会一起停止。子 scope 可以先独立 stop,而不影响父级其他效果;反向则会级联。effectScope(true) 是明确的例外,它不加入父树,需要自行停止。嵌套只管理创建时的作用域关系,不会自动捕获异步回调稍后创建的效果。
- 默认子作用域随父级级联停止
- 子级可提前结束而不停止父级
- detached 子级不参与默认级联
嵌套关系在创建时建立
只有创建子 scope 时父 scope 正处于 run 的活动执行中,默认子级才会登记到这个父级。把 scope 变量放在嵌套函数里,或在文件层级上看起来属于某模块,都不是运行时归属依据。
组合式函数内部创建 scope 时,调用该函数的当前活动作用域会影响所有权。可复用库应在文档中说明它依赖当前 scope,或由调用方显式传入 owner,避免同一函数在不同调用位置呈现意外寿命。
分别观察子级提前停止和父级停止
下例父级与子级各建立一个 watchEffect。先停止子级并改变 source,只增加父级记录;随后停止父级再改变,两份记录都保持不变,说明停止方向由所有权树决定。
这个实验使用默认子 scope。若将 child 改为 effectScope(true),父级停止后它仍会响应,必须额外 child.stop;因此评审代码时要注意那个看似很小的 true 参数。
import { effectScope, ref, watchEffect } from 'vue'
const source = ref(0)
const parentSeen: number[] = []
const childSeen: number[] = []
const parent = effectScope()
let child!: ReturnType<typeof effectScope>
parent.run(() => {
watchEffect(() => parentSeen.push(source.value))
child = effectScope()
child.run(() => watchEffect(() => childSeen.push(source.value)))
})初始两边都记录零。child.stop 后改为一并等待,只有 parentSeen 增加;parent.stop 后改为二并等待,两边都不再增加。这样同时验证两个停止方向。
按功能边界而非文件层级嵌套
例如编辑器工作区可以是父作用域,自动保存和预览各自是子作用域。关闭预览只停止预览分支,关闭整个编辑器则由父级统一结束所有分支,比散落停止句柄更容易保证完整。
不要为每个小函数无条件创建 scope。过深的所有权层次会让停止原因难追踪;只有子功能确实需要单独重启或提前结束时再建立子级,并用业务名称记录它的创建和停止。
容易答错的地方
- 认为 stop 会向上影响父级
- 默认级联方向是拥有者到后代。子 scope 自己结束后,父级的其他 watch 和 computed 仍然活动;若业务希望一起结束,应调用共同父级或统一的关闭入口。
- 用代码缩进推断作用域关系
- 异步回调里创建的 scope 可能没有预期父级,反之辅助函数同步调用时可能加入当前 scope。应根据运行时活动作用域和 API 参数判断,而不是根据文件位置或缩进。
面试官还会怎么问?
父级重复 stop 会重复运行子级清理吗?
停止流程应当可以安全重复调用,已结束分支不会因再次 stop 重新激活或重复执行正常的一次性清理。外部资源自己的关闭函数也建议实现幂等,以覆盖多条结束路径。
子 scope 停止后还能再次 run 吗?
不能把已停止 scope 当作可复用开关。要重启该子功能,应创建新的子 scope 并在正确的活动父级中初始化,这样新的所有权关系才明确。
如何设计多个可独立关闭的功能?
为每个具有独立寿命的功能创建命名子 scope,由父模块保存引用和关闭函数。父级最终 stop 负责兜底,子功能按钮则只停止并替换自己的 scope。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。