内存泄漏排查和 Chrome DevTools 使用教程

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 是否内存异常。

打开方式

  1. 在 Chrome 中按 Shift + Esc
  2. 或者点击 Chrome 右上角菜单
  3. 选择 More tools
  4. 选择 Task manager
  5. 在表头右键,勾选 JavaScript memory

截图

看什么

重点看两列:

text 复制代码
Memory footprint:
  浏览器进程层面的内存,DOM 节点也会占用这里的内存。

JavaScript Memory:
  JS heap 占用。
  括号里的 live 值更重要,表示当前可达对象占用的内存。

使用例子

排查"后台页面越用越卡":

  1. 打开目标页面
  2. 打开 Task Manager
  3. 勾选 JavaScript memory
  4. 记录初始值
  5. 重复操作:打开弹窗 -> 关闭弹窗,执行 20 次
  6. JavaScript Memory 的 live 值是否持续上涨

如果 live 值从 50 MB 涨到 120 MB,并且等待一段时间也不下降,就需要继续用 Memory 面板定位。


5. Performance Monitor 实时监控

Performance monitor 适合实时观察页面运行时指标,它能快速告诉你"有没有增长趋势"。

打开方式

  1. 打开 DevTools
  2. Command + Shift + P
  3. Windows / Linux 是 Control + Shift + P
  4. 输入 Performance monitor
  5. 选择 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 查询可能过多。

使用例子:弹窗泄漏

操作步骤:

  1. 打开 Performance monitor
  2. 记录初始值:
text 复制代码
JS heap size: 45 MB
DOM Nodes: 3000
JS event listeners: 800
  1. 重复操作 20 次:
text 复制代码
打开用户详情弹窗
关闭用户详情弹窗
  1. 观察最终值:
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 面板适合看"一次完整用户流程"的内存变化。

打开方式

  1. 打开 DevTools
  2. 进入 Performance 面板
  3. 勾选 Memory
  4. 点击录制按钮
  5. 点击垃圾桶/扫帚按钮,手动触发 GC
  6. 执行业务操作
  7. 再次手动 GC
  8. 停止录制

截图

看什么

录制结果里重点看:

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. 是谁还引用着它?

打开方式

  1. 打开 DevTools
  2. 进入 Memory 面板
  3. 选择 Heap snapshot
  4. 点击 Take snapshot

截图

推荐操作流程

  1. 刷新页面,进入稳定状态
  2. 手动 GC
  3. Snapshot 1
  4. 执行可疑操作,比如打开关闭弹窗 20 次
  5. 手动 GC
  6. Snapshot 2
  7. 选择 Snapshot 2
  8. 视图切到 Comparison
  9. 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 仍然持有这个节点的引用,
所以浏览器无法回收它。

截图

排查步骤

  1. 打开 Memory
  2. 选择 Heap snapshot
  3. 拍第一张快照
  4. 执行可疑操作
  5. 手动 GC
  6. 拍第二张快照
  7. Class filter 输入:
text 复制代码
Detached
  1. 查看是否有:
text 复制代码
Detached HTMLDivElement
Detached HTMLUListElement
Detached HTMLLIElement
Detached HTMLElement
  1. 点开对象
  2. 看下方面板的 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 适合查"某段操作期间创建了哪些对象,并且哪些对象最后还活着"。

打开方式

  1. 打开 DevTools
  2. 进入 Memory
  3. 选择 Allocations on timeline
  4. 点击 Start
  5. 执行业务操作
  6. 点击 Stop

怎么看

text 复制代码
蓝色柱子:
  分配后最终仍然存活,重点怀疑。

灰色柱子:
  分配过,但已经被 GC,通常不是泄漏。

使用例子

操作:

text 复制代码
打开弹窗 -> 关闭弹窗
重复 10 次

结果发现蓝色对象集中在:

text 复制代码
ModalComponent
ChartInstance
ResizeObserver
HTMLDivElement

说明这些对象在操作结束后仍然存活,需要继续看 Retainers。


10. Memory 面板:Allocation sampling

Allocation sampling 适合查"哪些函数分配了最多内存"。

