1. 什么是内存泄漏
内存泄漏不是简单的"内存占用高",而是:
text
某个操作结束后,本该释放的对象仍然被引用。
重复执行该操作后,JS heap、DOM nodes、event listeners 持续上涨。
即使手动触发 GC,也无法回到接近初始值。
典型表现:
- 页面越用越卡
- 切换页面后内存不下降
- 弹窗打开关闭多次后 DOM 节点越来越多
- 列表滚动越久越卡
- 定时器、事件监听、Observer、WebSocket 没有释放
2. 常见内存泄漏例子和修复
2.1 定时器没有清理
问题代码:
js
function mount() {
setInterval(() => {
console.log('polling')
}, 1000)
}
组件卸载后,定时器还在运行,闭包里的变量也不会释放。
修复:
js
let timer = null
function mount() {
timer = setInterval(() => {
console.log('polling')
}, 1000)
}
function unmount() {
clearInterval(timer)
timer = null
}
React 写法:
tsx
useEffect(() => {
const timer = setInterval(fetchData, 1000)
return () => {
clearInterval(timer)
}
}, [])
2.2 事件监听没有移除
问题代码:
js
function mount() {
window.addEventListener('resize', () => {
console.log('resize')
})
}
匿名函数没有引用,后续无法 removeEventListener。
修复:
js
function onResize() {
console.log('resize')
}
function mount() {
window.addEventListener('resize', onResize)
}
function unmount() {
window.removeEventListener('resize', onResize)
}
React 写法:
tsx
useEffect(() => {
function onResize() {
console.log(window.innerWidth)
}
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
}
}, [])
2.3 DOM 被移除,但 JS 仍然引用
问题代码:
js
let cachedNode = null
function createAndRemove() {
const div = document.createElement('div')
div.innerHTML = '<span>hello</span>'
document.body.appendChild(div)
document.body.removeChild(div)
cachedNode = div
}
div 已经不在页面 DOM 树中,但 cachedNode 仍然引用它,所以不能被 GC。
修复:
js
cachedNode = null
真实项目中常见来源:
- 弹窗管理器缓存了弹窗实例
- 全局 Map 缓存了 DOM
- 事件回调闭包引用了 DOM
- 第三方图表库实例没有 destroy
- Observer 没有 disconnect
2.4 Observer 没有释放
问题代码:
js
const observer = new ResizeObserver(() => {
console.log('resize')
})
function mount(el) {
observer.observe(el)
}
修复:
js
let observer = null
function mount(el) {
observer = new ResizeObserver(() => {
console.log('resize')
})
observer.observe(el)
}
function unmount() {
observer.disconnect()
observer = null
}
类似需要清理的对象:
text
MutationObserver.disconnect()
ResizeObserver.disconnect()
IntersectionObserver.disconnect()
WebSocket.close()
AbortController.abort()
subscription.unsubscribe()
chart.destroy()
3. Chrome 排查总流程
实际排查时不要一上来就看 Heap Snapshot。推荐顺序:
text
1. Performance monitor 看实时趋势
2. Performance 面板录制完整操作路径
3. Memory 面板拍 Heap Snapshot 做对比
4. 搜索 Detached DOM 或业务组件名
5. 查看 Retainers 引用链
6. 修复代码
7. 用同样路径复测
判断泄漏的关键指标:
text
JS heap size
DOM Nodes
JS event listeners
Documents
Frames
4. Chrome Task Manager 快速看页面内存
Chrome Task Manager 是最轻量的入口,适合先判断某个 Tab 是否内存异常。
打开方式
- 在 Chrome 中按
Shift + Esc - 或者点击 Chrome 右上角菜单
- 选择
More tools - 选择
Task manager - 在表头右键,勾选
JavaScript memory
截图

