百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

JS内存管理与常见泄漏排查(闭包、DOM 引用、定时器、全局变量)

itomcoil 2025-09-28 01:19 3 浏览


目标:用一套“能复现—能定位—能修复—能预防”的方法体系,把 JS 内存问题从玄学变工程。


1)先讲原理但不过度:JS/GC 的工作方式

1.1 GC 核心思想:可达性(Reachability)

  • GC 不关心“有没有引用计数”,而是看对象是否从根(window/global、活动栈、闭包环境、DOM 树)可达
  • 不可达 => 可回收;可达 => 保活(哪怕只是被某个全局 Map/闭包无意间握着)。

1.2 V8(浏览器/Node)常用术语(理解就够)

  • 分代回收:新生代(New Space)对象短命,Scavenge 频繁;老生代(Old Space)对象长命,Mark-Sweep/Mark-Compact。
  • 增量/并发标记:降低 STW(stop-the-world)卡顿。
  • 写屏障(Write Barrier):跨代引用记账,保证增量标记正确。
    工程启示短命对象可随用随丢长命大对象(全局缓存、单例、闭包)要严加管理。

2)十大高频泄漏形态(含“为什么”与“怎么修”)

2.1 意外的全局变量(Sloppy 模式)

反例

function foo() {
  bar = [];        // 没有声明关键字 → 隐式挂到 window(浏览器)
}

危害:全局可达,生命周期贯穿页面/进程。
修复

  • 全面启用 'use strict' 或 ESM(模块天然严格模式)。
  • ESLint no-undef 阻断上线。

2.2 定时器/动画回调未清理(setInterval / setTimeout 链)

反例

function mount(el) {
  const big = new Array(1e6).fill('x'); // 大对象
  const id = setInterval(() => {
    el.textContent = big[0];            // 闭包捕获 big & el
  }, 1000);
  // el 被移除后没清理 interval => big/回调/闭包都保活
}

修复

  • 组件式封装返回清理函数或用 AbortController/统一的 dispose()。
function mount(el) {
  const big = new Array(1e6).fill('x');
  const ac = new AbortController();
  const id = setInterval(() => { el.textContent = big[0]; }, 1000);
  ac.signal.addEventListener('abort', () => clearInterval(id), { once: true });
  return () => ac.abort();
}
  • 动画场景优先 requestAnimationFrame + 明确取消。

2.3 事件监听未解绑 / 错位绑定

反例 A:监听绑在 window,闭包抓住 DOM

function attach(el) {
  const onScroll = () => doSomethingWith(el); // 闭包持有 el
  window.addEventListener('scroll', onScroll);
  // el 从 DOM 移除但 onScroll 仍在 => el 保活
}

修复

  • 绑定到 元素自身 或最近稳定容器,减少持有层级;
  • 统一解绑(同上 AbortController);
  • 事件委托降低监听数量(但注意冒泡与 stopPropagation)。

反例 B:focus/blur 不冒泡,委托失效导致到处乱绑
修复:用 focusin/focusout 做委托;或仅在必要节点直接绑定并可控解绑。


2.4 “脱离文档的 DOM 节点”(Detached DOM)仍被 JS 引用

反例

const cache = [];
function removeItem(li) {
  document.querySelector('ul').removeChild(li);
  cache.push(li); // 仍引用着它(或 li.childNodes)
}

危害:DOM 与 JS 双向引用,泄漏增长像滚雪球。
修复

  • 不缓存 DOM 节点;确实要缓存,用 WeakMap 以 DOM 元素作键。
const meta = new WeakMap(); // key 必须是对象(DOM 元素 OK)
  • 渐进式 UI:尽量缓存 数据 而非 DOM;DOM 用 Virtual List/重渲染。

2.5 闭包意外持有巨大对象/上下文

反例

function createHandler(hugeData) {
  return () => doCalc(hugeData); // 事件/定时器闭包长期存活
}

修复

  • 缩小闭包捕获面:传轻量 key,按需索引全局弱引用缓存。
  • 对只读大对象,可在使用前切片结构化拷贝(Worker/structuredClone)。

2.6 巨大缓存/Map 从不淘汰

反例

const cache = new Map();
function get(key) {
  if (!cache.has(key)) cache.set(key, compute(key)); // 永远增长
  return cache.get(key);
}

修复

  • LRU/TTL;key 是对象时,用 WeakMap(只弱化 key,不弱化 value!)。
  • 生产中谨慎使用 WeakRef + FinalizationRegistry(不保证时序,勿用于核心业务逻辑)。

