进阶篇:内存泄漏
内存泄漏是指:程序运行过程中,已经动态分配的堆内存,由于代码逻辑缺陷、引用未释放、生命周期未终结等原因,导致程序不再使用但 GC 无法回收的内存资源浪费。
内存泄漏不会立刻引发报错,而是持续累积,随着页面运行时长、用户操作次数增加,内存占用不断攀升,最终造成页面卡顿、交互延迟、页面闪退、浏览器崩溃等严重问题。
核心特征:
- 隐蔽性:不同于语法错误、运行时报错,内存泄漏无显性报错,只能通过性能监控、内存快照才能发现。
- 积累性:单次泄漏内存体量小,不易感知;频繁重复操作后,泄漏内存持续叠加,最终暴露性能问题。
- 后置危害性:初期不影响功能,长期运行后导致页面帧率下降、GC 频繁卡顿、内存溢出崩溃。
JavaScript 内存管理机制
底层语言(C、C++)需要开发者手动调用 malloc、free 完成内存分配与释放。而 JavaScript 是高级动态语言,采用自动内存管理机制:变量创建时自动分配内存,变量失效后由垃圾回收机制自动清理内存。
自动内存管理降低了开发成本,但也让开发者容易忽视内存引用关系,是 JS 内存泄漏问题的根源。
通用内存生命周期
任何程序的内存使用都遵循三段式生命周期:
- 内存分配:声明变量、创建对象、数组、函数时,系统自动分配栈内存或堆内存。
- 内存使用:程序对已分配内存进行读取、赋值、运算等读写操作。
- 内存释放:数据不再被程序使用,回收内存空间,归还系统资源。
栈内存与堆内存
栈内存(Stack):
- 存储内容:基本数据类型(String、Number、Boolean、Null、Undefined、Symbol、BigInt)、引用类型的内存地址指针。
- 内存特点:空间固定、结构简单、读写速度快。
- 回收机制:函数执行上下文销毁、变量出作用域后,栈内存自动立即释放,无内存泄漏风险。
堆内存(Heap):
- 存储内容:所有引用类型数据(Object、Array、Function、RegExp、Date 等)。
- 内存特点:空间动态可变、结构复杂、由引擎统一管理。
- 回收机制:不会自动释放,完全依赖垃圾回收算法扫描回收,是唯一会发生内存泄漏的内存区域。
浏览器垃圾回收机制(GC)
垃圾回收(Garbage Collection)是 JS 引擎的后台进程,会周期性自动执行,扫描堆内存中不再被程序引用的对象,释放其占用的内存。不同浏览器的 GC 执行时间间隔、触发策略各不相同。
主流 GC 算法分为两种:引用计数法、标记清除法。
引用计数算法(初代算法)
引擎为每一个堆内存对象维护一个引用计数器:
- 对象被新变量引用,引用计数 +1;
- 对象引用被断开(变量重置、销毁),引用计数 -1;
- 引用计数为 0 时,判定为垃圾对象,立即回收内存。
var o = { a: { b: 2 } } // 引用次数:1
var o2 = o // 引用次数:2
var oa = o2.a // 引用次数:3
o = 1 // 引用次数:2
o2 = "yo" // 引用次数:1
oa = null // 引用次数:0,内存可被回收
致命缺陷:无法解决循环引用。
若两个对象互相引用,外部无任何有效引用,引用计数永远无法归 0,GC 永远不会回收,造成永久性内存泄漏。
function fn () {
let obj1 = {}
let obj2 = {}
obj1.a = obj2
obj2.b = obj1
}
fn()
标记清除算法(现代浏览器主流)
为解决引用计数的循环引用漏洞,现代 V8 引擎全部采用标记清除算法。
执行流程:
- 标记阶段:从全局作用域、当前活动作用域出发,递归遍历所有可访问对象,标记为「存活对象」。
- 清除阶段:未被标记的对象判定为废弃垃圾,统一回收堆内存。
核心特点:
- 不依赖引用计数,彻底解决循环引用泄漏问题;
- 全局作用域变量会持续被标记为存活,仅页面关闭、刷新时才会销毁;
- GC 周期性执行,非实时回收。
常见内存泄漏场景
意外的全局变量
JS 中未使用 let/const/var 声明的变量,会自动挂载到 window 对象上,成为全局变量。全局变量生命周期跟随页面,不会随函数、组件销毁,长期占用堆内存。
function fn() {
// 隐式全局变量:window.dataList
dataList = new Array(1000000).fill(1)
}
解决方案:启用严格模式 'use strict',禁止隐式全局变量;所有变量显式声明。
遗忘的定时器
setTimeout、setInterval 由浏览器独立定时器线程维护,生命周期不依附当前页面与组件。若定时器回调持有页面变量、DOM 引用,页面销毁后定时器未清除,会持续锁定内存,造成严重累积泄漏。
// 页面销毁后定时器仍在运行,内存无法释放
setInterval(function() {
const dom = document.getElementById('box')
if(dom) dom.innerText = "持续更新数据"
}, 1000)
解决方案:页面卸载、组件销毁时,手动执行 clearTimeout、clearInterval 清空定时器。
未销毁的事件监听
通过 addEventListener 绑定的事件回调,会被浏览器事件线程持有引用。组件销毁、页面跳转时若不主动移除监听,回调函数及其引用的数据不会被 GC 回收。
window.addEventListener("resize", handleResize)
box.addEventListener("click", handleClick)
解决方案:页面、组件销毁生命周期中,执行 removeEventListener 解绑事件。
脱离 DOM 的 JS 残留引用
DOM 元素的回收条件:既不在 DOM 树中,也无 JS 变量引用。若仅从页面移除 DOM,但 JS 变量仍持有 DOM 引用,该 DOM 对象会永久滞留堆内存。
let dom = document.getElementById('box')
document.body.removeChild(dom)
// DOM 已从页面移除,但 dom 变量仍强引用 → 内存泄漏
解决方案:DOM 移除后手动置空:dom = null,解除强引用。
闭包滥用导致的内存常驻
闭包会保留其定义时的外层词法环境引用。如果闭包被长期持有(赋值给全局变量、挂载组件实例),外层函数的局部大数组、大数据对象会永久无法回收。
function fn () {
const bigData = new Array(100000).fill("大量缓存数据")
// 闭包常驻,锁定外层变量
return function () {
console.log(bigData)
}
}
const keepFn = fn()
解决方案:避免无效闭包、及时释放闭包引用、将重复函数抽离外层。
上线残留的 console.log
开发环境中,浏览器控制台会持有打印对象的引用,用于开发者后续查看详情。若上线未清理打印对象的 console.log,引用类型数据无法被 GC 回收,造成隐性内存泄漏。
Chrome 内存泄漏排查工具
Performance 性能面板(初步筛查)
用于快速判断页面是否存在内存泄漏,观察整体内存走势。
操作步骤:
- 打开开发者工具,进入 Performance 面板;
- 顶部勾选「内存」选项,开启内存监控曲线;
- 录制前手动点击 GC 垃圾回收,清空初始内存;
- 模拟用户连续操作页面,复现业务场景;
- 停止录制,查看 JS 堆内存曲线走势。
泄漏判定标准:
- 正常内存:操作结束后内存回落、趋于平稳;
- 泄漏内存:反复操作后内存阶梯式持续上涨、不回落。

