一文搞懂JavaScript中的内存泄漏
本文深入解析JavaScript内存泄漏的成因、检测方法及常见场景。通过Chrome DevTools的Performance和Memory面板,结合引用计数与标记清除算法原理,详细演示如何排查由全局变量、定时器及闭包引发的内存泄漏问题,并提供Vue组件中的具体规避方案。
以往提及内存泄漏,多集中于后端开发领域,但这并不意味着前端可以高枕无忧。随着前端项目复杂度的不断提升,前端应用正逐渐演变为内存泄漏的高发区域。本文将带你系统性地认识并理解JavaScript中的内存泄漏问题。
什么是内存泄漏
内存是指程序运行期间,操作系统为其分配的一块存储空间。每一块被分配的内存都拥有明确的生命周期,主要包含以下三个阶段:
- 内存分配:在声明变量或定义函数时,系统自动为其分配相应的内存空间。
- 内存使用:对已分配的内存进行读写操作,即访问并调用变量或执行函数。
- 释放内存:当内存不再被使用时,系统将其释放,归还给操作系统。
与C语言等底层语言不同,后者需要程序员手动通过malloc和free来申请或释放内存,JavaScript及大多数现代编程语言均实现了自动内存管理机制。即在变量不再使用时,系统会“自动”释放其占用的内存,这一过程被称为垃圾回收(Garbage Collection)。
任何程序的运行都依赖于内存空间。若某块内存在使用后未被正确释放,且这种未释放的状态持续累积,最终导致可用内存耗尽,程序将无法正常运行。其直观表现通常为程序卡死、响应停滞甚至系统崩溃,这种现象即被称为内存泄漏。
接下来,我们通过几个具体案例来直观说明内存泄漏的发生过程。
内存分配
const obj = {
name: '张三'
} // 给{name: '张三'}分配内存
function foo() {
console.log('hello world')
} // 给函数分配内存
let date = new Date(); // 根据函数返回的结果创建变量,会分配一个Date对象
可能发生内存泄漏
function foo() {
const obj = {name: '张三'}
window.obj = obj;
console.log(obj)
}
foo(); // foo()执行完毕,{name: '张三'}对应的内存空间本应该被释放,但是由于又被全局变量所引用,因此其对应的内存空间不会被垃圾回收
闭包的内存占用
function bar() {
const data = {}
return {
get(key) {
return data[key]
},
set(key, value) {
data[key] = value
}
}; // 闭包对象
}
const {get, set} = bar; // 结构
set('name', '张三')
get('name'); // 张三
// 函数执行完毕,data对象并不会被垃圾回收,这是闭包的机制
内存泄漏的概念往往较为抽象,为了更直观地观察其发生过程,我们需要借助工具进行监测。
怎么检测内存泄漏
内存泄漏的核心特征是内存使用量持续异常升高。需要注意的是,正常的内存增长(如页面加载初期)不应被误判为泄漏。排查内存泄漏,我们可以借助Chrome DevTools中的Performance(性能)和Memory(内存)选项。以下是一个具体示例:
我们新建一个memory.html的文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body {
text-align: center;
}
</style>
</head>
<body>
<p>检测内存变化</p>
<button id="btn">开始</button>
<script>
const arr = [];
// 数组中添加100万个数据
for (let i = 0; i < 100 * 10000; i++) {
arr.push(i)
}
function bind() {
const obj = {
str: JSON.stringify(arr) // 浅拷贝的方式创建一个比较大的字符串
}
// 每次调用bind函数,都在全局绑定一个onclick监听事件,不一定非要执行
// 使用绑定事件,主要是为了保持obj被全局标记
window.addEventListener('click', () => {
// 引用对象obj
console.log(obj);
})
}
let n = 0;
function start() {
setTimeout(() => {
bind(); // 调用bind函数
n++; // 循环次数增加
if (n < 50) {
start(); // 循环执行50次,注意这里并没有使用setInterval定时器
} else {
alert('done');
}
}, 200);
}
document.getElementById('btn').addEventListener('click', () => {
start();
})
</script>
</body>
</html>
在无法确定是否发生内存泄漏时,建议先使用Performance面板录制一段页面加载过程中的性能变化数据,以此初步判断是否存在内存泄漏风险。
Performance
本案例仅以Chrome浏览器为例进行描述,其他浏览器的操作界面可能存在细微差异。首先,通过鼠标右键点击“检查”或直接按下F12键进入DevTools页面,在顶部面板中选择Performance,选择后界面应如下所示:

