页面越开越卡甚至崩溃:前端内存泄漏的排查与根治

内容提要

从常见泄漏场景与 GC 可达性出发,结合多轮堆快照、引用链分析、资源生命周期、AI 流式对话治理、线上采样和崩溃留证,建立从复现到修复验证的完整闭环。

页面运行一段时间后越来越卡,切换路由后没有恢复,最终崩溃,这是长时间运行的后台系统、实时监控、编辑器和 AI 流式对话中常见的问题。处理这类故障,需要把对象为什么继续存活、资源由谁负责释放,以及修复后如何验证联系起来。

完整流程是:区分问题类型 → 固定复现路径 → 比较回收后的存活对象 → 沿引用链找到持有者 → 修复生命周期或容量策略 → 重复验证并在线监控。仅凭内存曲线升高,不能直接认定泄漏;清缓存或刷新只能止损,不能替代根因修复。

1. 先区分泄漏、膨胀、分配抖动与资源增长

类型典型表现处理方向
内存泄漏回到相同业务状态后,GC 后的存活对象仍持续增加找到多余的持有关系并解除
内存膨胀数据仍有业务用途,但规模或缓存体积过大限制容量、分页、淘汰和按需加载
分配抖动临时对象大量创建和回收,GC 频繁并伴随卡顿降低分配速率和临时数据体积
JS 堆外资源增长JS 堆相对稳定,但页面或进程内存上涨检查图片、Canvas、WebGL、媒体、Worker 等

例如,聊天历史持续增加,可能是保留策略没有上限;退出聊天后,整个旧会话仍被全局订阅保留,则可能存在生命周期泄漏。两者都影响长期稳定性,但需要不同的解决方案。

判断重点是“相同业务状态下,经过回收,内存基线是否持续抬高”。一次增长、没有马上下降,或者进程内存没有立即归还操作系统,都不足以单独证明泄漏。Chrome 也区分泄漏、内存膨胀和频繁垃圾回收等问题,参见 排查内存问题。

2. 常见泄漏场景:关注生命周期不匹配

意外全局变量:在非严格模式的普通脚本中,对未声明变量赋值可能创建全局属性,普通函数调用中的 this 也可能指向全局对象。ES Module 默认严格模式,这类未声明赋值会报错。但显式写入 window、模块级数组、单例服务和全局缓存,仍可能长期持有不再需要的对象。顶层 let/const 也不等于 window 属性。

DOM 引用未释放:节点从文档中移除,只意味着不再显示。如果全局数组、缓存、订阅或闭包仍持有它,节点及相关对象可能继续存活;持有某个子节点,也可能间接保留父节点和相关子树。重点不是“代码引用了 DOM”,而是“已经退出业务生命周期的 DOM 被谁继续保留”。

定时器与事件监听未清理:setInterval、递归 setTimeout 和持续调度的 requestAnimationFrame 容易在页面退出后继续运行。一次性 setTimeout 执行结束后,通常不再由该计时任务继续持有回调,但失去用途的待执行任务仍应取消。监听 window、document、全局事件总线时,要特别注意它们的生命周期通常长于局部组件。

闭包保留大对象:闭包本身不是泄漏。只有闭包被长期保留,并持续引用已经不需要的大对象时,才形成问题。外部函数执行结束并不意味着闭包必须释放其捕获的数据;反过来,不再可达的闭包及其引用的数据也可以一起回收。

连接与订阅未结束:WebSocket、EventSource、fetch 流、状态库订阅以及事件总线,可能持续保留回调和页面状态。关闭 UI 不等于停止这些资源,尤其需要检查错误重试和重连任务是否在退出后重新启动。

还应检查的场景治理方式
无上限的 Map、数组、历史记录与缓存容量上限、TTL、LRU、按会话淘汰
Observer、RxJS、状态库订阅disconnect、unsubscribe 或对应取消函数
图表、地图、编辑器实例使用库提供的 dispose / destroy
Blob URL、Worker、媒体与图形资源在不再使用时调用对应释放或终止接口
隐藏页面、KeepAlive、后台标签区分停用与卸载,按业务暂停或恢复资源

不是所有“DOM + 监听器”的循环引用都会泄漏。如果 DOM、监听器和回调形成的对象群整体已不再从根可达,现代追踪式 GC 可以回收它们。真正需要查找的是来自长期存活对象的持有关系。

3. 理解 GC:可达性决定回收资格

现代主流 JavaScript 引擎的回收核心是可达性分析,不能简单理解为“引用次数减到零”。从 GC Roots 出发可以访问的对象需要保留;不再被强引用可达的对象具备回收条件,具体何时回收由引擎决定。

