一文带你了解如何排查内存泄漏导致的页面卡顿现象
不知道大家有没有被问到过这样一个问题:如果页面卡顿,你觉得可能是什么原因造成的?有什么办法锁定原因并解决吗?
这是一个很宽泛的问题,可能涉及网络请求、资源体积、主线程长任务、布局与绘制、渲染性能、GPU 资源以及内存问题。页面逐渐变慢、频繁暂停或最终崩溃,确实可能与内存泄漏有关,但卡顿不等于内存泄漏:一个页面也可能只是有长任务、内存使用过高(memory bloat)或频繁垃圾回收。
本文尽量保留原文的排查思路和案例,同时修正“栈中存基本类型、堆中存对象”“垃圾回收立即释放变量”“console.log 一定造成泄漏”等过度简化的说法。原文配图来自 Juejin/ByteDance CDN,已下载到同目录 images 文件夹;DevTools 界面和图表会随 Chrome 版本变化。
一、什么是内存泄漏
内存泄漏通常指:程序中仍然存在一条不必要的强引用路径,使本来已经不需要的对象继续保持可达,因而不能被垃圾回收器回收。它的表现通常是页面经过多次相同操作后,在垃圾回收之后的存活内存基线仍持续上升。
需要区分三个概念:
- 内存泄漏:不需要的对象因为意外的强引用长期存活;
- 内存膨胀:对象仍然有用,但占用的内存超过了产品和设备能够承受的范围;
- 频繁 GC:对象分配和回收过于频繁,导致执行被多次暂停,即使没有泄漏也可能卡顿。
JavaScript 使用自动内存管理。ECMAScript 不规定对象必须位于某个“栈”或“堆”,也不规定何时回收;现代引擎通常使用基于可达性的追踪式垃圾回收,并结合分代、增量、并发和并行等实现策略。开发者能够做的是解除不需要的强引用,不能依赖“函数返回后立即释放”或“把变量设为 null 就立刻释放”。
二、JavaScript 的内存模型应怎样理解
原文用“栈内存保存简单变量、堆内存保存复杂对象”帮助入门,但这不是 JavaScript 语言规范。更可靠的表述是:
- JavaScript 的值包括 primitive 和 Object;
- 对象具有身份,赋值和参数传递复制的是值,复制对象值后仍可能共享同一个对象;
- 引擎可以使用寄存器、栈槽、托管堆、外部内存或优化掉某些分配;
- 调用栈记录当前执行上下文,闭包则可能让词法绑定在函数返回后继续可观察;
- 垃圾回收判断的是从根和宿主句柄出发的可达性,不是简单查看某个函数是否已经返回。
原文的例子可以改写为可运行代码:
function createData() {
const value = {
name: '零一',
list: [1, 2, 3],
}
const number = 3
function logValue() {
console.log(value.name)
}
logValue()
return value
}
const result = createData()
console.log(result.name) // 零一

createData() 返回后,其执行上下文可以退出调用栈;但 result 仍然强引用返回的对象,所以这个对象仍然可达。number 和未逃逸的局部状态是否位于某个物理内存区域,是引擎实现和优化问题,不能从代码语义直接推断。

如果函数内部创建了一个只在函数中使用的对象,且没有被返回、全局变量、闭包、事件处理器、定时器或其他可达对象保存,那么它在后续 GC 中可能变成可回收对象:
function work() {
const temporary = { data: new Array(1000).fill(0) }
return temporary.data.length
}
console.log(work())
// temporary 没有逃逸到外部;何时回收由引擎决定

追踪式 GC 可以回收不可达的循环引用,因此“有循环引用就一定泄漏”也是过时结论:
function createCycle() {
const a = {}
const b = {}
a.other = b
b.other = a
// 函数返回后,如果外部没有保存 a 或 b,这个环整体都可能被回收
}
createCycle()

垃圾回收的现实边界
原文提到标记-清除、标记-整理和“交替执行”。可以保留这些名称,但应补充:
- ECMAScript 不规定某一种 GC 算法;
- 现代引擎通常以追踪式 GC 为基础,具体会使用年轻代复制、标记、清扫、选择性压缩等策略;
- 增量 GC 会把工作切成多个片段,并发 GC 会让辅助线程在 JavaScript 继续运行时工作,并行 GC 则可能让多个线程协作但暂停 JavaScript;
- GC 可能暂停脚本执行,但并不是每次回收都产生相同长度的暂停;
- 被判定为可回收不代表已经立刻回收,也不代表进程 RSS 会马上下降;
- JavaScript 没有标准的“立即执行 GC”接口。Node.js 的
--expose-gc只适合调试,不应作为业务逻辑依赖。
三、Chrome DevTools 中如何排查
排查内存问题不要只看一次快照的数字,而要设计一组可以重复的操作:加载页面、执行操作、等待稳定、重复操作,并观察 GC 后的存活基线和 retaining path。
1. 先排除其他卡顿原因
可以先在 Performance 面板确认:
- 是否存在超过一帧或更长的 JavaScript 长任务;
- 是否频繁触发强制布局、重排和重绘;
- 网络请求、脚本解析和资源解码是否阻塞;
- 是否有大量 DOM 更新或过重的渲染工作。
Chrome Task Manager 可以作为入口:
- Memory footprint 更接近进程/操作系统层面的内存,包括部分 DOM 和原生内存;
- JavaScript Memory 反映 JavaScript 堆相关数据,括号中的 live 数字更适合观察可达对象的趋势;
- 两者都不能单独证明存在泄漏,仍需要结合快照和保留路径分析。

