标记清除算法只回收不可达对象,泄漏与否取决于代码是否使本该释放的对象保持可达;标记阶段从根对象(如全局对象、栈变量、DOM引用、定时器等)出发递归标记所有可达对象。
标记清除算法本身不预防泄漏,它只负责回收“不可达”对象;泄漏是否发生,取决于你写的代码有没有让本该释放的对象一直保持可达。
标记不是凭空发生的,它依赖一组明确的根对象(GC Roots)。不同环境的根集合略有差异:
window 或 globalThis)、当前执行上下文的栈帧(含函数参数、局部变量)、正在运行的定时器回调、DOM 元素引用、postMessage 消息监听器等global 对象、活跃的 setTimeout/setInterval 句柄、process 事件监听器关键点:只要一个对象能通过任意引用链从任一根对象抵达,它就会被标记为“存活”。这意味着——addEventListener、setTimeout、未清理的 Promise 回调,都可能意外延长对象生命周期。
清除只处理“未被标记”的对象。所以问题从来不在清除逻辑,而在于标记阶段把不该保活的对象也标上了。常见诱因包括:
document.addEventListener('scroll', handler):即使组件已 unmount,handler 仍挂在 document 上,闭包里捕获的组件实例无法被 GCsetInterval(() => console.log(this.data), 1000):this 被定时器长期持有,且定时器本身是全局根cache.set(key, hugeObject) 后忘记 cache.delete(key),key 未失效则值永驻注意:WeakMap 和 WeakRef 不参与标记——它们不阻止所引用对象被清除,但也不能解决“本不该被引用却一直被引用”的问题。
不能只看内存占用曲线,得用 DevTools 直接观察对象存活状态:
Memory 面板中录制一次 Allocation instrumentation on timeline,触发疑似泄漏操作后停止,筛选“Detached DOM tree”或“JS heap”中长期增长的对象构造器名console.memory 粗略判断趋势,但更可靠的是拍堆快照(Heap Snapshot),对比两次快照,按 Retainers 列查看谁在持有着目标对象console.count('MyComponent mounted') 和 console.count('MyComponent cleanup'),确认卸载逻辑确实执行了最容易被忽略的一点:泄漏常发生在“多层间接引用”中——比如一个被遗忘的 EventTarget 实例,它内部又绑着一堆监听器,每个监听器又闭包着组件实例。这种链式持有必须逐层切断,单靠“组件卸载”不够。