Memory 内存面板(精准定位泄漏)
通过堆快照对比,精准定位持续增长的对象、变量、DOM 节点,定位泄漏源码位置。
操作步骤:
- 进入 Memory 面板,选择「堆快照」;
- 手动 GC 后拍摄快照 1(初始状态);
- 执行疑似泄漏的页面操作;
- 再次 GC,拍摄快照 2(操作后状态);5. 切换对比模式,查看「增量」列,定位新增量大、持续累积的对象。
排查优化建议:使用生产打包代码、关闭浏览器插件、关闭调试工具,避免开发环境内存干扰。

强引用与弱引用(内存优化核心)
GC 回收的唯一依据:对象是否存在有效强引用。根据引用特性,JS 分为强引用和弱引用。
强引用(默认引用方式)
普通变量赋值、对象属性挂载均为强引用。只要存在强引用,对象永远不会被 GC 回收,是绝大多数内存泄漏的根源。
let obj = { a: 1 }
// 必须手动置空,才能解除引用、释放内存
obj = null
弱引用(ES6 防泄漏方案)
弱引用不会计入对象引用计数,不会阻碍垃圾回收。当对象仅存在弱引用时,GC 会自动回收该对象内存。
ES6 提供两个弱引用结构:WeakMap、WeakSet。
核心特性:
- 仅支持对象作为键名,不支持基本类型;
- 引用不计入 GC 计数,不阻碍内存回收;
- 不可遍历、无 size、无
keys、values方法,规避回收不确定性。 ```javascript // Map 强引用:对象无法回收 let key1 = { name: "test" } const map = new Map() map.set(key1, 100) key1 = null // 对象仍被 Map 强引用,内存滞留
// WeakMap 弱引用:对象自动回收 let key2 = { name: "test" } const wm = new WeakMap() wm.set(key2, 200) key2 = null // 无强引用,对象自动被 GC 回收
**适用场景**:临时缓存、DOM 节点存储、组件数据挂载,优先使用 WeakMap、WeakSet。
## 尾调用与尾递归(执行栈内存优化)
尾调用是函数式编程的内存优化概念:**函数的最后一步操作,仅单纯调用另一个函数,无任何后续运算、赋值、表达式操作**。
```javascript
// 标准尾调用
function f(x){
return g(x)
}
// 非尾调用(存在后续运算)
function f1(x){
let y = g(x)
return y
}
function f2(x){
return g(x) + 1
}
尾调用优化原理
普通函数调用会保留当前函数的执行上下文,堆叠调用栈,占用大量栈内存。
尾调用作为函数最后一步执行逻辑,引擎可直接销毁当前函数执行栈帧,仅保留被调用函数栈帧,极大减少栈内存占用。
注意:ES6 尾调用优化仅严格模式生效,普通模式无效。
注意:目前主流浏览器中,只有 Safari 完整支持 JS 的尾调用优化(TCO),Chrome、Firefox、Edge 和 Node.js 均不支持或已移除此特性。
尾递归
尾递归是尾调用的特殊场景:函数最后一步调用自身。
普通递归会堆叠海量栈帧,极易造成栈溢出、内存爆满;尾递归全程只存在一个栈帧,彻底解决递归内存溢出问题。
内存泄漏与内存溢出区别
内存泄漏:内存持续无法释放、缓慢累积,属于慢性性能问题,长期运行导致页面越来越卡。
内存溢出:程序所需内存超过系统分配的最大可用内存,直接触发浏览器报错、页面崩溃,属于急性故障。
长期、大量的内存泄漏,是导致内存溢出的核心诱因。