提到 Vue 的实例销毁,很多人第一反应是“不就一个钩子函数吗,里面写个 console.log 就行”。但我在实际项目里排查过几回内存泄漏和页面卡死之后,对这两个钩子的态度彻底变了。这个系列前面几篇一直在聊生命周期里的“活着”阶段,这一期我们专门聊聊 Vue 实例销毁——也就是组件从页面上消失、从内存里被清理的完整过程中,真正值得你注意的东西。
先说一个很反直觉的事实:Vue 实例销毁不是你主动“杀”掉它才发生的。绝大多数销毁动作都是静默发生的,v-if 把组件切走、路由跳到下一个页面、弹窗关闭,组件实例就已经在走销毁流程了。如果你对这套流程理解不够,定时器还在跑、事件监听还挂在全局、图表实例还在后台刷数据,这些“僵尸资源”就会一直占着内存,页面用着用着越来越卡,最后只能靠刷新续命。
这篇文章我会把实例销毁的触发场景、钩子里到底该做什么、父子组件的销毁顺序、以及日常开发里最容易踩的坑一次性讲清楚。无论你用的是 Vue 2 还是 Vue 3,里面的思路和排查方法基本通用。
1. 先搞清楚一套生命周期在两代框架里的叫法
1.1 Vue 2 的 beforeDestroy 与 destroyed
Vue 2 时期,实例销毁相关的钩子有两个:beforeDestroy和destroyed。
beforeDestroy在实例销毁之前触发。触发这一刻,组件还能正常访问data、methods、computed、props,DOM 也还在页面上。也就是说,这是你最后能做清理动作的时间点。官方文档对这个钩子的描述很简单,但实际含义是:所有资源还在,你还有机会善后。
destroyed在实例销毁之后触发。此时 Vue 实例上的 watcher 已经解绑、指令已经解除、事件监听已经移除,DOM 也已经被移除或者被重新渲染覆盖。在 Vue 2 里,destroyed钩子里你会发现组件实例本身还在,this仍然可以访问到,但所有响应式数据已经不会再更新视图了,再对this.xxx赋值也不会触发渲染。
一个很容易被忽略的事实:Vue 2 的destroyed之后实例并不等于被 JS 垃圾回收了。instance上挂着的东西如果没有外部引用,才会被回收。所以如果你在外部变量里引用了这个组件实例,销毁之后它依然存活,这就是内存泄漏的根源之一。
1.2 Vue 3 把销毁改成了卸载:beforeUnmount 与 unmounted
Vue 3 里,命名从“销毁”变成了“卸载”。对应关系是:
beforeDestroy→beforeUnmountdestroyed→unmounted
字面意思更准确了:Vue 3 的组件树构建和卸载,本质上是把 vnode 从树里移除、停止响应式依赖追踪、解绑指令和事件。这个“卸载”动作发生在组件实例从组件树被摘除的时刻。
Vue 3 里还有一个不太起眼但很关键的差异:unmounted触发时,组件实例仍然可以访问,但应用级的effect已经停止。如果组件内部开启了副作用(比如setInterval、主动addEventListener),这些并不会因为组件卸载而自动解除,你必须主动清理。
此外,Vue 3 的组合式 API 里有个onUnmounted,在setup里面注册,对应组件卸载后的回调。onBeforeUnmount则对应卸载前回调。如果你用<script setup>,这些钩子是可以直接导入使用的。还有一个细节:在setup里注册的onUnmounted优先于组件选项里的unmounted执行,这个顺序在实际调试时经常会让人困惑。
1.3 一套生命周期,两种写法,背后是同一个问题
两代框架虽然叫法不同,但背后要解决的逻辑是完全一样的:
- 组件没了,但组件创建的“副作用”不该继续存在。
- 清理要在正确的时机做——早了,资源还没用完;晚了,泄漏已经发生。
所以我在下文里如果不特别注明,统一用“卸载前”和“卸载后”来指代这两个阶段。你在 Vue 2 里就对应beforeDestroy/destroyed,在 Vue 3 里就对应beforeUnmount/unmounted。
2. 什么场景会触发实例销毁
2.1 v-if 切换是最常见的销毁现场
很多开发者对 v-if 和 v-show 的理解停留在“v-if 是真正的条件渲染,v-show 只是控制 display”这个层面,但对销毁流程的影响了解不够。
用 v-if 控制组件显示,条件从 true 变成 false 的那一刻,组件就会进入卸载流程:beforeUnmount→ 卸载内部子组件 →unmounted。这个流程是彻底的,组件实例会被标记为卸载,响应式数据不再驱动任何 DOM 更新。
而 v-show 只是修改display样式,组件实例从来没有销毁过,整个生命周期流程根本不会走到卸载钩子。它只是“藏着”,数据依然活跃、定时器依然在跑、事件监听依然生效。
这就引出一个非常实际的建议:如果你的组件内部有定时器、轮询、实时图表之类的“重资源”操作,用 v-if 控制它,并且卸载时做好清理;如果只是一段静态展示内容,v-show 没毛病。反过来,如果组件很重、创建成本很高,频繁切换的场景里用 v-show 会更划算,但代价是资源一直挂着。别问哪种绝对好,问就是“看你的组件里挂了多少资源”。
2.2 路由切换毁掉页面组件的真相
路由切换是另一个高频销毁场景。在一个典型的 SP应用中,路由从/list跳到/detail,如果 /list 对应的路由组件没有被 keep-alive 缓存,那它就进入卸载流程。
这里有个容易误判的点:路由切换走的销毁流程跟 v-if 一模一样,都是组件实例的卸载,不是页面刷新,也不会重载整个应用。因此,路由跳走后,旧页面组件里如果还有定时器或 WebSocket,它们依然存在。我见过好几个案例,都是“从列表页跳到详情页再返回,列表页的轮询还在打后端接口”,最后后端接口 QPS 莫名其妙涨了一倍。
处理办法就是在卸载钩子里把轮询停掉。返回列表页时组件重新走一遍创建流程,重新开轮询,这是最干净的方式。
2.3 keep-alive 是个特例:不是销毁,是休眠
再说一个特例:keep-alive包裹的组件,切换时并不销毁。
被keep-alive缓存的组件,离开时会触发deactivated,回来时触发activated。beforeUnmount和unmounted在组件被缓存期间不会触发。也就是说,组件实例还活着,只是暂停了视图的活跃状态。
这里就有一个经典的坑:你在mounted里开了定时器,在beforeUnmount里清理。但如果组件被 keep-alive 缓存后切走,beforeUnmount不会执行,定时器继续跑。正确的做法是:在activated里开启定时器、deactivated里清理定时器。因为 activated/deactivated 才对应“隐藏-显示”的真生命周期。
很多用el-tabs做多标签页系统的项目,标签页切换不关闭时,底层组件是被 keep-alive 缓存住的。我在这些项目里普遍建议:网络轮询类操作跟随 activated/deactivated,而一次性初始化的资源跟随 mounted/unmounted。这两套机制不能搞混。
3. 销毁钩子里最应该做的清理工作
3.1 定时器:setInterval 与 setTimeout 的善后
定时器是实例销毁时头号需要清理的对象。
先看一个反例:
export default { data() { return { timer: null, count: 0 }; }, mounted() { this.timer = setInterval(() => { this.count += 1; console.log('interval running', this.count); }, 1000); }, beforeUnmount() { // 忘了清理 // clearInterval(this.timer); } };这段代码在组件卸载后,setInterval依然每秒钟执行一次,this.count仍在更新。虽然 Vue 3 卸载后的实例不再渲染视图,但这个回调闭包引用了组件实例,导致实例不能被垃圾回收,这就是泄漏。
正确写法:
export default { data() { return { timer: null }; }, mounted() { this.timer = setInterval(() => { this.doSomething(); }, 1000); }, beforeUnmount() { if (this.timer) { clearInterval(this.timer); this.timer = null; } } };setTimeout也一样。有人觉得setTimeout只执行一次,不用清理,但如果组件在定时器触发之前就已经卸载了,回调依然会执行,回调里如果访问了组件数据或者操作了 DOM,照样有报错和泄漏风险。建议把定时器 ID 都存起来,在卸载前统一clearTimeout。
3.2 事件监听:三种不同的解绑姿势
事件监听是另一个重灾区。先记住一个核心原则:谁绑定,谁解绑;在哪个元素绑定,在哪个元素解绑。
第一种:绑定在组件自身元素或子元素上的事件。比如:
mounted() { window.addEventListener('resize', this.handleResize); }, beforeUnmount() { window.removeEventListener('resize', this.handleResize); }这种写法要求handleResize必须是同名的具名函数。如果是匿名函数,removeEventListener永远移除不了,相当于白白加了一个永远不删除的监听器。
第二种:通过$on绑定的事件总线。Vue 2 里非常常见:
mounted() { this.$bus.$on('refresh', this.handleRefresh); }, beforeDestroy() { this.$bus.$off('refresh', this.handleRefresh); }事件总线本质是一个全局单例,组件卸载后如果$off没做,总线里还挂着组件的方法引用,这个组件就永远无法被回收。Vue 3 中如果还在用轻量的事件总线库(或者自己实现了一个),同理。
第三种:第三方库注册的监听器,比如ECharts的click事件、Leaflet地图的交互事件。这些库通常会提供销毁方法,比如 ECharts 的dispose。只解绑自己的 DOM 监听是不够的,库内部的监听器也要随实例销毁。
3.3 图表实例与外部资源:echarts、WebSocket、DOM 引用
图表库是目前前端项目里最容易被泄漏的一环。
以 ECharts 为例,很多组件是mounted里初始化图表,然后把echarts.init的实例保存在this.chart上,但卸载时不调用dispose。这样图表实例持有的 DOM 引用、事件监听、渲染进程的调度信息都还在。统计下来,一个详情弹窗每打开一次就多一个 chart 实例,内存涨得肉眼可见。
一个安全的写法:
beforeUnmount() { if (this.chart) { this.chart.dispose(); this.chart = null; } }WebSocket 同理。组件里建立连接,卸载时必须关闭:
beforeUnmount() { if (this.socket) { this.socket.close(); this.socket = null; } }这里有一个很容易忽略的点:WebSocket 的onmessage、onclose回调里如果引用了组件方法,连接不关闭,组件就不会被回收。
还有 DOM 引用。document.querySelector、this.$refs.xxx这类引用,虽然组件卸载后 refs 会被置空,但如果你在外部变量里缓存了this.$el或者某个子节点的引用,DOM 节点依然残留在内存中。排查这类问题很费劲,最好的办法是从一开始就避免把 DOM 引用存到组件外部。
3.4 事件总线与全局状态:别让销毁留下暗雷
除了定时器和事件监听,另一个容易忽略的清理点是全局状态和非响应式数据。
事件总线在 Vue 2 项目里很普及,很多团队把它当成跨组件通信的标准手段。$on注册的监听器,如果组件卸载时没有$off,组件实例就会被事件总线引用。最典型的现象是:页面多次进出后,相同的事件触发了多次回调。
我的建议是,使用事件总线时养成三个习惯:
- 统一在
beforeDestroy/beforeUnmount里$off掉所有本组件注册的事件。 - 注册事件时,把回调方法定义成具名方法,避免匿名函数导致无法定向解绑。
- 优先考虑移除事件总线,改用
provide/inject或 Pinia 这类状态管理方案,从架构上消灭总线引起的泄漏。
如果用了 Pinia 或 Vuex,组件的$store订阅也要清理。Vuex 的this.$store.subscribe返回一个解绑函数,组件卸载时调用一下即可。Pinia 里$subscribe返回的也是解绑函数,同样要在卸载钩子里调用。这些 API 官方文档都写了,但实际项目里很少有人真的去做。
4. 父子组件的销毁顺序
4.1 先父后子还是先子后父
父子组件的销毁顺序,是理解实例销毁的进阶内容。我在面试里问过不少人,大部分人能说出挂载是“父 beforeCreate → 父 mounted → 子 mounted”的顺序,但问销毁顺序就含糊了。
实际顺序是:
父 beforeUnmount → 子 beforeUnmount → 子 unmounted → 父 unmounted也就是说,父组件先进入卸载流程,但等到自身子组件全部卸载完成后,父组件才完成卸载。
这个顺序背后的设计逻辑很清晰:父组件下线,意味着整个子树都不再需要了。父组件的卸载前钩子里,还能访问到子组件实例,可以做一些控制子组件离开的工作;子组件的卸载前钩子才去做自己的清理。子组件全部卸载完,父组件才打上“已卸载”的标记。
Vue 2 里对应的是:
父 beforeDestroy → 子 beforeDestroy → 子 destroyed → 父 destroyed顺序完全一致。
4.2 顺序背后的真实场景
理解这个顺序,对实际开发有两个直接帮助。
第一个帮助是:如果你在父组件的unmounted里尝试访问子组件实例,此时子组件已经卸载了,访问不到或者拿到的是空引用。如果你确实需要子组件先清理某些资源,父组件等子组件的结果,应该把协作逻辑放在子组件的beforeUnmount里完成,通过事件或状态告知父组件。
第二个帮助是:如果你有个全局的“页面访问统计”需求,希望在任何页面组件卸载时上报一下停留时长,你可以把这个逻辑放到一个混入或者组合式函数里,在beforeUnmount里上报。由于子组件和父组件都会执行,要小心统计逻辑被重复触发。通常的做法是只在最外层的页面级组件里做上报,用组件名称或路由元信息做过滤。
5. 实例销毁实战中的高频坑
5.1 异步回调里操作已销毁的数据
这个坑可能是所有 Vue 开发者都踩过的。
组件在mounted里发了一个请求,请求尚未返回,用户就切换走了,组件被卸载。等请求回来,回调里写了this.list = res.data或者this.count = ...。在 Vue 2 里,这不会报致命错误,但会有一个警告:Avoid mutating a prop or a Vue instance after it has been destroyed。Vue 3 里则可能直接出现无声的无效更新,或者更隐晦的内存引用问题。
正确的处理方式是:在卸载时标记“已销毁”,回调里判断:
mounted() { this._isDestroyedFlag = false; fetchData().then(res => { if (this._isDestroyedFlag) return; this.list = res.data; }); }, beforeUnmount() { this._isDestroyedFlag = true; }在 Vue 3 的组合式 API 里可以用一个let isCancelled = false配合onUnmounted置位。虽然用 AbortController 取消请求是更彻底的方式,但至少要做到“回调回来了也不要碰已卸载的实例”。
5.2 匿名函数导致 removeEventListener 失效
严格来说这不是 Vue 的问题,而是原生事件模型的问题,但在 Vue 组件里特别容易踩:
mounted() { window.addEventListener('scroll', () => { this.handleScroll(); }); }, beforeUnmount() { // 无效,因为这里创建了一个新的匿名函数 window.removeEventListener('scroll', () => { this.handleScroll(); }); }removeEventListener的匹配规则是函数引用相同才移除,两个匿名函数虽然代码一样,但引用不同,所以永远移除不了。监听器会一直存在,组件反复创建销毁,监听器就不断堆积。
正确写法是把回调定义成组件方法:
methods: { handleScroll() { // ... } }, mounted() { window.addEventListener('scroll', this.handleScroll); }, beforeUnmount() { window.removeEventListener('scroll', this.handleScroll); }如果非要用匿名函数,就把匿名函数存到变量里:
mounted() { this._onScroll = () => { this.handleScroll(); }; window.addEventListener('scroll', this._onScroll); }, beforeUnmount() { window.removeEventListener('scroll', this._onScroll); }5.3 卸载钩子里还能不能访问 data
beforeUnmount里访问this.xxx完全没问题,此时实例还活着。unmounted里访问也问题不大,Vue 2 时期的实践来看,this依然指向实例,数据也都还在。但注意,unmounted里访问到的数据已经是“死数据”了——修改它不会触发视图更新,因为渲染作用已经销毁。
有个容易误导的细节:在unmounted里同步读取this.$el,Vue 2 中this.$el还在但已经被移除;Vue 3 中this.$el可能为null。所以不要在unmounted里依赖 DOM 结构做什么操作,要操作 DOM 请在beforeUnmount阶段完成。
5.4 手动调用 $destroy 的场景与风险
Vue 2 里组件实例可以手动this.$destroy(),或者通过vm.$destroy()强制销毁实例。Vue 3 移除了实例上的$destroy方法,改为通过渲染机制控制组件的卸载,比如render(null)或者卸载根组件。
为什么 Vue 3 要移除$destroy?因为手动销毁很容易破坏组件树的完整性。你手动销毁了某个子组件,但父组件的渲染函数还认为它存在,下次更新时又把它创建出来,导致状态错乱。
实际业务中几乎不需要手动调用$destroy。如果你有“动态创建组件实例”的需求,比如利用new Vue()挂载一个临时组件,那么在业务结束后应该调用销毁方法释放资源。这类场景现在更推荐用createApp+render构建可控的挂载/卸载流程,而不是依赖实例的$destroy。
6. 实例销毁之后,我的一些习惯
做了几年前端,处理过各种离奇的内存相关问题之后,我总结了一套自己的组件开发习惯,这里分享一下。
写任何带异步逻辑或外部资源的组件时,我优先会想清楚“这个组件离开时,什么东西会留在这个世界上”。定时器、监听器、订阅器、连接、图表实例,统称为“组件外部资源”。这些资源的特点是不会随着组件卸载而自动消失,必须手动清理。
我会在组件代码里把“创建外部资源”和“销毁外部资源”写在同一块视线可及的地方。比如mounted里setInterval,我会立刻在beforeUnmount里写clearInterval。window.addEventListener后面我会紧跟着写removeEventListener。这样做的好处是:组件改动时,对资源的生命周期一目了然,不会出现“这个清理逻辑藏在 100 行之外的某个钩子里”的情况。
对于setInterval、setTimeout,我在团队里定的规范是:把 timer ID 统一放到this._timers数组里,beforeUnmount时统一清理:
data() { return { _timers: [] }; }, mounted() { this._timers.push(setInterval(...)); this._timers.push(setTimeout(...)); }, beforeUnmount() { this._timers.forEach(id => clearInterval(id)); this._timers = []; }当然,如果用了 Vue 3 迟更早的玩法,还可以把定时器直接放到一个独立的 composable 里,自动在onUnmounted中清理,比如useTimer、useEventListener这类封装。这样业务组件本身反而变得更干净。
其中useEventListener这类工具的底层,就是自动感应到组件卸载时removeEventListener。我建议小团队也准备几个这样的通用 composable,把常见的“清理需求”沉淀下来。组件销毁这件事方不方便,很大程度上取决于基础设施有没有把清理做到位。
关于实例销毁,我最后再分享一个小经验:排查内存泄漏,不要直接拿 Performance 面板一顿乱拍,先打开开发者工具的 Memory 页面,“Heap snapshot”一下,反复进入退出某个页面,看内存是否稳步上涨。如果上涨了再拍第二份快照,对比两份快照里新增了哪些对象。十次里有八次,新增对象都指向某个事件监听器、定时器回调、图表实例或者组件实例。沿着这条路找,比盲目看代码要高效得多。