先记住这个答案
在组件 setup 同步执行期间创建的侦听器通常会绑定组件,随卸载停止;普通定时器、Promise 回调里的创建动作已经离开这个上下文,不能依赖自动销毁。优先同步创建侦听器,用响应式就绪状态控制是否工作。必须延迟创建时,要同步注册卸载清理、保存 stop 句柄,并阻止卸载后才到达的异步回调继续创建。
- 优先同步创建并用就绪状态控制执行
- 延迟创建既要停止已有监听,也要阻止迟到创建
- 普通异步回调不能假定存在组件上下文
问题根源是创建时的归属
组件不是根据代码文件位置来识别监听的所有者,而是依赖执行时的上下文。即便定时器代码写在 setup 内,它稍后调用 watch 的那一刻也不等于仍然处在最初的同步初始化阶段。
泄漏的表现可能是离开页面后日志继续增长,或重新进入页面时每次输入触发多份请求。排查时记录创建次数、停止次数及组件实例标识,比只观察当前画面是否正常更容易发现残留订阅。
优先让监听等待数据就绪
下面的组合式函数应在 setup 同步调用。监听先建立,ready 的改变只负责开关工作内容,不再在每次加载完成时新增监听;卸载时框架能够结束这份已归属组件的效果。
如果加载逻辑本身也是异步任务,仍然要处理加载取消和过期响应。同步创建只是解决监听的生命周期归属,并不会让业务请求自动获得取消、错误处理或最新结果优先的能力。
import { ref, watchEffect } from 'vue'
function useDeferredReporter(report: (value: string) => void) {
const ready = ref(false)
const value = ref('')
const stop = watchEffect(() => {
if (!ready.value) return
report(value.value)
})
return { ready, value, stop }
}ready 为假时只收集实际访问到的就绪依赖,切换为真后会读取并跟踪 value。组合式函数需要在组件同步初始化期调用,放进异步回调仍然无法获得这里预期的自动归属。
必须延迟创建时要守住卸载窗口
例如外部 SDK 初始化完成后才知道监听源,先在同步阶段注册卸载钩子。卸载时设置 disposed 标记并调用已有 stop;初始化回调先检查 disposed,再创建监听并保存句柄。
只在卸载钩子里写可选调用 stop 不够:卸载发生时句柄可能尚未赋值,稍后回调还是会创建新监听。可以取消的定时器也应清除,无法取消的 Promise 则需要在继续执行前检查任务是否仍然有效。
容易答错的地方
- 在异步回调里注册卸载钩子
- 异步回调中可能连生命周期钩子的组件上下文都已丢失,因此把清理注册与监听创建一起延后无法补救。应在同步初始化期确定清理责任,再让异步完成路径服从结束标记。
- 仅修复停止却忽略重复创建
- 当加载状态反复切换时,每次成功都创建监听会不断累积订阅。除卸载清理外,还应保证同一功能只持有一份活动监听,替换前结束旧监听,或改成一次创建并调整源。
面试官还会怎么问?
为什么同步创建后加条件可以减少泄漏?
监听从一开始就有组件作为所有者,条件只决定本次是否产生业务副作用,不改变这份监听的生命周期。数据多次就绪也只是重新执行同一份效果,不会追加新的观察者。
组件卸载后才完成初始化应该怎么办?
在初始化完成入口检查已经设置的结束标记,直接释放刚取得的外部资源并停止后续创建。若初始化库提供取消能力,卸载时同时调用,但仍保留标记以应对取消来不及的情况。
所有 await 之后创建监听都一定失去归属吗?
不能这样一概而论。普通异步回调通常没有原组件上下文,而 script setup 顶层 await 会经过编译器转换。回答时应明确代码执行方式,并优先采用同步注册这种更易审查的结构。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。