看什么
重点看两列:
text
Memory footprint:
浏览器进程层面的内存,DOM 节点也会占用这里的内存。
JavaScript Memory:
JS heap 占用。
括号里的 live 值更重要,表示当前可达对象占用的内存。
使用例子
排查"后台页面越用越卡":
- 打开目标页面
- 打开 Task Manager
- 勾选
JavaScript memory - 记录初始值
- 重复操作:打开弹窗 -> 关闭弹窗,执行 20 次
- 看
JavaScript Memory的 live 值是否持续上涨
如果 live 值从 50 MB 涨到 120 MB,并且等待一段时间也不下降,就需要继续用 Memory 面板定位。
5. Performance Monitor 实时监控
Performance monitor 适合实时观察页面运行时指标,它能快速告诉你"有没有增长趋势"。
打开方式
- 打开 DevTools
- 按
Command + Shift + P - Windows / Linux 是
Control + Shift + P - 输入
Performance monitor - 选择
Show Performance monitor
或者:
text
DevTools 右上角三个点
-> More tools
-> Performance monitor
截图
指标说明
text
CPU usage:
CPU 使用率。持续偏高说明页面有重计算、死循环、频繁渲染等问题。
JS heap size:
JS 堆内存。重复操作后持续上涨,可能存在 JS 对象泄漏。
DOM Nodes:
页面 DOM 节点数量。打开关闭弹窗后不下降,常见于 Detached DOM 泄漏。
JS event listeners:
事件监听数量。页面切换或弹窗关闭后不下降,说明监听器可能没有移除。
Documents:
文档数量。SPA 路由切换后持续上涨,需要警惕 iframe、document 引用泄漏。
Frames:
frame 数量。iframe 创建后没有销毁时会增长。
Layouts / sec:
每秒布局次数。频繁上涨说明可能存在 layout thrashing。
Style recalcs / sec:
每秒样式重算次数。频繁上涨说明样式变更或 DOM 查询可能过多。
使用例子:弹窗泄漏
操作步骤:
- 打开 Performance monitor
- 记录初始值:
text
JS heap size: 45 MB
DOM Nodes: 3000
JS event listeners: 800
- 重复操作 20 次:
text
打开用户详情弹窗
关闭用户详情弹窗
- 观察最终值:
text
JS heap size: 95 MB
DOM Nodes: 9000
JS event listeners: 2200
判断:
text
DOM Nodes 上涨:弹窗 DOM 或子组件可能没有释放。
JS event listeners 上涨:弹窗内部事件监听可能没有 remove。
JS heap size 上涨:组件实例、数据缓存、闭包可能还被引用。
下一步进入 Memory -> Heap snapshot 定位具体对象。
6. Performance 面板录制内存曲线
Performance 面板适合看"一次完整用户流程"的内存变化。
打开方式
- 打开 DevTools
- 进入
Performance面板 - 勾选
Memory - 点击录制按钮
- 点击垃圾桶/扫帚按钮,手动触发 GC
- 执行业务操作
- 再次手动 GC
- 停止录制
截图

看什么
录制结果里重点看:
text
JS Heap
Documents
Nodes
Listeners
GPU memory
正常曲线
text
打开弹窗时上涨
关闭弹窗后下降
手动 GC 后回到接近初始值
异常曲线
text
每次打开弹窗都上涨
关闭后不下降
手动 GC 后仍然比初始值高很多
使用例子:路由切换泄漏
测试路径:
text
/user/list -> /user/detail -> /user/list
重复 10 次
观察:
text
初始 DOM Nodes: 2500
切换 10 次后 DOM Nodes: 8000
手动 GC 后 DOM Nodes: 7800
结论:
text
详情页卸载后,DOM 或组件实例没有释放。
常见原因是事件监听、全局 store、缓存 Map、第三方组件实例仍然引用详情页对象。
7. Memory 面板:Heap Snapshot 定位泄漏对象
Heap Snapshot 是定位内存泄漏最重要的工具。
它能回答两个问题:
text
1. 哪些对象没有释放?
2. 是谁还引用着它?
打开方式
- 打开 DevTools
- 进入
Memory面板 - 选择
Heap snapshot - 点击
Take snapshot
截图

推荐操作流程
- 刷新页面,进入稳定状态
- 手动 GC
- 拍
Snapshot 1 - 执行可疑操作,比如打开关闭弹窗 20 次
- 手动 GC
- 拍
Snapshot 2 - 选择
Snapshot 2 - 视图切到
Comparison - 和
Snapshot 1对比
Comparison 重点字段
text
# New:
新增对象数量
# Deleted:
删除对象数量
Delta:
净增长数量
Alloc. Size:
新增对象占用内存
Freed Size:
释放对象占用内存
Size Delta:
净增长内存
使用例子
你发现:
text
Detached HTMLDivElement +400
UserDetailModal +20
ResizeObserver +20
Array +1000
说明:
text
Detached HTMLDivElement:
DOM 已经离开页面,但仍然被 JS 引用。
UserDetailModal:
弹窗组件实例没有释放。
ResizeObserver:
弹窗里的 Observer 没有 disconnect。
8. 查 Detached DOM
Detached DOM 是最常见的前端内存泄漏之一。
什么是 Detached DOM
text
DOM 节点已经从页面 DOM 树中移除,
但是 JavaScript 仍然持有这个节点的引用,
所以浏览器无法回收它。
截图

