用了Vue3 Composition API开发后总感觉页面卡?说不定是踩了内存泄漏的坑
上周收到好几个刚转Vue3的前端同事的求助:做的电商详情页、实时数据大屏、后台多标签页系统,打开切换几次就开始掉帧,输入框打字都有延迟,浏览器标签页占的内存更是一路飙升到三四G,必须手动关掉才恢复,排查一圈下来,90%以上都是踩了Vue3特有的内存泄漏坑——尤其是Composition API带来的新变化,很多习惯Vue2 Options API的开发者容易忽略,今天咱们就用问答的形式,把这些高频坑一个个揪出来,再配上能直接复制改参数的解决办法,帮你彻底解决页面卡顿的问题。
什么是Vue3的内存泄漏?和普通JavaScript内存泄漏有区别吗?
先别着急找坑,得先搞清楚“Vue3内存泄漏”到底指啥,不然就算看到代码,也可能漏过去。 普通JavaScript的内存泄漏,就是代码里申请了内存空间(比如创建了变量、绑定了事件),但再也不用了,也没还给浏览器,浏览器的垃圾回收器(GC)找不到“这块空间没人用”的信号,就一直留着,久而久之可用内存越来越少。 Vue3作为框架,底层会帮我们做很多自动回收的工作——比如组件卸载时自动解绑组件内直接绑定的DOM事件、自动销毁响应式数据的内部依赖(watch、computed这些)、自动解绑v-once/v-if/v-for的虚拟DOM绑定,但如果我们的代码“绕过”了Vue3的自动回收机制,就会产生Vue3特有的内存泄漏。 举个简单的例子:在Vue2里,你在mounted里给window绑了个resize事件,忘了在beforeUnmount(对应Vue2的beforeDestroy)里解绑,这是普通的JS全局事件泄漏;但在Vue3里,如果你用了onMounted钩子却漏写解绑逻辑,或者用setup里的定时器/异步请求没有在正确的时机清理,或者用了跨组件的响应式数据却没有处理引用关系,这些都是和框架强相关的,而且更容易犯——因为Composition API把逻辑拆得更细了,之前在Options API里“beforeUnmount必清理”的惯性被打破了。
Vue3中最容易踩的高频内存泄漏坑有哪些?怎么排查?
光说概念没用,直接上高频踩坑场景,每个场景附一段“踩坑代码”和“修正代码”,最后再教你个通用的快速排查法。
场景1:setup里的全局/跨组件事件、定时器、异步请求忘记清理
这绝对是新手转Vue3踩的第一个大坑!Options API里,beforeDestroy钩子就在那里摆着,你写了mounted的逻辑,大概率会瞟一眼beforeDestroy;但Composition API里,逻辑是按功能拆成组合式函数或者写在setup根级的,清理逻辑很容易跟着功能漏在后面,甚至很多组合式函数自己忘了封装清理步骤。
踩坑代码
<template>
<div>当前时间:{{ currentTime }}</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const currentTime = ref('');
// 定时器,每秒更新时间
onMounted(() => {
setInterval(() => {
currentTime.value = new Date().toLocaleString();
}, 1000);
});
</script>
这段代码看起来没问题吧?但你打开页面,切换到另一个页面(假设是路由跳转且组件销毁了),再打开开发者工具的性能面板/内存面板,会发现:定时器还在每秒触发,浏览器一直在更新currentTime的响应式引用,但currentTime所在的组件实例已经被销毁了,整个响应式链路都留着,内存根本不会回收。
修正代码
Vue3给我们提供了onUnmounted钩子和组合式函数的返回清理函数机制,两种方法都能用,推荐用后者写组合式函数,更符合“高内聚低耦合”的原则。
方法1:用onUnmounted直接清理
<template>
<div>当前时间:{{ currentTime }}</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const currentTime = ref('');
let timer = null;
onMounted(() => {
timer = setInterval(() => {
currentTime.value = new Date().toLocaleString();
}, 1000);
});
onUnmounted(() => {
clearInterval(timer);
timer = null; // 顺便把变量也置空,彻底切断引用
});
</script>
方法2:用组合式函数的返回清理函数(推荐,复用性强)
<template>
<div>当前时间:{{ currentTime }}</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
// 封装一个获取实时时间的组合式函数
const useCurrentTime = () => {
const currentTime = ref('');
let timer = null;
onMounted(() => {
timer = setInterval(() => {
currentTime.value = new Date().toLocaleString();
}, 1000);
});
onUnmounted(() => {
clearInterval(timer);
timer = null;
});
return { currentTime };
};
// 在组件里直接用
const { currentTime } = useCurrentTime();
</script>
哦对了,异步请求也是同理——比如你在setup里发了个详情页的请求,用户快速切换路由,请求还在回来的路上,这时候如果响应式数据还绑定着,就会导致泄漏,怎么处理?用AbortController!
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import axios from 'axios';
const detailData = ref(null);
const controller = new AbortController();
const signal = controller.signal;
onMounted(async () => {
try {
const res = await axios.get('/api/detail', { signal });
detailData.value = res.data;
} catch (err) {
if (!axios.isCancel(err)) { // 排除手动取消的情况,打印真实错误
console.error(err);
}
}
});
onUnmounted(() => {
controller.abort(); // 组件卸载时取消请求
});
</script>
场景2:使用了ref/reactive绑定了DOM元素但没清空引用
这也是Composition API独有的坑!Options API里的$refs是Vue实例的一个属性,组件卸载时Vue会自动把$refs下的所有DOM引用清空;但setup里的ref绑定DOM(或者说“模板引用”)是独立的响应式变量,组件卸载时Vue只会解绑DOM事件,不会把这个ref变量的value置空——如果这个ref变量还被其他地方引用了(比如闭包、定时器回调、异步请求回调),那整个DOM节点及其子节点都会留在内存里,这可是大内存消耗!
踩坑代码
<template>
<!-- 假设这是一个全屏canvas数据大屏 -->
<canvas ref="chartCanvas"></canvas>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import * as echarts from 'echarts';
const chartCanvas = ref(null);
let chartInstance = null;
onMounted(() => {
// 初始化echarts
chartInstance = echarts.init(chartCanvas.value);
chartInstance.setOption({ /* 这里是你的图表配置 */ });
// 顺便给window绑了个resize事件,触发echarts的重绘
window.addEventListener('resize', () => {
chartInstance?.resize();
});
});
// 只清理了chartInstance,没清理chartCanvas
onUnmounted(() => {
chartInstance?.dispose();
chartInstance = null;
// window.removeEventListener('resize', ...); 这里也容易漏!
});
</script>
这段代码里有两个坑:一是没清空chartCanvas.value,二是没清理window的resize事件,就算清理了chartInstance,chartCanvas.value还是指向那个已经被移出DOM的canvas节点,resize事件的回调里还可能用到chartInstance或者chartCanvas(虽然这里用了可选链,但引用关系还在),整个内存都会卡着。
修正代码
把该清的都清掉!模板引用一定要在onUnmounted里置空,全局事件一定要解绑。
<template>
<canvas ref="chartCanvas"></canvas>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import * as echarts from 'echarts';
const chartCanvas = ref(null);
let chartInstance = null;
// 把resize回调单独抽出来,方便解绑
const handleResize = () => {
chartInstance?.resize();
};
onMounted(() => {
chartInstance = echarts.init(chartCanvas.value);
chartInstance.setOption({ /* 配置 */ });
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
// 1. 先解绑全局事件
window.removeEventListener('resize', handleResize);
// 2. 再销毁第三方库实例
chartInstance?.dispose();
chartInstance = null;
// 3. 最后清空模板引用
chartCanvas.value = null;
});
</script>
场景3:使用了provide/inject传递响应式数据,子组件持有长期引用但没断开
provide/inject是Vue3里跨层级组件通信的常用方法,但如果用得不当,也会产生严重的内存泄漏——尤其是在后台多标签页、动态组件切换的场景下。 举个例子:你有一个父组件叫App,provide了一个全局的用户信息reactive对象;然后有一个动态加载的子组件叫UserCard,inject了这个对象,还在自己的组合式函数里用watch监听了这个对象的某个属性,并且把watch的返回值(停止监听函数)存在了闭包里忘了调用?不对,watch如果是写在子组件的setup根级或者组合式函数里(且组合式函数调用了Vue的生命周期钩子),Vue会自动在组件卸载时停止watch的,那真正的坑是什么? 是provide的是一个动态创建的响应式数据,而不是App根组件里一直存在的响应式数据,或者子组件把inject的响应式数据赋值给了一个外部作用域的变量(比如全局变量、单例对象、没有清理的闭包变量)。
踩坑代码
假设有一个后台管理系统,左侧是菜单,右侧是动态加载的标签页组件:
<!-- 父组件:标签页容器 -->
<template>
<component :is="currentTabComponent" v-for="tab in tabs" :key="tab.id"></component>
</template>
<script setup>
import { ref, provide, shallowRef } from 'vue';
import UserList from './UserList.vue';
import OrderList from './OrderList.vue';
const tabs = ref([
{ id: 1, name: '用户列表', component: shallowRef(UserList) },
{ id: 2, name: '订单列表', component: shallowRef(OrderList) },
]);
const currentTabId = ref(1);
// 这里的坑:provide了一个动态创建的“当前选中标签页信息”reactive对象
// 而且每次currentTabId变的时候,这个provide的响应式数据会不会更新?其实不会,provide是一次性的(除非用computed或者watch重新provide,但这里我们只说内存泄漏的问题)
const currentTabInfo = reactive({ id: currentTabId.value, name: tabs.value.find(t => t.id === currentTabId.value)?.name });
provide('currentTabInfo', currentTabInfo);
</script>
<!-- 子组件:用户列表 -->
<script setup>
import { inject, onMounted } from 'vue';
const currentTabInfo = inject('currentTabInfo');
// 假设这里有一个外部的单例全局事件总线(很多项目里还在用EventBus)
import eventBus from '@/utils/eventBus';
onMounted(() => {
// 把inject的currentTabInfo直接绑在EventBus的事件回调里!
eventBus.on('refreshTab', () => {
console.log('刷新当前标签页:', currentTabInfo.name);
});
});
</script>
这段代码的问题:
- EventBus是全局单例,不会随组件销毁而销毁;
- 子组件UserList卸载时,没有解绑EventBus的'refreshTab'事件;
- 事件回调里直接引用了currentTabInfo——currentTabInfo是从标签页容器组件provide的,标签页容器组件一般不会卸载,所以currentTabInfo本身不会被回收;但更严重的是,如果标签页容器组件的tabs是动态添加的(比如点击左侧菜单才加),那我们再做个假设:子组件UserList把自己的组合式函数里的某个临时数据赋值给了currentTabInfo,那就算UserList卸载了,临时数据也会被currentTabInfo引用,永远不会回收。
修正代码
跨层级通信的内存泄漏,核心原则就是:谁持有引用,谁负责清理;尽量不要把外部作用域的变量(尤其是全局变量、单例对象)和组件内部的响应式数据/临时变量直接绑定。 针对上面的例子,有三个修正方向:
- 尽量不用全局EventBus,改用Pinia/Vuex状态管理库——状态管理库的设计本身就是考虑了全局状态的,而且一般不会直接引用组件内部的临时数据;
- 如果必须用EventBus,一定要在子组件的onUnmounted里解绑事件,而且事件回调里尽量不要直接引用inject/provide的数据,如果要引用,最好用shallowRef或者只引用基本类型(引用类型就算解绑了,也可能有闭包残留,不过概率低一点);
- 如果provide的是动态组件内部的数据(不是根组件的全局数据),一定要确保动态组件卸载时,provide的响应式数据的引用被彻底断开——不过一般不建议这么做,provide最好只提供根组件或者布局组件里长期存在的全局数据。
场景4:使用了keep-alive缓存组件,但没有正确处理activated/deactivated钩子
keep-alive是Vue里用来缓存组件状态的神器,但也是内存泄漏的重灾区——因为被keep-alive包裹的组件,切换时不会销毁,只会触发deactivated钩子,再次切换回来才会触发activated钩子,很多开发者不知道这一点,还是按照普通组件的逻辑,在onMounted里绑定事件,在onUnmounted里解绑——结果切换到其他keep-alive组件时,事件还在触发,内存继续消耗;而且如果keep-alive的max属性设置得很大或者没设置,缓存的组件越来越多,内存直接爆炸。
踩坑代码
<!-- App.vue,用keep-alive包裹路由组件 -->
<template>
<keep-alive>
<router-view v-if="$route.meta.keepAlive"></router-view>
</keep-alive>
<router-view v-if="!$route.meta.keepAlive"></router-view>
</template>
<!-- 缓存的用户列表组件 -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import eventBus from '@/utils/eventBus';
const userList = ref([]);
// 绑定事件的钩子写错了!
onMounted(() => {
eventBus.on('addUser', (newUser) => {
userList.value.push(newUser);
});
// 或者绑了个定时器,每5秒刷新列表
setInterval(() => {
// 发起请求刷新userList
}, 5000);
});
// 解绑事件的钩子也写错了!
onUnmounted(() => {
eventBus.off('addUser');
// 清理定时器
});
</script>
这段代码的问题:用户列表组件被keep-alive缓存了,切换到其他缓存组件时,只会触发deactivated,不会触发onUnmounted,所以事件不会解绑,定时器不会停止;再次切换回来时,又会触发onMounted,绑定第二个事件、第二个定时器——循环往复,内存直接飙升。
修正代码
把绑定/启动的逻辑放在activated钩子,把解绑/清理的逻辑放在deactivated钩子,这样组件切换时就会暂停消耗,再次回来才重新启动,一定要给keep-alive设置max属性,限制缓存的组件数量,避免缓存过多导致内存不足。
<!-- App.vue修正版,加上max属性 -->
<template>
<keep-alive :max="5">
<router-view v-if="$route.meta.keepAlive"></router-view>
</keep-alive>
<router-view v-if="!$route.meta.keepAlive"></router-view>
</template>
<!-- 缓存的用户列表组件修正版 -->
<script setup>
import { ref, onActivated, onDeactivated } from 'vue';
import eventBus from '@/utils/eventBus';
const userList = ref([]);
let refreshTimer = null;
// 把事件回调抽出来
const handleAddUser = (newUser) => {
userList.value.push(newUser);
};
// 组件激活时绑定/启动
onActivated(() => {
eventBus.on('addUser', handleAddUser);
refreshTimer = setInterval(() => {
// 刷新列表
}, 5000);
});
// 组件失活时解绑/清理
onDeactivated(() => {
eventBus.off('addUser', handleAddUser);
clearInterval(refreshTimer);
refreshTimer = null;
});
</script>
场景5:使用了第三方库(比如ECharts、Three.js、Leaflet)但没正确调用库的销毁方法
这个场景Vue2也有,但Vue3用组合式函数拆分第三方库的逻辑后,更容易漏销毁步骤——比如你把ECharts的初始化逻辑放在一个组合式函数里,却忘了在组合式函数里调用onUnmounted来dispose ECharts实例。 不同的第三方库有不同的销毁方法,
- ECharts:chartInstance.dispose()
- Three.js:渲染器renderer.dispose(),场景scene.traverse(item => { if(item.geometry) item.geometry.dispose(); if(item.material) item.material.dispose(); })
- Leaflet:mapInstance.remove()
- Swiper:swiperInstance.destroy(true, true)
这里就不贴单独的踩坑代码了,核心原则就是:引入第三方库后,一定要去查它的官方文档,找到“销毁实例、释放内存”的方法,然后在组件卸载(或者keep-alive失活)时调用,最后把实例变量和模板引用都置空。
通用的Vue3内存泄漏快速排查法
说了这么多坑,怎么才能快速找到自己的项目里有没有内存泄漏呢?用Chrome开发者工具的Memory面板和Performance面板就行,不用太复杂的操作,跟着下面的步骤走:
- 打开Chrome开发者工具,切换到Performance面板,点击顶部的“垃圾筒”图标(强制GC),然后点击“录制”按钮(圆形的那个);
- 操作你怀疑有泄漏的场景:比如反复打开关闭某个详情页、反复切换keep-alive的标签页、反复滚动数据大屏;
- 操作结束后,停止录制,强制GC一次,然后看Performance面板里的“Memory”折线图——如果折线图是“先上升,然后慢慢下降(GC回收)”,那说明没有泄漏;如果折线图是“一路上升,强制GC后也降不下来多少”,那肯定有泄漏;
- 如果确定有泄漏,切换到Memory面板,点击“堆快照”按钮(照相机图标),拍第一张堆快照(命名为“操作前”);
- 重复步骤2的操作,再强制GC一次,拍第二张堆快照(命名为“操作后”);
- 在Memory面板的顶部,把视图切换为“Comparison(比较)”,把“基准”选为“操作前”,把“目标”选为“操作后”;
- 看比较结果里的“Retained Size(保留大小,也就是这个对象及其引用的所有对象占用的内存)”列,按从大到小排序——如果发现有很多Vue组件实例、DOM节点、第三方库实例(比如EChartsInstance)还留在堆里,且数量和你操作的次数一致,那就是泄漏源了;
- 点击泄漏源的对象,看右侧的“Retainers(引用链)”,找到是谁在引用这个对象,然后修改代码切断引用链就行。
有没有预防Vue3内存泄漏的通用规范?
光会排查和修复还不够,最好从写代码的第一天起就遵循规范,从源头上避免泄漏,我结合自己的项目经验,整理了5条非常实用的规范:
- 逻辑拆分时,清理逻辑一定要和初始化逻辑放在同一个组合式函数里——比如你封装了一个useECharts的组合式函数,那init逻辑、setOption逻辑、dispose逻辑都要放在里面,这样用的人就不用自己再写清理步骤了;
- setup根级的定时器、异步请求、全局事件,一定要对应写onUnmounted清理——如果嫌麻烦,可以用VueUse里的useInterval、useTimeoutFn、useEventListener这些组合式函数,它们已经帮你封装好了清理逻辑;
- 模板引用一定要在onUnmounted里置空——哪怕你觉得没有其他地方引用它,置空也是个好习惯;
- keep-alive的activated/deactivated钩子要和普通组件的onMounted/onUnmounted钩子区分开——缓存组件的初始化逻辑放activated,清理逻辑放deactivated;
- 尽量不用全局EventBus,改用Pinia/Vuex状态管理库——如果必须用EventBus,一定要单独抽事件回调函数,在组件卸载时解绑;
- 引入第三方库后,第一时间查文档找销毁方法——可以写个TODO注释在初始化代码旁边,提醒自己写完逻辑要加销毁步骤。
Vue3的内存泄漏,大部分都是因为“忽略了Composition API的自动回收范围”“keep-alive的生命周期钩子用错了”“第三方库没销毁”这几个原因导致的,只要我们遵循规范,写代码时多留个心眼,再配合Chrome开发者工具的Memory和Performance面板排查,就能彻底解决页面卡顿的问题。 最后再提醒一句:内存泄漏不是小事,尤其是在后台管理系统、实时数据大屏这种需要长时间打开的项目里,内存泄漏会导致浏览器崩溃,甚至影响用户的电脑性能,一定要重视起来!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网