在开始录制之前,建议先点击一下Collect garbage和clear按钮,以确保内存环境干净,排除其他遗留内存数据的干扰。随后,点击Record开始录制,同时点击页面上的开始按钮以触发代码执行。待代码执行完毕后,再次点击Record按钮停止录制。录制时长可能与代码实际执行时间略有出入,只要确保代码已完全执行即可。停止录制后,我们将得到如下结果:

Performance的内容很多,我们只需要关注内存的变化,由此图可见,内存这块区域的曲线是在一直升高的并且到达顶点后并没有回落,这就有可能发生了内存泄漏。因为正常的内存变化曲线应该是类似于“锯齿”,也就是有上有下,正常增长后会有一定的回落,但不一定回落到和初始值一样。而且我们还可以隐约看到程序运行结束后,内存从初始的6.2MB增加到了差不多351MB,这个数量级的增加还是挺明显的。我们只是执行了50次循环,如果执行的次数更多,将会耗尽浏览器的内存空间,导致页面卡死。
虽然已确认存在内存泄漏,但若想进一步定位泄漏发生的具体位置,仅靠Performance面板是不够的,此时我们需要切换到Memory面板进行深入分析。
Memory
DevTools的Memory选项主要用于录制堆内存快照,以便进一步分析内存泄漏的详细信息。有人可能会问,为何不直接使用Memory,而是先使用Performance?正如前文所述,内存增长并不必然意味着泄漏,它可能是正常的资源分配。若直接使用Memory面板分析,可能无法得出准确结论。
我们先来看一下如何使用Memory面板:

首先选择Memory选项,清除缓存,并在配置选项中勾选“堆内存快照”。每次点击录制按钮,面板都会记录当前的内存使用情况。我们可以在程序开始前点击一次以记录初始内存状态,代码结束后再点击一次记录最终状态,中间过程可根据需要点击。最终,在快照列表中至少可以得到两条内存记录:

初始内存数据暂不深究,我们选择列表中的最后一条记录,并在筛选下拉框中选择最后一个选项,即查看第一个快照与第二个快照之间的差异。

这里重点说明Shallow Size和Retained Size的区别:
- Shallow Size(浅层大小):对象自身占用的内存大小,字符串和数组的Shallow Size通常较大。
- Retained Size(保留大小):对象自身占用内存加上无法被垃圾回收器(GC)释放的内存总和。如果Retained Size与Shallow Size相差不大,基本可判定未发生内存泄漏;若两者相差悬殊,如上图中的
Object所示,则表明发生了内存泄漏。
进一步展开查看Object,在树形结构中可发现每个对象都绑定了一个全局事件,并占用了较大的内存空间。解决本案例涉及的内存泄漏相对简单,关键在于及时解除绑定的全局事件监听。