GC Roots 不只有 window,还包括执行栈、引擎内部句柄和运行时维护的相关引用。循环引用本身不是障碍:两个对象即使互相引用,只要整个对象群不再从根可达,就可以被回收。

以 V8 为例,其实现结合分代、标记、清理和整理,并使用增量、并发和并行等技术减少停顿。但这些技术不能判断业务是否已经不需要某个仍可达的对象。泄漏的根本问题通常是:业务认为对象应该结束生命周期,程序却仍保留了通向它的引用。参见 V8 的垃圾回收介绍。

需要区分“解除一条引用”和“对象可以释放”。如果另一个全局 Map 仍保存同一对象,局部变量赋值为 null 不会消除那条全局持有链。只有相关的多余强引用都解除,对象才可能被回收。

4. WeakRef 与 FinalizationRegistry 的正确定位

WeakRef 和 FinalizationRegistry 不是 2026 年的新特性,也不是可靠的泄漏检测器。FinalizationRegistry 的清理回调可能延迟执行,甚至不执行;没有观察到回调,不能证明对象仍然存在,更不能单独证明泄漏。

  • 不依赖 FinalizationRegistry 关闭连接、取消订阅或释放必须及时释放的资源。
  • 不把“回调是否在某个期限内触发”作为 CI 的可靠通过条件。
  • heldValue 被注册表强引用保存,不能让它间接指回目标对象。
  • WeakRef 不能替代缓存的容量上限,弱引用目标也不保证立即被回收。
  • WeakMap 适合按对象身份关联辅助数据,但不适合需要精确计数、枚举和容量管理的所有缓存场景。

弱引用可以用于特定缓存或辅助诊断,但关键清理仍应有显式入口。行为边界见 MDN:FinalizationRegistry。

5. 本地排查:先建立可重复的基线

先固定复现路径,例如“进入聊天页 → 建立流式请求 → 接收消息 → 退出聊天页”,每轮回到相同业务状态。使用固定数据和操作规模,先预热,排除首次加载、组件初始化和合理缓存造成的增长。

工具主要回答的问题
Performance 与内存曲线增长与哪些操作、长任务、GC 停顿相关
Heap Snapshot当前有哪些存活对象,以及它们为何被保留
Allocation instrumentation on timeline哪些时段分配了对象,哪些分配仍存活
Allocation sampling以较低开销了解主要分配来源
Performance MonitorJS 堆、DOM 节点和监听器等指标是否持续增长
浏览器任务管理器页面或进程级内存是否与 JS 堆表现不同

内存曲线呈锯齿形通常说明分配与回收交替发生,不能据此判定泄漏。更可疑的表现是:同样的进入/退出操作重复后,回收后的低点持续抬高。合理缓存也可能先上升再进入平台,需要多轮观察。

建议在受控本地环境中按下面的顺序获取快照:

  • 预热后,回到初始业务状态,触发 GC,记录快照 A。
  • 执行若干轮操作,完成退出与清理,等待异步工作稳定,再记录快照 B。
  • 重复相同轮数,再回到相同状态,记录快照 C。
  • 比较 A→B 与 B→C,寻找每轮持续残留的对象,而不是只看一次新增。

本地手动触发 GC 是为了减少比较噪声,不能作为生产环境解决方案。自动化环境中的受控 GC 也不代表真实用户能随时强制回收。

6. 堆快照:从新增对象追到实际持有者

Comparison 视图可以帮助找到持续增加的构造函数、实例和内存,但“某类对象增加”只是线索。应选中本应已经退出生命周期的实例,在 Retainers 中沿持有关系向上追踪。

GC Root
  → 全局事件总线
  → subscribers 数组
  → onMessage 回调
  → 已退出页面的会话状态
  → 消息数组与 DOM 引用

这条链路表明,应修复全局订阅没有解除的问题。仅清空某个局部变量,或者在组件里给另一个引用赋 null,都不能解除 subscribers 数组的持有关系。

  • Shallow Size:对象自身占用的大小。
  • Retained Size:对象支配的保留集合所占内存,有助于发现“小回调保留大对象”的情况。
  • Retainers:哪些对象指向当前对象,用于回答“为什么还活着”。

可以搜索 Detached 检查脱离文档的 DOM 树,但 Detached 不自动等于泄漏,仍需结合业务生命周期和引用链判断。也不要简单累加多个对象的 Retained Size,它们的保留集合可能存在包含关系。

分配记录回答“在哪里创建”,保留链回答“为什么没有释放”。堆快照并不保证直接指出所有问题的源码行,需要结合源码、函数名和分配栈。Chrome 的 堆快照文档介绍了这些视图。

