我们知道当页面运行数小时后突然崩溃,动画开始掉帧,或者定时器清空了内存还在涨,这些往往是因为内存泄漏------那些本该被回收的对象,因为某些原因一直留在堆里,最终拖垮了整个应用。今天,我们就来聊聊关于垃圾回收(GC)的那些事儿。

一、为什么你需要了解垃圾回收?
有人觉得"垃圾回收是引擎的事,关我什么事?"直到他们遇到了这些场景:
- 一个后台看板每5秒刷新一次图表,跑了半小时后页面卡顿,内存占用从50MB涨到800MB。
- 用户在SPA中频繁切换路由,每次切回来页面都变慢,最后整个标签页崩溃。
- 一个列表页支持拖拽排序,每次拖拽后内存都会涨一点,操作几十次后已经能感受到明显延迟。
这些问题的根源,都是开发者不经意间保留了对不再需要的对象的引用,导致垃圾回收器无法回收它们。理解GC的工作原理,才能精准地避免这些坑。
二、内存模型回顾:栈与堆
我们知道JavaScript内存分为栈(Stack)和堆(Heap)。
- 栈 :存储原始值(number, string, boolean等)和对象的引用地址。空间小、操作快,函数调用时分配,返回时自动释放。
- 堆 :存储对象、数组、函数、闭包变量等。空间大、动态分配,需要垃圾回收器来清理不再使用的对象。
当我们在代码中写:
javascript
let user = { name: 'weedsfly', age: 30 };
- 堆中创建了对象
{ name: 'weedsfly', age: 30 }。 - 栈中变量
user存储了这个对象在堆中的内存地址。
当你将 user = null,栈中的地址被清除,堆中的对象变得不可达,等待垃圾回收。
三、垃圾回收算法:从引用计数到标记-清除
1. 引用计数(已成历史)
早期引擎(IE6/7)使用引用计数:每个对象记录有多少个变量引用它,引用归零则立即回收。但循环引用导致无法回收:
javascript
function fn() {
let a = {};
let b = {};
a.b = b;
b.a = a; // 互相引用,离开函数后计数器仍为1,永远无法回收
}
fn();
现代浏览器已全部废弃引用计数,改用标记-清除。
2. 标记-清除(Mark-Sweep)
这是V8、SpiderMonkey等引擎的核心算法。它基于可达性:从"根对象"出发,能访问到的对象就是活的,其余都是垃圾。
根对象包括:
- 全局对象(window / global)
- 当前调用栈上的局部变量
- 活跃的闭包引用
流程:
- 标记:从根出发,深度遍历所有引用,给遇到的对象打上"存活"标记。
- 清除:遍历整个堆,回收没有标记的对象。
循环引用不再成为问题:fn执行完后,a和b从根不可达,即使互相引用也一起被清理。
为了减少对主线程的阻塞,V8还引入了增量标记 和并发标记,将标记过程拆分成小块,与JS代码交替执行。
四、V8的分代回收:为什么大多数对象都"短命"?
V8基于"弱分代假说"------绝大部分对象生命周期极短,只有少数长期存活。因此,堆被分为两代:
- 新生代(Young Generation) :存放新创建的对象。使用Scavenge算法 (半空间复制),只复制存活对象,速度快。经过两次回收仍存活的对象会晋升到老生代。
- 老生代(Old Generation) :存放长期对象。使用标记-清除-整理,回收后还会整理内存碎片。
对我们的启示:避免无意中让临时对象进入老生代,例如全局变量、永远不清的闭包等。
五、项目中的内存泄漏场景与修复
以下列举React/Vue项目中常见的泄漏原因,并给出具体代码示例和修复方案。
1. 遗忘的定时器
jsx
useEffect(() => {
setInterval(() => {
fetchData(); // 组件卸载后,这个定时器还在跑,引用了组件上下文
}, 5000);
// 没有清理
}, []);
修复:使用清理函数。
jsx
useEffect(() => {
const id = setInterval(fetchData, 5000);
return () => clearInterval(id);
}, []);
2. 未解绑的事件监听
jsx
useEffect(() => {
const handler = () => console.log('resize');
window.addEventListener('resize', handler);
// 组件卸载后handler还在window上,导致闭包无法回收
}, []);
修复:
jsx
useEffect(() => {
const handler = () => console.log('resize');
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
3. 游离DOM(Detached DOM)
在JavaScript中保留了已从页面删除的DOM元素引用。
javascript
let savedEl = null;
function mount() {
const el = document.createElement('div');
document.body.appendChild(el);
savedEl = el;
}
function unmount() {
document.body.removeChild(savedEl);
// savedEl 还指向该元素,该元素无法被回收
}
修复:在组件卸载时将引用置空。
jsx
const ref = useRef(null);
useEffect(() => {
return () => { ref.current = null; };
}, []);
4. 闭包持有大对象
jsx
useEffect(() => {
const bigData = new Array(100000).fill('data');
const timer = setInterval(() => {
console.log(bigData.length); // 闭包一直引用 bigData
}, 1000);
return () => clearInterval(timer);
}, []);
即使定时器清理了,在 bigData 还可能被其他闭包引用时要谨慎。对于这种只读数据,可以用useRef避免直接捕获整个对象。
5. 第三方库实例未销毁
图表(ECharts)、地图(高德/百度)、编辑器(Quill/TinyMCE)需要显式调用destroy()。
jsx
useEffect(() => {
const chart = echarts.init(containerRef.current);
chart.setOption(options);
return () => chart.dispose(); // 释放内部资源
}, []);
6. 全局事件总线未解绑
Vue 2中的EventBus、或自定义全局事件,必须成对on和off。React中可以封装一个useEvent钩子自动解绑。
7. 不恰当的数据结构:Map vs WeakMap
如果使用普通Map存储DOM相关信息,DOM移除后,Map中的键引用仍然存在,DOM无法回收。应改用WeakMap,它对键是弱引用,不阻止垃圾回收。
javascript
// 普通 Map
const nodeInfo = new Map();
nodeInfo.set(document.getElementById('box'), { clicks: 0 });
// WeakMap
const nodeInfo = new WeakMap();
nodeInfo.set(document.getElementById('box'), { clicks: 0 });
六、项目中的最佳实践清单
- 每个
useEffect/onUnmounted清理定时器、事件监听、Observer、WebSocket。 - 调用第三方库时查阅文档,确认是否需要显式销毁。
- 避免在全局对象上挂载临时数据。
- 对于频繁更新的状态,考虑使用
useRef减少对象分配。 - 使用
WeakMap/WeakSet管理关联DOM的元数据。 - 避免在模板中直接创建新对象/数组传给子组件(React中使用
useMemo)。 - 生产环境去除
console.log大对象,或使用条件编译。 - 必要时使用
AbortController取消未完成的请求并释放资源。
七、总结
JavaScript的垃圾回收机制给了我们不用手动管理内存的自由,但自由不等于放纵。理解可达性、标记-清除、分代回收,能够帮助我们有意识地写出"易于回收"的代码。
你在项目中遇到过最离奇的内存泄漏是什么?是如何排查出来的?欢迎评论区分享你的故事。