JS垃圾回收:从原理到项目实战,彻底根治内存泄漏

我们知道当页面运行数小时后突然崩溃,动画开始掉帧,或者定时器清空了内存还在涨,这些往往是因为内存泄漏------那些本该被回收的对象,因为某些原因一直留在堆里,最终拖垮了整个应用。今天,我们就来聊聊关于垃圾回收(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)
  • 当前调用栈上的局部变量
  • 活跃的闭包引用

流程

  1. 标记:从根出发,深度遍历所有引用,给遇到的对象打上"存活"标记。
  2. 清除:遍历整个堆,回收没有标记的对象。

循环引用不再成为问题: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、或自定义全局事件,必须成对onoff。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的垃圾回收机制给了我们不用手动管理内存的自由,但自由不等于放纵。理解可达性、标记-清除、分代回收,能够帮助我们有意识地写出"易于回收"的代码。

你在项目中遇到过最离奇的内存泄漏是什么?是如何排查出来的?欢迎评论区分享你的故事。

相关推荐
Jcc1 小时前
虚拟 DOM 是什么?从 Snabbdom 理解 Vue 的 DOM 更新机制
前端
user62229864925811 小时前
Vue 常用技术知识全景:从响应式到组件通信的系统理解
前端
feiyu_gao1 小时前
一个人 + AI:246 commits 做出设计系统 CLI 的故事
前端·ai编程·交互设计
奶油mm1 小时前
从 0 到 1 搭建高可用 Redis Cluster:踩坑、优化与生产实践
前端
掘金安东尼2 小时前
Agent Loop 深度调研:把决定权交给模型的一次换代,为什么发生在现在
前端
亿元程序员2 小时前
Cocos视频拼图,终于支持微信小游戏了!
前端
JarvanMo2 小时前
Flutter 的默认颜色
前端
IT_陈寒2 小时前
Vite打包时踩的坑:静态资源为啥突然404了?
前端·人工智能·后端