还要排除调试工具造成的干扰:console 输出、临时全局变量和对象检查操作本身可能保留对象。定位后,应在干净环境中复核,避免把调试引用当成业务泄漏。

7. 根治:为资源指定拥有者与清理入口

创建资源的模块负责提供清理方法,生命周期拥有者负责调用。清理路径要覆盖组件卸载、依赖变化、用户取消、路由切换和初始化失败;清理函数尽量幂等,避免多次调用引发新问题。

function mountPanel(element) {
  const controller = new AbortController();
  const timer = setInterval(updateStatus, 5000);
  let disposed = false;

  function onResize() {
    updateLayout(element);
  }

  function updateStatus() {
    // 更新本模块状态
  }

  window.addEventListener("resize", onResize, {
    signal: controller.signal,
  });

  return function dispose() {
    if (disposed) return;
    disposed = true;
    controller.abort();
    clearInterval(timer);
  };
}

// 宿主在面板退出生命周期时调用返回的 dispose。
// updateLayout 是具体业务提供的布局更新函数。

AbortController 可以管理支持 AbortSignal 的监听器和请求,但不会自动关闭 WebSocket、EventSource 或销毁图表。手动 removeEventListener 时,需要使用注册时的同一个函数对象,并匹配 capture;重新 bind 得到的是新函数,不能用于移除旧监听。参见 MDN:removeEventListener。

资源常见清理方式
定时器clearTimeout / clearInterval,并阻止递归继续调度
rAF 动画cancelAnimationFrame,并阻止下一轮调度
事件监听removeEventListener,或绑定独立生命周期的 AbortSignal
Observerunobserve / disconnect,按资源和需求选择
WebSocket / EventSourceclose,并取消相关重连任务
fetch 与流式读取使用 signal 取消请求,按读取方式取消 reader,并在 finally 释放读取锁
Worker对不再使用的专属 Worker 调用 terminate
Blob URL在使用结束后 revokeObjectURL,避免过早释放仍在使用的资源
第三方实例与订阅dispose / destroy / unsubscribe,遵循库的约定

React 中把建立和清理放在匹配的 Effect 生命周期中,依赖变化时也要释放旧资源;Vue 中区分卸载和 KeepAlive 停用。将页面隐藏并不会自动触发卸载清理,后台运行策略需要显式设计。

对缓存和历史记录,应定义明确的容量、淘汰、失效和会话隔离规则。弱引用、垃圾回收和虚拟列表都不能替代这些策略。

8. AI 流式对话:不仅要关闭连接,还要控制数据积压

AI 对话通常同时涉及请求、读取循环、增量文本、组件状态、工具输出和重试逻辑。退出会话时,要停止整条生产和消费链,而不是仅仅不再显示消息。

  • 区分 EventSource SSE、WebSocket 和 fetch 流,采用各自的取消方式;使用 SSE 格式不代表一定使用 EventSource。
  • 用户取消或切换会话时,中止旧请求、读取和重连定时器,避免退出后重新连接。
  • 使用取消状态或会话标识阻止旧异步结果回写新会话,但不要仅靠忽略结果,仍应停止可以取消的底层工作。
  • 清理 token 缓冲、批量更新定时器、待执行的 rAF 和消息订阅。
  • 限制历史消息、工具结果、日志和附件引用,避免所有会话永久驻留内存。
  • 消费速度落后时进行批处理、合并或背压,避免无限增加待处理队列。

还应检查增量渲染是否反复保存完整的中间版本。历史快照、撤销栈、状态管理调试记录和富文本解析缓存,都可能让一条回答产生大量长期保留的数据。

虚拟列表可以减少挂载的消息节点,但不会减少数据数组本身。长会话需要把渲染窗口、数据窗口和持久化历史分别设计。

9. 线上监控:能力检测、低频采样与业务计数

PerformanceObserver 不能直接监听 JS 堆大小、DOM 总数或事件监听器数量。它用于观察浏览器支持的性能条目,例如资源、长任务和部分交互指标,这些可以辅助关联卡顿,但不能直接证明内存泄漏。应通过 supportedEntryTypes 检查实际支持范围,参见 MDN:性能条目支持列表。

performance.memory 是非标准、已弃用的接口,主要存在于 Chromium 系浏览器,而且可能高估或低估实际内存。它不是页面总内存,也不能作为所有用户都具备的监控能力。参见 MDN:performance.memory。

可以评估 performance.measureUserAgentSpecificMemory(),但其支持范围有限,需要安全上下文与跨源隔离,并要处理调用失败。数据口径依赖浏览器实现,不应把不同浏览器或版本的绝对值直接混用。参见 MDN:内存测量 API。