关于Performance和Memory面板的详细使用指南,可参考:手把手教你排查Javascript内存泄漏。
内存泄漏的场景
在大多数情况下,垃圾回收器会及时释放内存,一般不会发生内存泄漏。然而,某些特定场景是内存泄漏的高发区,开发时需格外注意:
开发中常使用console在控制台打印信息,但这会引发一个问题:被console引用的对象将无法被垃圾回收,从而导致内存泄漏。因此,生产环境中不建议使用console.log()的理由又多了一条。
被全局变量或全局函数引用的对象,若在Vue组件销毁时未清除引用,可能会导致内存泄漏。
// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
const arr = reactive([1,2,3]);
onMounted(() => {
window.arr = arr; // 被全局变量引用
window.arrFunc = () => {
console.log(arr); // 被全局函数引用
}
})
// 正确的方式
onBeforeUnmount(() => {
window.arr = null;
window.arrFunc = null;
})
</script>
定时器未在Vue组件销毁时及时清除,可能会导致内存泄漏。
// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
const arr = reactive([1,2,3]);
const timer = reactive(null);
onMounted(() => {
setInterval(() => {
console.log(arr); // arr被定时器占用,无法被垃圾回收
}, 200);
// 正确的方式
timer = setInterval(() => {
console.log(arr);
}, 200);
})
// 正确的方式
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
})
</script>
使用setTimeout和setInterval两个定时器时,均应注意是否需要清理定时器,特别是setInterval,务必确保在适当时机清除。
绑定的事件监听器未在Vue组件销毁时清除,可能会导致内存泄漏。
事件绑定在实际开发中十分常见,我们通常使用addEventListener方法来创建。
// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
const arr = reactive([1,2,3]);
const printArr = () => {
console.log(arr)
}
onMounted(() => {
// 监听事件绑定的函数为匿名函数,将无法被清除
window.addEventListener('click', () => {
console.log(arr); // 全局绑定的click事件,arr被引用,将无法被垃圾回收
})
// 正确的方式
window.addEventListener('click', printArr);
})
// 正确的方式
onBeforeUnmount(() => {
// 注意清除绑定事件需要前后是同一个函数,如果函数不同将不会清除
window.removeEventListener('click', printArr);
})
</script>
被自定义事件引用的对象,若在Vue组件销毁时未清除,可能会导致内存泄漏。
自定义事件通过emit/on机制来发起和监听。清除自定义事件与绑定事件类似,不同之处在于需要调用off方法。
// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
import event from './event.js'; // 自定义事件
const arr = reactive([1,2,3]);
const printArr = () => {
console.log(arr)
}
onMounted(() => {
// 使用匿名函数,会导致自定义事件无法被清除
event.on('printArr', () => {
console.log(arr)
})
// 正确的方式
event.on('printArr', printArr)
})
// 正确的方式
onBeforeUnmount(() => {
// 注意清除自定义事件需要前后是同一个函数,如果函数不同将不会清除
event.off('printArr', printArr)
})
</script>
除了及时清除监听器和事件外,对于全局变量的引用,我们可以选择使用WeakMap、WeakSet等弱引用数据类型。这样,即使引用的对象数据即将被垃圾回收,弱引用的全局变量也不会阻止GC的执行。
垃圾回收算法
了解了内存泄漏的含义及检测方法,我们甚至能在一定程度上规避内存泄漏。那么,JavaScript采用何种机制来确保垃圾尽可能被回收呢?早期的垃圾回收算法使用的是引用计数法,而目前主流浏览器均采用标记清除法。
引用计数
当我们创建一个对象时,JavaScript会在堆内存中分配一块区域存储对象信息,并在栈内存中保存该对象数据的引用地址。举例如下:
let obj = {name: '张三'};
let obj2 = obj;
// 对象数据{name: '张三'}被obj和obj2引用,引用计数为2,此时{name: '张三'}不能被垃圾回收
obj = 0; // obj虽然不引用{name: '张三'},但是obj2还在引用,此时{name: '张三'}也不能被垃圾回收
obj2 = 0; // 此时的{name: '张三'}已经是零引用了,可以被垃圾回收
下图展示了创建变量时的内存管理情况:

对于函数而言,正常情况下,函数执行完毕后,其占用的内存会被垃圾回收器回收:
function foo() {
const obj = {name: '张三'}
console.log(obj)
}
foo(); // 函数执行完毕,obj作为局部变量,会随着函数的结束而结束,{name: '张三'}由于零引用,其占用的内存空间会被释放
然而,如果函数内部存在全局引用,那么函数结束后,该全局引用占用的内存将无法被释放。
function foo() {
const obj = {name: '张三'}
window.obj = obj;
console.log(obj)
}
foo(); // 函数执行完毕,{name: '张三'}被全局引用
引用计数法存在一个显著缺陷,即无法处理循环引用问题。
循环引用
循环引用指的是两个或多个对象之间存在相互引用,形成一个闭环。在函数内部,函数运行结束后本应释放所有局部变量引用的对象,但按照引用计数算法,循环引用间的引用计数不为零,因此它们不会被释放。举例如下:
function foo() {
const obj = {name: '张三'};
const obj2 = {age: 0};
obj.a = obj2; // obj对象引用了obj2
obj2.a = obj; // obj2引用了obj
}
foo(); // 由于存在循环引用,{name: '张三'}和{age: 0}所占用的堆内存都不会被释放
上述例子可能较为抽象,我们再来看一个实际案例。在IE6、7版本中(IE已于2022年6月15日正式退出历史舞台),在对DOM对象进行垃圾回收时,就可能因循环引用导致内存泄漏。
let div = document.getElementById('div1');
div.a = div; // 循环引用自己
div.someBigData = new Array(10000).fill('*');
在上述例子中,div对象的a属性引用了div对象自身,构成了最简单的循环引用,且该属性未被移除或显式设置为null。对于引用计数器而言,这是一个有效的引用。因此,div对象将一直驻留在内存中,即使从DOM树中删除了div1,且div对象的someBigData所引用的数据也将一直保留在内存中,不会被释放。若div对象本身或其属性引用的数据体积庞大,持续累积将导致内存泄漏。
标记清除
标记清除算法是对引用计数算法的改进。如果说引用计数法判断的是对象是否“不再需要”,那么标记清除法判断的是对象是否“可获得”。可以获得的对象将保留在内存中,不可获得的对象则被垃圾回收。
垃圾回收并非实时进行。使用标记清除算法的垃圾回收器会定期从根对象(在JavaScript中即window对象)开始,找出所有从根引用的对象,以及这些对象所引用的对象,直到遍历完所有可达对象。垃圾回收器从而收集到所有可获得和不可获得的对象,并将不可获得的对象进行回收。
使用标记清除算法可以有效解决引用计数算法中的循环引用问题。还是刚才那个例子:
function foo() {
const obj = {name: '张三'};
const obj2 = {age: 0};
obj.a = obj2; // obj对象引用了obj2
obj2.a = obj; // obj2引用了obj
}
foo();
foo()函数执行完毕后,垃圾回收器从window对象开始查找,发现obj和obj2是不可获得的对象,因此它们引用的数据将被回收。我们简单修改一下代码:
function foo() {
const obj = {name: '张三'};
const obj2 = {age: 0};
obj.a = obj2; // obj对象引用了obj2
obj2.a = obj; // obj2引用了obj
window.obj = obj;
}
foo();
console.log(window.obj)
foo()函数执行完毕后,垃圾回收器从window对象开始查找,发现可以通过window对象找到obj属性,obj和obj2存在循环引用,最终obj和obj2引用的数据都不会被垃圾回收。

这个例子也提醒我们,使用全局变量时需特别注意是否可能造成内存泄漏,详细情况可查看内存泄漏的场景部分。使用标记清除算法已基本能满足垃圾回收的需求,且自2012年起,现代主流浏览器的垃圾回收器均已实现标记清除算法,后续的改进也是基于该算法进行的。
闭包是内存泄漏吗
使用过闭包的开发者都知道,闭包中的数据会保留在内存中,不会被回收。那么闭包是否属于内存泄漏?答案是否定的。因为内存泄漏指的是不符合预期的内存持续增长,而闭包虽然占用内存不释放,但这符合我们的预期设计。
闭包不属于内存泄漏,是否就可以随意使用了?答案显然是否定的。如果闭包中的数据量巨大,仍会消耗大量内存,导致网页卡顿甚至崩溃。因此,我们不能滥用闭包,在闭包函数退出前,应清除不必要的局部变量。
总结
本文详细解释了什么是内存泄漏、如何检测泄漏、浏览器的垃圾回收机制以及常见的内存泄漏场景。总体而言,内存泄漏是指发生非预期的内存占用持续增长,直至耗尽内存,导致系统崩溃。在实际开发中,内存泄漏较为常见,编写代码时应留意高发场景,尽可能在编码阶段避免潜在的内存泄漏,而非等到系统崩溃后再寄希望于“重启(刷新)大法”。
以上就是一文搞懂JavaScript中的内存泄露的详细内容,更多关于JavaScript内存泄露的资料请关注本站其它相关文章!
JS动态构建复杂嵌套JSON对象的方法
2026-11-05 15:41:52
JS负数取余为什么是负数?详解余数与模运算区别
2026-11-05 13:34:41
JS 解压出现乱码或报错怎么解决?
2026-11-05 12:32:38
JS 中 startsWith、indexOf 与正则判断字符串前缀的对比
2026-11-04 22:19:43
JS解析Excel后怎么将数据转换为JSON格式
2026-11-04 20:58:28