2.7 Promise 链/微任务持有引用

  • 长链里捕获了大上下文或错误堆栈,未释放
    修复
  • 中间态返回基础数据,不要把 DOM/大对象贯穿 Promise;
  • 注意 async 闭包里对外层巨对象的捕获。

2.8 第三方库/调试句柄“帮你”泄漏

  • 往 window.debug = store、window.$vm0 挂对象;
  • DevTools Elements 面板 $0…$4、控制台最近求值结果 $_ 会保活引用。
    修复
  • 排查前不要选中要调查的节点;清空控制台;或在快照配置里忽略这种引用。

2.9 Web 资源对象未释放

  • URL.createObjectURL(blob) 未 revokeObjectURL;
  • ImageBitmap/WebGL 贴图未删除;
  • AudioContext/MediaStream 未关闭。
    修复:使用完显式释放,或封装成可 dispose() 的资源对象。

2.10 Node.js 服务端特有

  • 全局缓存、长连接会话、事件总线订阅未取消;
  • 热更新/Worker 重启不彻底;
  • 不必要的闭包抓住请求/响应对象。
    修复:利用 process.memoryUsage() 监控趋势,heapdump/DevTools 堆快照定位 dominator;服务层面做请求级隔离生命周期管理

3)系统化定位手段(浏览器 & Node)

3.1 Chrome DevTools:两个面板搞定

A. Performance 面板(勾选 Memory)

  1. 打开页面,点击 Performance,勾选 Memory
  2. 录制:执行“可复现内存增长”的用户路径 30–60s;
  3. 看 JS Heap 曲线:交互结束后是否回落?不回落 => 可能泄漏。

B. Memory 面板

  • Heap snapshot(堆快照)
  • baseline 快照;2) 执行动作 N 次;3) 第二个快照;
    Diff 视图按 Class / Dominators 查看增长对象;重点关注:Detached HTMLDivElement、(system)、大数组、Map/Set。
  • Allocation instrumentation on timeline:定位“谁在不停分配”。

实操要点

  • Retainers(保留路径):找出“为什么还可达”(通常是 Map/闭包/监听器)。
  • 关注 Distance to Window:距离越短越“根”,越值得查。
  • 排查时不要选中节点($0 会保活),尽量关闭 “Preserve log”。

3.2 Node.js:与浏览器类似的流程

  • 启动:node --inspect index.js → Chrome chrome://inspect → 堆快照/性能采样;
  • 运行中观察:process.memoryUsage()、v8.getHeapStatistics();
  • 生成堆转储:heapdump 包(不要在线上频繁 dump)。
  • 负载回放:autocannon/wrk 压测 + 观察堆曲线是否“锯齿回落”。
  • 高级:--trace-gc(调试用,勿在高负载生产长期开);clinic/0x 做火焰图辅助定位热点。

4)可复制的修复模板

4.1 统一的“资源托管”与清理

class Disposer {
  #tasks = [];
  on(fn) { this.#tasks.push(fn); return () => this.off(fn); }
  off(fn) { this.#tasks = this.#tasks.filter(t => t !== fn); }
  dispose() { for (const t of this.#tasks.splice(0)) try { t(); } catch {} }
}

function mount(el) {
  const d = new Disposer();

  // 定时器
  const id = setInterval(() => {}, 1000);
  d.on(() => clearInterval(id));

  // 事件(带 AbortController 更省心)
  const ac = new AbortController();
  window.addEventListener('scroll', () => {}, { signal: ac.signal, passive: true });
  d.on(() => ac.abort());

  // 返回卸载函数,在框架组件的 unmount 里调用
  return () => d.dispose();
}

4.2 事件委托 + 弱引用缓存

const meta = new WeakMap(); // DOM 节点 → 元数据

function onDelegate(container, type, selector, handler, options) {
  const listener = (e) => {
    const t = e.target.closest(selector);
    if (t && container.contains(t)) handler.call(t, e, t);
  };
  container.addEventListener(type, listener, options);
  return () => container.removeEventListener(type, listener, options);
}

// 使用:避免把子节点散着注册/难清理

4.3 LRU/TTL 缓存(避免永久增长)

class LRU {
  constructor(limit = 500) { this.limit = limit; this.map = new Map(); }
  get(k) { const v = this.map.get(k); if (!v) return; this.map.delete(k); this.map.set(k, v); return v; }
  set(k, v) { if (this.map.has(k)) this.map.delete(k); this.map.set(k, v);
    if (this.map.size > this.limit) this.map.delete(this.map.keys().next().value); }
}

4.4 严格模式与 ESLint

  • 顶层模块化:全部源码转 ESM/TS(天然严格模式)。
  • 规则:no-undef、no-implied-eval、no-loop-func、no-global-assign、no-new-func、no-restricted-globals、no-async-promise-executor。

5)专项排查指南(按场景)

5.1 输入框/搜索建议:闭包 & 防反复创建

  • 把防抖函数存在组件实例,不要每次输入都 new 一个
  • 异步结果只保留必要字段,避免把完整列表贯穿到事件回调闭包。

5.2 长列表/虚拟滚动:DOM/监听器

  • 用虚拟列表,控制 DOM 数量;
  • 列表容器做事件委托(增删行无需解绑)。

5.3 Canvas/WebGL/媒体

  • createObjectURL 用完 revoke
  • WebGLTexture/Buffer 用完 delete
  • AudioContext、MediaStream、MediaRecorder 使用完 close/stop

5.4 SPA 路由切换后内存不降

  • 查看是否有跨路由的单例(事件总线、全局 store)握着页面对象;
  • 检查路由守卫/订阅是否解绑。

5.5 Node.js 接口层

  • 中间件/事件总线订阅泄漏:打印 process.listenerCount('event') 或使用 EventEmitter.setMaxListeners() 提前预警;
  • 连接池/缓存:设置 TTL;避免把 req/res 对象放进 promise 链外的全局变量。

6)团队级防御性实践清单

  • 架构层
    • 组件/页面 统一 dispose() 协议
    • 事件与定时器统一注册入口(可自动注入 AbortSignal);
    • 缓存设计默认 LRU/TTL;DOM 元素相关信息放 WeakMap
  • 代码规范
    • 模块化 + 'use strict';
    • ESLint + TypeScript(类型缩小能避免意外挂载到全局);
    • 禁止将大对象/DOM 节点挂到 window 便于调试;有需要用 WeakRef 包一层。
  • 观测与压测
    • 浏览器:集成 PerformanceObserver 上报内存趋势(Chrome 可用 performance.memory);
    • Node:业务入口埋点 process.memoryUsage(),配合告警;
    • PR 合并前跑**“泄漏金丝雀”脚本**:执行 N 次页面操作,校验堆占用是否回落到阈值。
  • 排障流程固化
    • Performance Memory 曲线,后 Heap Snapshot Diff
    • 记录 Retainers 截图与修复 PR 关联;
    • 每季度对关键路径做一次“泄漏演练”。

7)误区纠偏(常见认知坑)

  • “DOM 节点移除就一定释放监听”:只有在没有其他 JS 引用时才释放;闭包/全局集合持有会保活。
  • “用了 WeakMap 就万事大吉”:WeakMap 只弱化,value 若被其他地方引用仍会保活。
  • “FinalizationRegistry 等于析构函数”:不是。它时序不确定,只能做旁路清理或遥测。
  • “const 就是不可变”:const 只保证绑定不可变,对象内容仍可增长。
  • “DevTools 快照百分百可信”:调试器本身可能保活($0、$_、Preserve log),要规避其影响再拍快照。

8)附:最小泄漏复现 & 排查 demo

复现

<button id="add">Add</button>
<ul id="list"></ul>
<script>
  const list = document.getElementById('list');
  const cache = []; // 故意泄漏
  document.getElementById('add').onclick = () => {
    const li = document.createElement('li');
    li.textContent = new Array(1e4).fill('x').join('');
    list.appendChild(li);
    setTimeout(() => { list.removeChild(li); cache.push(li); }, 500); // 泄漏
  };
</script>

排查

  1. Performance 勾 Memory:点 N 次 Add,结束后堆不回落;
  2. Memory 快照对比:看到 Detached HTMLLIElement 增长;
  3. Retainers:指向 cache (Array);
  4. 修复:不缓存 DOM,或换 WeakMap 存元数据。

结语

  • 内存问题的本质是“可达性管理”:谁在握着引用、握多久、是否有上限。
  • 落地做法:严格模式 + 统一清理协议 + 弱引用/淘汰型缓存 + DevTools 例行体检
  • 把“可回收”变成默认,把“长期保活”变成白名单,团队就能把内存问题控制在工程可接受的范围内。

#前端##javascript#

相关推荐

《Queendom》宣布冠军!女团MAMAMOO四人激动落泪

网易娱乐11月1日报道据台湾媒体报道,南韩女团竞争回归的生死斗《Queendom》昨(10/31)晚播出大决赛,并以直播方式进行,6组女团、女歌手皆演唱新歌,并加总前三轮的赛前赛、音源成绩与直播现场投...

正确复制、重写别人的代码,不算抄袭

我最近在一篇文章提到,工程师应该怎样避免使用大量的库、包以及其他依赖关系。我建议的另一种方案是,如果你没有达到重用第三方代码的阈值时,那么你就可以自己编写代码。在本文中,我将讨论一个在重用和从头开始编...

HTML DOM tr 对象_html event对象

tr对象tr对象代表了HTML表格的行。HTML文档中出现一个<tr>标签,就会创建一个tr对象。tr对象集合W3C:W3C标签。集合描述W3Ccells返回...

JS 打造动态表格_js如何动态改变表格内容

后台列表页最常见的需求:点击表头排序+一键全选。本文用原生js代码实现零依赖方案,涵盖DOM查询、排序算法、事件代理三大核心技能。效果速览一、核心思路事件入口:为每个<th>绑...

连肝7个晚上,总结了66条计算机网络的知识点

作者|哪吒来源|程序员小灰(ID:chengxuyuanxiaohui)计算机网络知识是面试常考的内容,在实际工作中经常涉及。最近,我总结了66条计算机网络相关的知识点。1、比较http0....

Vue 中 强制组件重新渲染的正确方法

作者:MichaelThiessen译者:前端小智来源:hackernoon有时候,依赖Vue响应方式来更新数据是不够的,相反,我们需要手动重新渲染组件来更新数据。或者,我们可能只想抛开当前的...

为什么100个前端只有1人能说清?浏览器重排/重绘深度解析

面试现场的"致命拷问""你的项目里做过哪些性能优化?能具体讲讲重排和重绘的区别吗?"作为面试官,我在秋招季连续面试过100多位前端候选人,这句提问几乎成了必考题。但令...

HTML DOM 介绍_dom4j html

HTMLDOM(文档对象模型)是一种基于文档的编程接口,它是HTML和XML文档的编程接口。它可以让开发人员通过JavaScript或其他脚本语言来访问和操作HTML和XML文档...

JavaScript 事件——“事件流和事件处理程序”的注意要点

事件流事件流描述的是从页面中接收事件的顺序。IE的事件流是事件冒泡流,而NetscapeCommunicator的事件流是事件捕获流。事件冒泡即事件开始时由最具体的元素接收,然后逐级向上传播到较为不...

探秘 Web 水印技术_水印制作网页

作者:fransli,腾讯PCG前端开发工程师Web水印技术在信息安全和版权保护等领域有着广泛的应用,对防止信息泄露或知识产品被侵犯有重要意义。水印根据可见性可分为可见水印和不可见水印(盲水印)...

国外顶流网红为流量拍摄性侵女学生?仅被封杀三月,回归仍爆火

曾经的油管之王,顶流网红DavidDobrik复出了。一切似乎都跟他因和成员灌酒性侵女学生被骂到退网之前一样:住在950万美元的豪宅,开着20万美元的阿斯顿马丁,每条视频都有数百万观看...人们仿佛...

JavaScript 内存泄漏排查方法_js内存泄漏及解决方法

一、概述本文主要介绍了如何通过Devtools的Memory内存工具排查JavaScript内存泄漏问题。先介绍了一些相关概念,说明了Memory内存工具的使用方式,然后介绍了堆快照的...

外贸独立站,网站优化的具体内容_外贸独立站,网站优化的具体内容有哪些

Wordpress网站优化,是通过优化代码、数据库、缓存、CSS/JS等内容,提升网站加载速度、交互性和稳定性。网站加载速度,是Google搜索引擎的第一权重,也是SEO优化的前提。1.优化渲染阻塞。...

这8个CSS工具可以提升编程速度_css用什么编译器

下面为大家推荐的这8个CSS工具,有提供函数的,有提供类的,有提取代码的,还有收集CSS的统计数据的……请花费两分钟的时间看完这篇文章,或许你会找到意外的惊喜,并且为你的编程之路打开了一扇新的大门。1...

vue的理解-vue源码 历史 简介 核心特性 和jquery区别 和 react对比

一、从历史说起Web是WorldWideWeb的简称,中文译为万维网我们可以将它规划成如下的几个时代来进行理解石器时代文明时代工业革命时代百花齐放时代石器时代石器时代指的就是我们的静态网页,可以欣...