async function sampleMemory() {
  if (!globalThis.crossOriginIsolated ||
      typeof performance.measureUserAgentSpecificMemory !== "function") {
    return null;
  }

  try {
    const sample = await performance.measureUserAgentSpecificMemory();
    return { bytes: sample.bytes, sampledAt: Date.now() };
  } catch {
    return null;
  }
}

这只是一次能力受限的采样入口。线上应低频、按比例调用,避免采样任务重叠;不支持时返回不可用,而不是记录为 0。不要为了获得该指标,在没有评估第三方资源和集成影响时贸然修改跨源隔离策略。

更有业务解释力的信号包括:活跃连接数、订阅数、未完成请求数、Worker 和编辑器实例数、缓存条数、消息条数以及待处理队列长度。可以由应用自己的资源管理层维护这些计数,无需假设存在通用的网页监听器枚举 API。

上报时关联发布版本、路由、会话时长、功能开关和必要的设备分组,比较同类会话随时间或操作次数的增长趋势。日志采用限长环形缓冲,不保存完整 DOM、大对象或无限历史,避免监控本身成为新的泄漏源。

10. 崩溃现场:提前留证,不依赖崩溃回调

OOM、渲染进程崩溃或系统杀进程发生时,页面可能已经无法执行任何 JavaScript。不能指望在“崩溃事件”里调用 sendBeacon,也不能依赖 window.onerror、unload 或 pagehide 捕获所有异常终止。

更可靠的思路是在正常运行期间低频保存和上报有界的诊断摘要,在 visibilitychange 进入 hidden 时尽力补充上报。下次启动时,结合上一次会话标记、最后上报时间和服务端记录识别疑似异常终止。Beacon 也只是尽力发送,不保证服务端收到。

document.wasDiscarded 可以辅助识别浏览器丢弃后的恢复,但不覆盖所有崩溃,不能单独证明 OOM。心跳中断和未正常结束标记也可能来自网络中断、用户关闭浏览器或系统结束进程,只能作为线索。参见 Chrome:页面生命周期。

线上告警不宜只用一个跨设备通用的固定内存阈值。应结合版本对比、同类会话的增长率、资源计数和卡顿信号进行分级。接近风险时暂停非必要任务、淘汰可重建缓存或提示保存后刷新,属于止损,不等于根治。

11. 治理与验收:把修复变成可回归的规则

开发阶段,建立资源获取与释放配对约定,Review 关注全局持有关系、模块级缓存、订阅和异步取消。ESLint 可以识别未声明变量和部分已知危险模式,但不能可靠证明所有资源都已经释放,跨函数和框架生命周期仍需设计与验证。

测试阶段,把已经复现的进入/退出流程固化为回归场景。固定浏览器版本、数据和操作规模,先预热,再做多轮操作,比较存活对象、活跃资源和回收后基线。CI 应允许测量噪声与合理缓存,不要只用一次总内存超阈值就判定泄漏。

  • 修复前能稳定复现增长,并找到可解释的持有链。
  • 修复后,相同业务状态下,相关对象与资源不再按操作次数持续增加。
  • 预热后的内存逐步进入平台,允许正常波动,不要求字节数精确回到首次加载值。
  • 清理后不会重新连接、重复调度或回写旧会话。
  • 长时间运行中,队列、缓存和历史数据保持在明确的业务边界内。
  • 在线上同类会话中,增长趋势、异常资源计数和故障信号得到改善。

不要通过删除仍有业务用途的数据、强制刷新用户页面或屏蔽监控来让测试通过。验收需要同时保证数据正确性、交互行为和资源稳定性。

12. 面试回答:说明从判断到验证的闭环

遇到页面开久了变卡甚至崩溃,我会先区分内存泄漏、合理数据增长、无上限缓存和频繁分配,也会检查 JS 堆以外的图片、图形及媒体资源。然后固定一条可以重复进入和退出的操作路径,预热后比较多轮操作、GC 后的存活对象与内存基线。

定位时用 Performance 判断增长和卡顿的时机,用堆快照 Comparison 找持续残留的对象,再沿 Retainers 找到生命周期不匹配的持有者,结合分配记录和源码确认原因。修复资源清理、异步取消或缓存容量策略后,重复同样流程验证增长消失,最后用线上低频采样和业务资源计数做回归监控。

根治的标准是:不再需要的对象不被错误保留,仍然需要的数据有合理容量,外部资源在业务生命周期结束时被释放,而且这些条件能够通过重复测试和线上趋势持续验证。