排查步骤
- 打开
Memory - 选择
Heap snapshot - 拍第一张快照
- 执行可疑操作
- 手动 GC
- 拍第二张快照
- 在
Class filter输入:
text
Detached
- 查看是否有:
text
Detached HTMLDivElement
Detached HTMLUListElement
Detached HTMLLIElement
Detached HTMLElement
- 点开对象
- 看下方面板的
Retainers
Retainers 怎么看
Retainers 表示"是谁引用了这个对象"。
例子:
text
Window
-> modalService
-> activeModals
-> UserDetailModal
-> rootElement
-> Detached HTMLDivElement
解释:
text
Window 上有一个 modalService。
modalService.activeModals 保存了 UserDetailModal。
UserDetailModal.rootElement 引用了已经被移除的 DOM。
所以这个 DOM 无法释放。
修复方向:
js
class ModalService {
activeModals = new Set()
open(modal) {
this.activeModals.add(modal)
}
close(modal) {
modal.destroy()
this.activeModals.delete(modal)
}
}
9. Memory 面板:Allocations on timeline
Allocations on timeline 适合查"某段操作期间创建了哪些对象,并且哪些对象最后还活着"。
打开方式
- 打开 DevTools
- 进入
Memory - 选择
Allocations on timeline - 点击
Start - 执行业务操作
- 点击
Stop
怎么看
text
蓝色柱子:
分配后最终仍然存活,重点怀疑。
灰色柱子:
分配过,但已经被 GC,通常不是泄漏。
使用例子
操作:
text
打开弹窗 -> 关闭弹窗
重复 10 次
结果发现蓝色对象集中在:
text
ModalComponent
ChartInstance
ResizeObserver
HTMLDivElement
说明这些对象在操作结束后仍然存活,需要继续看 Retainers。
10. Memory 面板:Allocation sampling
Allocation sampling 适合查"哪些函数分配了最多内存"。
它不一定直接证明泄漏,但能帮助定位高频创建对象的位置。
打开方式
- 打开 DevTools
- 进入
Memory - 选择
Allocation sampling - 点击
Start - 执行操作
- 点击
Stop - 查看
Heavy或Bottom-up
使用例子
结果:
text
createTableRows 80 MB
renderVirtualList 40 MB
parseLargeJson 30 MB
排查方向:
text
是否每次 render 都创建大数组
是否列表没有虚拟滚动
是否数据被重复 clone
是否缓存没有上限
是否闭包保留了旧数据
11. Rendering 面板介绍
Rendering 面板不是专门查内存泄漏的,但它能帮助发现重绘、布局偏移、合成层、滚动性能问题。
内存泄漏经常伴随:
text
DOM Nodes 上涨
页面重绘区域变大
滚动越来越卡
FPS 下降
GPU memory 升高
打开方式
- 打开 DevTools
- 按
Command + Shift + P - Windows / Linux 是
Control + Shift + P - 输入
Rendering - 选择
Show Rendering
或者:
text
DevTools 右上角三个点
-> More tools
-> Rendering
12. Rendering:Paint flashing
作用
开启后,页面发生重绘的区域会闪绿色。
截图
使用步骤
- 打开
Rendering - 勾选
Paint flashing - 滚动页面、移动鼠标、打开弹窗、切换 tab
- 观察哪些区域闪绿色
判断
正常:
text
只有变化区域闪绿。
异常:
text
鼠标移动一下,整个页面都闪绿。
滚动列表时,大面积区域反复重绘。
常见原因:
text
频繁修改 width / height / top / left
动画没有使用 transform / opacity
滚动时触发大量 DOM 样式更新
页面有巨大的 repaint 区域
优化例子:
css
/* 不推荐:容易触发布局和重绘 */
.box {
left: 100px;
}
/* 推荐:更容易走合成 */
.box {
transform: translateX(100px);
}
13. Rendering:Layout Shift Regions
作用
高亮发生布局偏移的区域,通常显示为紫色。
截图
使用步骤
- 打开
Rendering - 勾选
Layout Shift Regions - 刷新页面
- 观察哪些区域发生紫色高亮
使用例子
问题:
text
页面加载时,图片后加载,下面的按钮突然下移。
修复:
html
<img src="banner.png" width="600" height="300" />
或者:
css
.banner {
aspect-ratio: 2 / 1;
}
这样图片加载前就预留空间,减少布局偏移。
14. Rendering:Frame rendering stats
作用
在页面右上角显示实时帧率、掉帧、GPU raster、GPU memory。
截图

