先记住这个答案
组件手动创建的定时器、window 事件、ResizeObserver、连接或第三方实例,应在卸载流程中显式释放。onBeforeUnmount 运行时组件仍然完整可用,适合需要读取实例状态并发出关闭动作;onUnmounted 运行时子组件与关联响应式效果已经停止,适合确认结束。许多资源在任一处都能释放,关键是注册与取消使用同一引用、覆盖提前失败,并让清理幂等。setup 中同步创建的 watch 会自动停止,但其回调建立的外部资源仍需登记清理。
- 外部资源需要显式调用自己的释放接口
- beforeUnmount 时组件仍然可用
- 注册和移除必须保存同一函数或实例引用
内存泄漏之外还有行为泄漏
全局键盘监听忘记移除,不仅保留闭包和组件数据,还会让用户离开页面后继续触发快捷键。定时器可能重复请求,重新进入页面又添加一份,最终表现为动作执行多次。
排查时不能只看浏览器内存曲线。记录活动监听、连接与定时任务的数量,在反复挂载和卸载后应回到基线;同时验证卸载后模拟事件不会继续改变旧状态。
使用同一个函数引用移除监听
下例由调用方传入类似 EventTarget 的对象,mounted 时添加固定 handler,beforeUnmount 时用同一引用移除。把匿名函数分别写在 add 和 remove 中会得到两个对象,无法取消原监听。
示例让 handler 只递增普通计数,以便在自定义事件源中验证卸载后不再调用。真实浏览器实现还要确保事件类型、capture 等选项在添加和移除时匹配。
import { h, onBeforeUnmount, onMounted } from 'vue'
function makeListenerProbe(target: Pick<EventTarget, 'addEventListener' | 'removeEventListener'>) {
const state = { calls: 0 }
return {
state,
component: {
setup() {
const handler = () => { state.calls++ }
onMounted(() => target.addEventListener('change', handler))
onBeforeUnmount(() => target.removeEventListener('change', handler))
return () => h('span', String(state.calls))
}
}
}
}挂载组件后触发 change 会让 calls 加一;卸载后再次触发保持不变。工厂保存状态仅用于验证,产品状态若需要更新视图应使用 ref,并避免在组件结束后继续接收事件。
清理异步资源要考虑等待
关闭 WebSocket 可以同步发起,但服务端确认可能稍后到达;组件卸载钩子不应被当作等待所有关闭 Promise 的事务。先让本地回调失效、移除引用,再由日志或应用级关闭流程观察异步结果。
如果资源在异步初始化完成前组件就卸载,清理钩子当时可能还没有句柄。需要取消初始化或设置 disposed 标记,资源迟到后立即关闭,避免只写 optionalHandle?.close 而留下卸载后创建窗口。
容易答错的地方
- add 和 remove 分别使用匿名函数
- 两个看似相同的箭头函数并不是同一引用,remove 无法匹配已注册监听。先声明 handler,再在创建和清理两处复用,并保存必要选项。
- 认为自动停止 watch 会关闭所有任务
- Vue 能停止观察者本身,但无法猜测回调启动的请求、第三方订阅或工作器如何结束。把这些取消动作登记在 watcher 清理或组件作用域清理中。
面试官还会怎么问?
应该选择 onBeforeUnmount 还是 onUnmounted?
需要在组件仍完整可用时读取引用或主动断开,使用 before;只做无需组件状态的最终释放,两者通常都可。团队更应统一资源所有权和幂等规则,而非死记唯一钩子。
KeepAlive 切走时会执行卸载吗?
缓存组件进入 deactivated 状态而不是普通卸载,若资源只应在可见时活动,要在 deactivated 暂停、activated 恢复;最终从缓存移除时再完成永久释放。
如何测试监听真的移除了?
使用可计数的事件源,挂载后确认监听数为一并能触发,卸载后确认回到零且再次触发不改变状态。再循环挂载多次,验证数量不会逐次累积。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。