JavaScript 内存管理与常见泄漏排查(闭包、DOM 引用、定时器、全局变量)
目标:用一套“能复现—能定位—能修复—能预防”的方法体系,把 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); // 闭包持有 elwindow.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)
打开页面,点击 Performance,勾选 Memory;
录制:执行“可复现内存增长”的用户路径 30–60s;
看 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
→ Chromechrome://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>
排查
Performance 勾 Memory:点 N 次 Add,结束后堆不回落;
Memory 快照对比:看到
Detached HTMLLIElement
增长;Retainers:指向
cache (Array)
;修复:不缓存 DOM,或换 WeakMap 存元数据。
最后小结语
内存问题的本质是“可达性管理”:谁在握着引用、握多久、是否有上限。
落地做法:严格模式 + 统一清理协议 + 弱引用/淘汰型缓存 + DevTools 例行体检。
把“可回收”变成默认,把“长期保活”变成白名单,团队就能把内存问题控制在工程可接受的范围内。