2. 使用 Performance 记录趋势
打开 DevTools 的 Performance 面板,勾选 Memory 后录制一段可重复的用户操作。重点观察:
- JS heap、DOM nodes、Documents、Listeners 等计数是否随同一操作不断增长;
- 手动触发 GC 后,曲线是否回到相近的基线;
- 页面是否因为频繁分配和 GC 出现周期性暂停。
强制 GC 只适合调试观察。它能帮助建立基线,但一次 GC 不能证明“所有垃圾都已回收”,也不能代表生产环境的调度。


3. 使用 Memory 面板的 Heap Snapshot
Heap Snapshot 是某个时间点的对象图。建议至少拍两次:
- 页面初始稳定后拍 Snapshot 1;
- 重复执行同一操作若干次;
- 等待稳定或在调试环境触发 GC;
- 拍 Snapshot 2,并使用 Comparison 视图比较新增且仍存活的对象;
- 查看对象的 Retainers / retaining path,找到从全局、缓存、事件监听器、闭包或其他根保留它的路径。
对于 DOM 问题,可以在快照的 Class filter 中搜索 Detached,但“Detached”只说明节点已经脱离当前 DOM 树并仍被某处保留,最终原因仍要沿引用路径确认。



4. 使用 Allocation instrumentation 和 Allocation sampling
- Allocation instrumentation on timeline:适合观察一段操作期间创建的对象,以及这些对象是否在 GC 后仍然存活;
- Allocation sampling:适合按函数查看分配热点;
- 看到蓝色分配柱并不等于泄漏,关键是这些对象在后续 GC 后是否仍被保留。







四、常见泄漏场景与修复方式
1. 闭包、缓存和长期存活的引用
闭包不是泄漏本身。闭包只是让函数能够访问创建它时的词法绑定;当闭包本身被长期存活的对象保存时,它所引用的状态也可能继续存活。
function createHandler() {
const largeData = new Array(100_000).fill({ value: 1 })
return function handler() {
return largeData.length
}
}
const handlers = []
handlers.push(createHandler())
// 如果 handlers 无限增长,旧 handler 和 largeData 也会一直可达
修复方式不是盲目把局部变量设为 null,而是管理真正的所有者:在不需要时移除 handler、限制缓存大小、取消订阅或清空集合。
const handlers = new Set()
function addHandler(handler) {
handlers.add(handler)
return () => handlers.delete(handler)
}
const remove = addHandler(createHandler())
remove() // 解除长期引用

2. 意外的全局变量
在非严格模式下,给未声明的标识符赋值可能创建隐式全局变量;在严格模式、ES 模块和现代工程配置中通常会直接抛出 ReferenceError。不要依赖这种行为:
'use strict'
function createLeak() {
// leaked = new Array(10_000) // ReferenceError:leaked 未声明
}
createLeak()
正确做法是显式声明,并控制生命周期:
function createData() {
const data = new Array(10_000)
return data
}
let data = createData()
// 不再需要时解除外部绑定;何时回收仍由 GC 决定
data = null
如果确实需要全局状态,应明确写在 globalThis 或应用状态容器上,并设计清理策略,而不是让变量“泄漏”到全局。

3. 分离的 DOM 节点(Detached DOM)
DOM 节点从文档树移除后,如果 JavaScript、事件监听器、闭包、缓存或宿主对象仍然保留它,它就可能继续占用内存:
<div id="root">
<div class="child">我是子元素</div>
<button id="remove">移除</button>
</div>
<script>
const root = document.querySelector('#root')
const button = document.querySelector('#remove')
const child = root.querySelector('.child')
button.addEventListener('click', () => {
child.remove()
// 如果 child 仍被一个长期存活的变量、缓存或闭包引用,节点可能保持 detached
})
</script>
更好的做法是只在需要时取得节点,并在组件销毁时移除监听器或取消订阅:
const controller = new AbortController()
const button = document.querySelector('#remove')
button.addEventListener('click', () => {
const root = document.querySelector('#root')
const child = root?.querySelector('.child')
child?.remove()
}, { signal: controller.signal })
// 组件卸载时:
controller.abort()
AbortController 适合统一取消 DOM 事件、fetch 和其他支持 signal 的异步工作。不能简单地把“局部变量离开回调”当成绝对保证,仍需检查是否存在其他保留路径。