使用步骤
- 打开
Rendering - 勾选
Frame rendering stats - 滚动页面或执行动画
- 观察右上角浮层
怎么看
text
FPS:
越接近 60 越流畅。
Dropped frames:
掉帧多说明主线程、渲染、合成压力大。
GPU memory:
持续上涨可能说明图层、canvas、图片、视频资源占用过高。
使用例子
问题:
text
长列表滚动时 FPS 从 60 降到 20。
结合 Performance monitor 观察:
text
DOM Nodes: 50000
Layouts / sec: 持续很高
Style recalcs / sec: 持续很高
结论:
text
列表 DOM 太多,并且滚动过程中触发了频繁布局和样式重算。
修复方向:
text
使用虚拟列表
减少滚动事件里的同步 DOM 查询
避免滚动时 setState 更新大量组件
使用 passive listener
15. Rendering:Layer borders
作用
显示页面合成层和 tile 边界,帮助排查图层过多、GPU memory 高的问题。
使用步骤
- 打开
Rendering - 勾选
Layer borders - 观察页面上出现的边界线
常见问题
text
大量元素使用 transform
大量元素使用 will-change
大量 fixed / sticky 元素
复杂动画让图层数量暴涨
错误例子:
css
.card {
will-change: transform;
}
如果页面上有几百个 .card,长期保留 will-change 会造成额外内存和合成压力。
更合理:
css
.card {
will-change: auto;
}
.card.is-animating {
will-change: transform;
}
16. Rendering:Scrolling Performance Issues
作用
高亮可能影响滚动性能的元素,比如阻塞滚动的事件监听。
使用步骤
- 打开
Rendering - 勾选
Scrolling Performance Issues - 滚动页面
- 看页面是否出现高亮区域
常见修复
如果事件监听不会调用 preventDefault,加上 passive: true:
js
window.addEventListener('touchmove', onTouchMove, {
passive: true,
})
滚动事件中避免同步读写 DOM:
js
// 不推荐
window.addEventListener('scroll', () => {
const top = box.getBoundingClientRect().top
box.style.width = `${top}px`
})
更好的方式:
js
let ticking = false
window.addEventListener('scroll', () => {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
update()
ticking = false
})
}, { passive: true })
17. 完整案例:弹窗内存泄漏
问题现象
text
用户详情弹窗打开关闭多次后,页面越来越卡。
第一步:Performance monitor 看趋势
初始:
text
JS heap size: 45 MB
DOM Nodes: 3000
JS event listeners: 800
打开关闭弹窗 20 次后:
text
JS heap size: 95 MB
DOM Nodes: 9000
JS event listeners: 2200
初步判断:
text
DOM、事件监听、组件实例都有泄漏嫌疑。
第二步:Heap Snapshot 对比
操作:
text
Snapshot 1: 初始状态
Snapshot 2: 打开关闭弹窗 20 次后
视图切换到 Comparison
发现:
text
Detached HTMLDivElement +400
UserDetailModal +20
ResizeObserver +20
第三步:看 Retainers
引用链:
text
Window
-> modalService
-> activeModals
-> UserDetailModal
-> resizeObserver
-> rootElement
-> Detached HTMLDivElement
结论:
text
modalService.activeModals 没有删除弹窗实例。
ResizeObserver 没有 disconnect。
rootElement 指向已经被移除的 DOM。
第四步:修复代码
问题代码:
js
class UserDetailModal {
constructor(el) {
this.el = el
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(el)
window.addEventListener('resize', this.handleResize)
}
close() {
document.body.removeChild(this.el)
}
}
修复后:
js
class UserDetailModal {
constructor(el) {
this.el = el
this.handleResize = this.handleResize.bind(this)
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(el)
window.addEventListener('resize', this.handleResize)
}
close() {
this.resizeObserver.disconnect()
window.removeEventListener('resize', this.handleResize)
this.el.remove()
this.el = null
}
}
class ModalService {
constructor() {
this.activeModals = new Set()
}
open(modal) {
this.activeModals.add(modal)
}
close(modal) {
modal.close()
this.activeModals.delete(modal)
}
}
第五步:复测
重复同样操作 20 次后:
text
JS heap size: 50 MB
DOM Nodes: 3100
JS event listeners: 820
判断:
text
GC 后指标回到接近初始值,泄漏基本修复。