它不一定直接证明泄漏,但能帮助定位高频创建对象的位置。

打开方式

  1. 打开 DevTools
  2. 进入 Memory
  3. 选择 Allocation sampling
  4. 点击 Start
  5. 执行操作
  6. 点击 Stop
  7. 查看 HeavyBottom-up

使用例子

结果:

text 复制代码
createTableRows        80 MB
renderVirtualList      40 MB
parseLargeJson         30 MB

排查方向:

text 复制代码
是否每次 render 都创建大数组
是否列表没有虚拟滚动
是否数据被重复 clone
是否缓存没有上限
是否闭包保留了旧数据

11. Rendering 面板介绍

Rendering 面板不是专门查内存泄漏的,但它能帮助发现重绘、布局偏移、合成层、滚动性能问题。

内存泄漏经常伴随:

text 复制代码
DOM Nodes 上涨
页面重绘区域变大
滚动越来越卡
FPS 下降
GPU memory 升高

打开方式

  1. 打开 DevTools
  2. Command + Shift + P
  3. Windows / Linux 是 Control + Shift + P
  4. 输入 Rendering
  5. 选择 Show Rendering

或者:

text 复制代码
DevTools 右上角三个点
-> More tools
-> Rendering

12. Rendering:Paint flashing

作用

开启后,页面发生重绘的区域会闪绿色。

截图

使用步骤

  1. 打开 Rendering
  2. 勾选 Paint flashing
  3. 滚动页面、移动鼠标、打开弹窗、切换 tab
  4. 观察哪些区域闪绿色

判断

正常:

text 复制代码
只有变化区域闪绿。

异常:

text 复制代码
鼠标移动一下,整个页面都闪绿。
滚动列表时,大面积区域反复重绘。

常见原因:

text 复制代码
频繁修改 width / height / top / left
动画没有使用 transform / opacity
滚动时触发大量 DOM 样式更新
页面有巨大的 repaint 区域

优化例子:

css 复制代码
/* 不推荐:容易触发布局和重绘 */
.box {
  left: 100px;
}

/* 推荐:更容易走合成 */
.box {
  transform: translateX(100px);
}

13. Rendering:Layout Shift Regions

作用

高亮发生布局偏移的区域,通常显示为紫色。

截图

使用步骤

  1. 打开 Rendering
  2. 勾选 Layout Shift Regions
  3. 刷新页面
  4. 观察哪些区域发生紫色高亮

使用例子

问题:

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。

截图

使用步骤

  1. 打开 Rendering
  2. 勾选 Frame rendering stats
  3. 滚动页面或执行动画
  4. 观察右上角浮层

怎么看

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 高的问题。

使用步骤

  1. 打开 Rendering
  2. 勾选 Layer borders
  3. 观察页面上出现的边界线

常见问题

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

作用

高亮可能影响滚动性能的元素,比如阻塞滚动的事件监听。

使用步骤

  1. 打开 Rendering
  2. 勾选 Scrolling Performance Issues
  3. 滚动页面
  4. 看页面是否出现高亮区域

常见修复

如果事件监听不会调用 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 后指标回到接近初始值,泄漏基本修复。
相关推荐
KaifuZeng1 小时前
模电面试问题汇总一
单片机·嵌入式硬件·面试·模电
IT_陈寒2 小时前
Java线程池这个坑我算是踩明白了
前端·人工智能·后端
An_s2 小时前
rust开发wasm与浏览器(js)交互
javascript·rust·wasm
山河木马2 小时前
GPU自动处理专题4-填充覆盖区域(光栅化)
javascript·webgl·计算机图形学
悦儿遥遥雨13 小时前
PXE + Kickstart 无人值守批量部署系统
linux·javascript·nginx
31535669133 小时前
Superpowers:GPT-5.6 时代下的流程毒瘤
前端·后端
清秋3 小时前
全网最全 ECMAScript 攻略( 更新至 ES2026)
前端·javascript·编程语言
Cao_Ron3 小时前
用 SVG image symbol 画 ECharts graph 圆形节点
前端
hxy06014 小时前
React-Call让你的React组件像异步函数一样调用
前端·react.js