4. 控制台打印与调试器保留
原文把 console.log(obj) 直接定性为内存泄漏,这不够准确。浏览器 DevTools 可能为了在 Console 中展示对象而暂时保留对象,打开 DevTools、展开对象、断点和其他调试功能也可能改变可达图;具体行为依浏览器和 DevTools 版本而变。
因此:
- 开发调试时可以打印,但不要把巨大的对象、数组或完整响应长期打印;
- 发现内存基线异常时,先清空 Console、关闭 DevTools 或用无痕窗口重复测试;
- 生产代码应移除不必要的日志,或使用受控的日志级别;
- 即使关闭
console.log后内存下降,也要结合 Heap Snapshot 确认真正的 retaining path。
function debugData(data, isDevelopment) {
if (isDevelopment) {
console.debug(data)
}
}

5. 遗忘的定时器和动画帧
只要定时器仍然有效,它的回调就可能被定时器系统保留;如果回调闭包捕获了大对象,该对象也可能继续存活:
let timerId
function startTimer() {
const largeData = new Array(100_000).fill(0)
let count = 0
timerId = setInterval(() => {
// 使用 largeData
void largeData.length
count += 1
if (count >= 3) {
clearInterval(timerId)
timerId = undefined
}
}, 1000)
}
原文示例在 index === 3 时才清除并随后递增,实际可能比预期多执行一次;上面用 count >= 3 明确限制次数。组件卸载时应统一清除:
const timeoutId = setTimeout(() => {
console.log('done')
}, 1000)
const frameId = requestAnimationFrame(() => {
console.log('frame')
})
clearTimeout(timeoutId)
cancelAnimationFrame(frameId)
setTimeout、setInterval、requestAnimationFrame 的回调还可能被事件监听器、Promise 链或第三方库间接保留;排查时应沿 retaining path 检查所有者。

6. 事件监听器和订阅
单页应用中,页面或组件反复挂载时,如果每次都 addEventListener 却没有对应的清理,就可能积累回调和闭包:
function mount(element) {
const controller = new AbortController()
const onClick = () => {
console.log(element.dataset.id)
}
window.addEventListener('resize', onClick, {
signal: controller.signal,
})
return () => controller.abort()
}
const unmount = mount(document.querySelector('#root'))
unmount()
对于 EventEmitter、RxJS、WebSocket、观察者和自定义发布订阅,也要在生命周期结束时调用 off、unsubscribe、close 或对应的销毁方法。WeakMap 可以避免某些以对象为 key 的辅助数据结构阻止 key 被回收,但它不能替代清理事件监听器,也不能解决无界缓存。
五、一些容易误判的结论
- 把变量设为
null不会立即释放内存:它只是解除这条引用,只有对象从所有根和强引用路径都不可达后,才具备被回收的资格。 - 循环引用不必然泄漏:现代追踪式 GC 可以回收不可达的环。
- 堆快照数字一次变大不等于泄漏:分配器、缓存、字符串、图片、DOM 和 DevTools 自身都会影响数字。
- JS heap 不是全部内存:ArrayBuffer、WebAssembly、GPU、DOM 和浏览器原生对象可能属于其他内存统计。
WeakMap不是通用缓存:它只弱持有 key,不能枚举;使用普通Map做无界缓存时仍需淘汰策略。WeakRef和FinalizationRegistry不适合业务正确性:回收时机不确定,回调可能延迟甚至不执行,只适合非关键的优化清理。

六、排查清单
- 用真实设备和可重复操作确认是长任务、内存膨胀、频繁 GC 还是泄漏;
- 用 Task Manager 和 Performance 观察 JS heap、DOM nodes、listeners 及 GC 后基线;
- 用 Heap Snapshot Comparison 查找重复操作后仍存活的对象;
- 搜索
Detached,沿 retaining path 找到全局、闭包、监听器、定时器或缓存; - 对事件、订阅、定时器、动画帧和请求设计成对的清理流程;
- 对缓存设置容量、过期时间或淘汰策略,必要时使用
WeakMap; - 修复后重复同样的操作,确认对象数量和 GC 后基线不再持续上升;
- 记录浏览器、Node.js、设备和 DevTools 版本,因为内存实现和面板名称会变化。
虽然 JavaScript 的垃圾回收是自动的,但内存生命周期仍然是开发者的责任:不是手动“释放地址”,而是管理不需要的强引用、订阅和资源所有权。