组件销毁前的资源清理,是 Vue 项目中最容易被忽视、又最容易引发线上问题的一环。定时器没清除导致回调重复执行,window 上的事件监听器没移除导致内存泄漏,WebSocket 没关闭导致连接堆积——这些问题往往在开发阶段毫无征兆,等到页面越用越卡时才暴露出来。Vue 3 的组合式 API 提供了一整套与组件生命周期绑定的清理机制,用好了可以让资源的申请与释放天然成对出现。本文从生命周期钩子入手,逐步展开各种典型资源的清理写法,最后介绍如何用 composable 和 EffectScope 把清理逻辑封装得更加优雅。

一、理解 Vue 3 的卸载生命周期与清理时机
Vue 3 提供了两个与组件卸载相关的钩子:onBeforeUnmount 和 onUnmounted。前者在组件卸载之前触发,此时组件实例还完全可用,DOM 仍然存在,适合做那些需要访问实例状态的清理操作;后者在卸载完成之后触发,此时组件的 DOM 已经被移除,实例上的响应式效果也已停止。
两者的分工可以这样理解:如果你需要在清理前读取一些数据、或者给其他模块发一次通知,用 onBeforeUnmount;如果只是纯粹地释放资源,比如断开连接、销毁实例,两个钩子都可以,但 onUnmounted 的语义更贴合“资源已不再被需要”这一事实。需要注意的是,这两个钩子必须在 setup 执行期间同步调用,不能放在异步回调或 setTimeout 里调用,否则 Vue 无法将钩子注册到当前组件实例上,会收到一条警告并且钩子永远不会执行。
还有一个容易混淆的点:v-if 切换、路由跳转、KeepAlive 的 deactivate 是三种不同的场景。前两者会真正触发卸载钩子,而 KeepAlive 缓存的组件只会触发 onDeactivated,组件实例还活着,定时器依旧在跑。如果你的组件可能被 KeepAlive 缓存,资源清理应该同时考虑 onDeactivated 与 onUnmounted,前者暂停工作,后者彻底释放。
二、常见资源的清理写法:定时器、事件监听与第三方实例
先看最常见的定时器。很多内存泄漏案例的源头就是 setInterval 创建之后没有 clearInterval,组件销毁了,回调却还在改一个已经无人渲染的状态。规范的做法是把定时器句柄存下来,在卸载钩子里清除:
import { onBeforeUnmount } from 'vue'
export function usePolling(callback, delay = 3000) {
let timer = null
const start = () => {
timer = setInterval(callback, delay)
}
const stop = () => {
if (timer !== null) {
clearInterval(timer)
timer = null
}
}
onBeforeUnmount(stop)
return { start, stop }
}这段代码体现了一个重要思想:把资源的创建与销毁封装在同一个函数里,使用者只需要调用 start,清理工作由函数内部自动完成。相比在组件里裸写 setInterval 再手动清掉,这种方式不会遗漏,也不会因为业务代码改动而引入泄漏。
window 或 document 上的原生事件监听是另一大泄漏源。Vue 模板中的 @click 会随组件自动解绑,但通过 addEventListener 手动挂到全局对象上的监听器,Vue 是管不到的,必须成对调用 removeEventListener,并且第三个参数(比如 passive 或 capture 配置)要保持一致,否则移除会静默失败:
import { onMounted, onBeforeUnmount } from 'vue'
export function useResize(handler) {
const wrapped = () => handler(window.innerWidth, window.innerHeight)
onMounted(() => {
window.addEventListener('resize', wrapped, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener('resize', wrapped, { passive: true })
})
}注意这里必须保留对同一个函数引用的持有。如果 add 和 remove 时各写一个箭头函数,即使代码一模一样,也是两个不同的引用,监听器依然不会被移除,这是实际项目里非常经典的坑。同理,echarts 实例、地图 SDK、视频播放器这类第三方库,都应该在卸载时调用它们各自的 dispose 或 destroy 方法,让库自己回收内部绑定的 DOM 事件和 Canvas 上下文。
三、响应式副作用与异步请求的自动清理
组合式 API 中有一类资源是可以自动清理的,最典型的就是 watch 和 watchEffect。当它们在组件的 setup 上下文中创建时,会自动关联到当前组件实例的作用域,组件卸载时 Vue 会自动停止这些侦听器,不需要手动调用返回的 stop 函数。但如果你在组件外部(比如一个独立的工具模块里)创建 watcher,或者在异步回调之后才创建,它就脱离了组件作用域,需要手动管理。
异步请求的清理则更微妙一些。组件销毁后,正在进行中的请求依然会完成,此时回调里如果继续修改响应式状态,虽然 Vue 3 不会报错(响应式系统还在,只是没人渲染了),但可能触发额外的逻辑。常见的做法是用一个取消标志位,或者直接利用 AbortController:
import { onMounted, onBeforeUnmount } from 'vue'
export function useFetchUser(userId) {
const controller = new AbortController()
onMounted(async () => {
try {
const res = await fetch(`/api/users/${userId.value}`, {
signal: controller.signal
})
const data = await res.json()
// 正常处理数据
} catch (e) {
if (e.name !== 'AbortError') throw e
}
})
onBeforeUnmount(() => {
controller.abort()
})
}如果团队统一使用 axios,也可以在请求配置里传入同一个 AbortController 的 signal,效果一致。核心原则是:组件销毁后,所有与它相关的异步流程要么被取消,要么在回调里主动检查组件是否还存活,避免执行无意义甚至有害的后续操作。
四、进阶封装:tryOnScopeDispose 与 EffectScope
VueUse 库里有一个非常实用的工具函数 tryOnScopeDispose,它的作用是:如果当前处于一个有效的 effect 作用域中,就注册清理回调;否则什么也不做。配合它,你可以写出既能在组件内使用、也能脱离组件独立运行的通用逻辑:
import { effectScope, onScopeDispose } from 'vue'
export function createEventBus() {
const listeners = new Set()
const scope = effectScope()
scope.run(() => {
// 在这个作用域内创建的所有 watcher 都会被统一管理
})
function on(fn) {
listeners.add(fn)
// 返回取消订阅函数,调用方也可以手动取消
return () => listeners.delete(fn)
}
function dispose() {
listeners.clear()
scope.stop() // 停止作用域内所有响应式副作用
}
// 如果在组件 setup 中调用,组件卸载时自动 dispose
onScopeDispose(dispose)
return { on, dispose }
}EffectScope 是 Vue 3.2 之后暴露的底层 API,它把一组响应式副作用打包成一个整体,调用 scope.stop() 就能一次性停止里面所有的 watcher 和 computed。对于复杂的组合式函数,与其在每个 watcher 上单独调用 stop,不如把它们放进一个 scope 统一管理。组件自身的 setup 就运行在一个内置的 scope 中,这也是 watch 能自动清理的根本原因——理解了这一点,你对 Vue 响应式系统的资源管理就有了完整的认识。
总结一下实践建议:优先依赖 Vue 的自动清理机制(模板事件、作用域内的 watcher);手动创建的全局资源务必封装成 composable,把申请和释放写在一起;在卸载钩子里只做清理,不要发起复杂的业务流程;涉及 KeepAlive 时同时处理 deactivate 场景。把这些习惯固化下来,资源泄漏问题基本可以从代码层面根除。
Vue 3资源清理onBeforeUnmount修改时间:2026-09-16 21:57:52