技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

一文带你了解如何排查内存泄漏导致的页面卡顿现象

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/71-一文带你了解如何排查内存泄漏导致的页面卡顿现象
本文目录7 个章节
  1. 一、什么是内存泄漏
  2. 二、JavaScript 的内存模型应怎样理解
  3. 三、Chrome DevTools 中如何排查
  4. 四、常见泄漏场景与修复方式
  5. 五、一些容易误判的结论
  6. 六、排查清单
  7. 参考资料

一文带你了解如何排查内存泄漏导致的页面卡顿现象

不知道大家有没有被问到过这样一个问题:如果页面卡顿,你觉得可能是什么原因造成的?有什么办法锁定原因并解决吗?

这是一个很宽泛的问题,可能涉及网络请求、资源体积、主线程长任务、布局与绘制、渲染性能、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 数字更适合观察可达对象的趋势;
  • 两者都不能单独证明存在泄漏,仍需要结合快照和保留路径分析。

Chrome Performance 面板中的内存曲线

2. 使用 Performance 记录趋势

打开 DevTools 的 Performance 面板,勾选 Memory 后录制一段可重复的用户操作。重点观察:

  • JS heap、DOM nodes、Documents、Listeners 等计数是否随同一操作不断增长;
  • 手动触发 GC 后,曲线是否回到相近的基线;
  • 页面是否因为频繁分配和 GC 出现周期性暂停。

强制 GC 只适合调试观察。它能帮助建立基线,但一次 GC 不能证明“所有垃圾都已回收”,也不能代表生产环境的调度。

Performance 记录中的节点和堆内存变化

Performance 录制示例

3. 使用 Memory 面板的 Heap Snapshot

Heap Snapshot 是某个时间点的对象图。建议至少拍两次:

  1. 页面初始稳定后拍 Snapshot 1;
  2. 重复执行同一操作若干次;
  3. 等待稳定或在调试环境触发 GC;
  4. 拍 Snapshot 2,并使用 Comparison 视图比较新增且仍存活的对象;
  5. 查看对象的 Retainers / retaining path,找到从全局、缓存、事件监听器、闭包或其他根保留它的路径。

对于 DOM 问题,可以在快照的 Class filter 中搜索 Detached,但“Detached”只说明节点已经脱离当前 DOM 树并仍被某处保留,最终原因仍要沿引用路径确认。

Heap Snapshot 入口

Heap Snapshot 示例

Heap Snapshot 的对象比较

4. 使用 Allocation instrumentation 和 Allocation sampling

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

Allocation Timeline 入口

Allocation Timeline 的分配柱

Allocation Timeline 的对象详情

Allocation Sampling 面板

Detached elements 分析

分配热点示例

Detached DOM 节点示例

四、常见泄漏场景与修复方式

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 的异步工作。不能简单地把“局部变量离开回调”当成绝对保证,仍需检查是否存在其他保留路径。

Detached DOM 的引用示意图

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)
  }
}

Console 对象保留的调试示意图

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)

setTimeoutsetIntervalrequestAnimationFrame 的回调还可能被事件监听器、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、观察者和自定义发布订阅,也要在生命周期结束时调用 offunsubscribeclose 或对应的销毁方法。WeakMap 可以避免某些以对象为 key 的辅助数据结构阻止 key 被回收,但它不能替代清理事件监听器,也不能解决无界缓存。

五、一些容易误判的结论

  1. 把变量设为 null 不会立即释放内存:它只是解除这条引用,只有对象从所有根和强引用路径都不可达后,才具备被回收的资格。
  2. 循环引用不必然泄漏:现代追踪式 GC 可以回收不可达的环。
  3. 堆快照数字一次变大不等于泄漏:分配器、缓存、字符串、图片、DOM 和 DevTools 自身都会影响数字。
  4. JS heap 不是全部内存:ArrayBuffer、WebAssembly、GPU、DOM 和浏览器原生对象可能属于其他内存统计。
  5. WeakMap 不是通用缓存:它只弱持有 key,不能枚举;使用普通 Map 做无界缓存时仍需淘汰策略。
  6. WeakRefFinalizationRegistry 不适合业务正确性:回收时机不确定,回调可能延迟甚至不执行,只适合非关键的优化清理。

原文配图:内存泄漏排查总结

六、排查清单

  1. 用真实设备和可重复操作确认是长任务、内存膨胀、频繁 GC 还是泄漏;
  2. 用 Task Manager 和 Performance 观察 JS heap、DOM nodes、listeners 及 GC 后基线;
  3. 用 Heap Snapshot Comparison 查找重复操作后仍存活的对象;
  4. 搜索 Detached,沿 retaining path 找到全局、闭包、监听器、定时器或缓存;
  5. 对事件、订阅、定时器、动画帧和请求设计成对的清理流程;
  6. 对缓存设置容量、过期时间或淘汰策略,必要时使用 WeakMap
  7. 修复后重复同样的操作,确认对象数量和 GC 后基线不再持续上升;
  8. 记录浏览器、Node.js、设备和 DevTools 版本,因为内存实现和面板名称会变化。

虽然 JavaScript 的垃圾回收是自动的,但内存生命周期仍然是开发者的责任:不是手动“释放地址”,而是管理不需要的强引用、订阅和资源所有权。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS