进阶篇:内存泄漏

内存泄漏是指:程序运行过程中,已经动态分配的堆内存,由于代码逻辑缺陷、引用未释放、生命周期未终结等原因,导致程序不再使用但 GC 无法回收的内存资源浪费。

内存泄漏不会立刻引发报错,而是持续累积,随着页面运行时长、用户操作次数增加,内存占用不断攀升,最终造成页面卡顿、交互延迟、页面闪退、浏览器崩溃等严重问题。

核心特征:

  • 隐蔽性:不同于语法错误、运行时报错,内存泄漏无显性报错,只能通过性能监控、内存快照才能发现。
  • 积累性:单次泄漏内存体量小,不易感知;频繁重复操作后,泄漏内存持续叠加,最终暴露性能问题。
  • 后置危害性:初期不影响功能,长期运行后导致页面帧率下降、GC 频繁卡顿、内存溢出崩溃。

JavaScript 内存管理机制

底层语言(C、C++)需要开发者手动调用 mallocfree 完成内存分配与释放。而 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',禁止隐式全局变量;所有变量显式声明。

遗忘的定时器

setTimeoutsetInterval 由浏览器独立定时器线程维护,生命周期不依附当前页面与组件。若定时器回调持有页面变量、DOM 引用,页面销毁后定时器未清除,会持续锁定内存,造成严重累积泄漏。

// 页面销毁后定时器仍在运行,内存无法释放
setInterval(function() {
    const dom = document.getElementById('box')
    if(dom) dom.innerText = "持续更新数据"
}, 1000)

解决方案:页面卸载、组件销毁时,手动执行 clearTimeoutclearInterval 清空定时器。

未销毁的事件监听

通过 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 性能面板(初步筛查)

用于快速判断页面是否存在内存泄漏,观察整体内存走势。

操作步骤

  1. 打开开发者工具,进入 Performance 面板;
  2. 顶部勾选「内存」选项,开启内存监控曲线;
  3. 录制前手动点击 GC 垃圾回收,清空初始内存;
  4. 模拟用户连续操作页面,复现业务场景;
  5. 停止录制,查看 JS 堆内存曲线走势。

泄漏判定标准

  • 正常内存:操作结束后内存回落、趋于平稳;
  • 泄漏内存:反复操作后内存阶梯式持续上涨、不回落

e6c9d24ely1h0hai55x5kj211c0jn77f.jpg

Memory 内存面板(精准定位泄漏)

通过堆快照对比,精准定位持续增长的对象、变量、DOM 节点,定位泄漏源码位置。

操作步骤

  1. 进入 Memory 面板,选择「堆快照」;
  2. 手动 GC 后拍摄快照 1(初始状态)
  3. 执行疑似泄漏的页面操作;
  4. 再次 GC,拍摄快照 2(操作后状态);5. 切换对比模式,查看「增量」列,定位新增量大、持续累积的对象。

排查优化建议:使用生产打包代码、关闭浏览器插件、关闭调试工具,避免开发环境内存干扰。

强引用与弱引用(内存优化核心)

GC 回收的唯一依据:对象是否存在有效强引用。根据引用特性,JS 分为强引用和弱引用。

强引用(默认引用方式)

普通变量赋值、对象属性挂载均为强引用。只要存在强引用,对象永远不会被 GC 回收,是绝大多数内存泄漏的根源。

let obj = { a: 1 }
// 必须手动置空,才能解除引用、释放内存
obj = null

弱引用(ES6 防泄漏方案)

弱引用不会计入对象引用计数,不会阻碍垃圾回收。当对象仅存在弱引用时,GC 会自动回收该对象内存。

ES6 提供两个弱引用结构:WeakMap、WeakSet

核心特性

  • 仅支持对象作为键名,不支持基本类型;
  • 引用不计入 GC 计数,不阻碍内存回收;
  • 不可遍历、无 size、无 keysvalues 方法,规避回收不确定性。 ```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 均不支持或已移除此特性。

尾递归

尾递归是尾调用的特殊场景:函数最后一步调用自身

普通递归会堆叠海量栈帧,极易造成栈溢出、内存爆满;尾递归全程只存在一个栈帧,彻底解决递归内存溢出问题。

内存泄漏与内存溢出区别

内存泄漏:内存持续无法释放、缓慢累积,属于慢性性能问题,长期运行导致页面越来越卡。

内存溢出:程序所需内存超过系统分配的最大可用内存,直接触发浏览器报错、页面崩溃,属于急性故障

长期、大量的内存泄漏,是导致内存溢出的核心诱因

参考资料

MDN - 内存管理

© lizhao all right reserved,powered by Gitbook文件修订时间: 2026-08-23 17:00:30

results matching ""

    No results matching ""