JS 内存泄漏如何检测?常见场景有哪些?
面试速答(30 秒版 TL;DR)
- JS 有 GC,但“对象是否能被回收”取决于 可达性:只要还有引用链能到达,就不会回收。
- 检测(浏览器端)常用 Chrome DevTools:
- Performance 看长时间交互后的内存曲线是否持续上升
- Memory 做 Heap Snapshot 对比、看 Detached DOM、看 Retainers(是谁把它留住的)
- Performance monitor 观察 JS heap size、DOM Nodes
- 常见泄漏来源:未解绑事件监听、定时器未清理、Detached DOM、全局缓存无限增长、闭包意外持有大对象。
心智模型:泄漏 = 生命周期管理失败
面试回答抓住一句话:不是 GC 不工作,而是你的引用让它没法回收。
浏览器端:一套可复现的排查流程
1)先判断“是不是泄漏”
- 打开 DevTools 的 Performance/Performance monitor
- 重复执行同一用户操作(打开弹窗、切换路由、滚动列表等)
- 如果 JS heap / DOM Nodes 持续上升且不回落,才怀疑泄漏
2)用 Heap Snapshot 找“谁在持有”
操作要点(面试可口述):
- 在可疑操作前后各拍一次 Heap Snapshot
- 对比两次快照,关注增长明显的构造类型(Array、Object、Closure、Detached DOM)
- 点开对象看 Retainers:沿着引用链往上找,定位到具体代码路径(监听器、缓存、全局单例等)
3)关注 Detached DOM
常见模式:
- DOM 节点从页面移除了,但仍被 JS 引用(例如数组里缓存了节点,或事件回调闭包引用了节点)
常见泄漏场景(高频背诵点)
1)事件监听不解绑
function mount() {
const onResize = () => {/* ... */};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}
要点:解绑需要同一个函数引用。
2)setInterval / 定时器没清理
const id = setInterval(() => {/* ... */}, 1000);
// 组件卸载/页面离开时:clearInterval(id)
3)缓存无限增长(Map/数组)
- 例如把请求结果按 key 缓存,但没有 TTL/上限/淘汰策略。
4)Detached DOM
- 典型:把 DOM 节点塞进全局数组,或闭包捕获了节点引用导致无法回收。
5)闭包持有大对象
- 不是闭包必然泄漏,而是闭包“活得太久”且捕获了不该捕获的数据。
典型追问
Q1:怎么区分“正常上涨”和“泄漏”?
- 正常:操作峰值上升,但 idle 后会回落或趋于稳定。
- 泄漏:重复同一操作,基线持续上升且不回落。
Q2:定位到代码后怎么修?
- 缩短引用生命周期:解绑监听、清理定时器、释放缓存、断开对 DOM 的引用。
- 给缓存加上限/淘汰策略(LRU/TTL 等),避免无界增长。
易错点/坑
- 只看一次 snapshot 就下结论:要做“前后对比”。
- 误把“内存抖动”当泄漏:关键看基线是否持续上升。
速记要点(可背诵)
- 泄漏本质:引用让对象一直可达。
- 三板斧:看曲线 -> 两次快照对比 -> Retainers 找持有者。
- 高频元凶:事件、定时器、Detached DOM、无界缓存、长寿命闭包。