很多前端开发者写了多年 JavaScript,却从未真正理解过内存。我们享受着"自动垃圾回收"带来的便利,却也在不知不觉中写出大量内存泄漏、频繁 GC 卡顿、页面越用越慢的代码。事实上,内存管理与垃圾回收是 JavaScript 性能优化中最底层、也最容易被忽视的一环。本文将从内存模型讲起,深入 V8 引擎的垃圾回收机制,系统梳理常见内存泄漏场景、检测工具链、WeakMap/WeakRef 等现代 API,以及一整套可落地的高性能编码实践。35分钟沉浸式学习,带你从"会用 JS"进阶到"懂 JS"。
为什么前端必须关心内存
在很长一段时间里,"内存管理"被当作后端和服务端的专属话题。但今天的 Web 应用早已不是简单的页面展示——单页应用、长时间驻留的后台系统、WebGL 渲染、大表格与虚拟列表、音视频处理,任何一个环节的内存失控,都会直接表现为页面卡顿、崩溃或设备发烫。
// 每次切换路由都往全局数组里塞数据
const globalCache = [];
function loadPageData(page) {
const data = new Array(100000).fill({
page,
payload: 'x'.repeat(1024)
});
// 只增不减 —— 内存永远不会被回收
globalCache.push(data);
return data;
}
// 用户切了 100 次路由之后……
// Heap Size: 10GB → 页面崩溃
JavaScript 内存模型:栈与堆
理解内存管理的第一步,是搞清楚数据到底存在哪里。JavaScript 中的内存大致分为两块:栈内存(Stack)与堆内存(Heap),它们的分工截然不同。
let a = 1;
let b = a; // 复制值
b = 2;
console.log(a); // 1 —— 互不影响
// 引用值 → 堆内存(栈中只存指针)
let obj1 = { name: '张玥' };
let obj2 = obj1; // 复制引用(指针)
obj2.name = '李雷';
console.log(obj1.name); // '李雷' —— 指向同一块堆内存
// 函数执行上下文 → 调用栈
function foo() {
const local = { big: new Array(1e6) };
return local.big.length;
}
// foo() 执行结束后,栈帧弹出
// local 失去引用,堆中的大数组等待被 GC 回收
· 变量名与对象的引用指针
· 函数调用栈帧
· 空间小(通常 1MB 左右)
· 分配与回收极快(移动栈指针)
· 大小不固定,动态分配
· 空间大(可达数 GB)
· 分配较慢,需要 GC 介入回收
· 内存碎片问题突出
栈是"自动"的,堆才是垃圾回收的主战场
内存生命周期:分配 → 使用 → 释放
无论哪种编程语言,内存的生命周期都遵循同一套流程。JavaScript 的特别之处在于第三步——释放由垃圾回收器自动完成,开发者无法(也不应该)手动调用 free()。
const num = 42; // 栈分配
const str = 'hello'; // 栈(短字符串)
const obj = { a: 1 }; // 堆分配
const arr = [1, 2, 3]; // 堆分配
/* 阶段二:使用内存(读 / 写) */
obj.a = 100;
arr.push(4);
/* 阶段三:释放内存 */
// 开发者无法手动释放,只能"解除引用"
// 让对象变得"不可达",交给 GC 处理
let temp = { big: new Array(1e7) };
temp = null; // 解除引用,等待回收
"自动"不等于"免费",写代码的方式决定了 GC 的负担
垃圾回收算法(一):引用计数
最早的垃圾回收思路非常直观:给每个对象维护一个"被引用次数"的计数器,计数归零就回收。这种算法实现简单、回收及时,但存在一个致命缺陷——循环引用。
let obj = {}; // obj 引用计数 = 1
let ref = obj; // 计数 = 2
ref = null; // 计数 = 1
obj = null; // 计数 = 0 → 立即回收 ✅
// 致命缺陷:循环引用
function createCycle() {
const a = {};
const b = {};
a.b = b; // b 的计数 +1
b.a = a; // a 的计数 +1
return 'done';
}
createCycle();
// 函数执行完毕,a 和 b 已无法从外部访问
// 但两者互相引用,计数永远 ≥ 1
// → 内存永远无法回收 ❌
// 老版本 IE 的经典泄漏:DOM + JS 循环引用
// element.onclick = function() { ... element ... }
优点:回收即时、实现简单
缺点:无法处理循环引用
垃圾回收算法(二):标记清除与标记整理
现代 JavaScript 引擎普遍采用可达性(Reachability)作为判断标准,而非引用计数。核心算法是 Mark-Sweep(标记清除)与 Mark-Compact(标记整理)。
// 从一组"根对象"(GC Roots)出发
// 能顺着引用链走到的对象 = 存活
// 走不到的 = 垃圾,可以回收
// GC Roots 包括:
// 1. 全局对象 window / globalThis
// 2. 当前调用栈上的局部变量
// 3. 闭包捕获的变量
// 4. DOM 树中的引用
/* 标记清除 Mark-Sweep */
// ① 从根遍历,标记所有可达对象
// ② 遍历堆,回收未被标记的对象
// 缺点:产生大量内存碎片
/* 标记整理 Mark-Compact */
// ① 标记可达对象
// ② 将存活对象向一端移动
// ③ 直接清理掉边界外的内存
// 优点:无碎片,但移动成本高
标记清除解决循环引用,标记整理解决内存碎片
V8 引擎的分代垃圾回收
V8 并没有对所有对象"一视同仁"。基于分代假说(大部分对象生命周期极短),V8 把堆划分为新生代与老生代,分别采用不同的回收策略,从而大幅降低 GC 成本。
// 容量小(约 1~16MB),分为 From / To 两个半区
// 算法:Scavenge(Cheney 复制算法)
// 1. 从 From 空间标记存活对象
// 2. 复制到 To 空间(并整理,无碎片)
// 3. 清空 From,From / To 角色互换
/* 对象晋升(Promotion) */
// 条件一:经历过一次 Scavenge 仍存活
// 条件二:To 空间使用超过 25%
// → 被移动到老生代
/* 老生代(Old Generation) */
// 容量大,存放生命周期长的对象
// 算法:Mark-Sweep + Mark-Compact
// 优化:增量标记 / 并发标记 / 惰性清理
/* 为什么要"增量"? */
// 全量标记会长时间阻塞主线程
// 拆成许多小步,穿插在 JS 执行间隙
// 把一次 100ms 的停顿拆成 100 次 1ms
分代 + 并发,让 GC 停顿从百毫秒降到毫秒级
六大常见内存泄漏场景
内存泄漏的本质是:对象已经不再需要,却依然可以从 GC Roots 可达。下面是实际项目中最常踩的六个坑。
function leak() {
name = '张玥'; // 漏写 let/const → window.name
this.data = new Array(1e6); // this 指向全局
}
/* ② 未清理的定时器 / 回调 */
const timer = setInterval(() => {
doSomething(hugeData); // 永久持有 hugeData
}, 1000);
// 组件卸载时必须 clearInterval(timer)
/* ③ 未解绑的 DOM 事件监听 */
element.addEventListener('click', handler);
// element 被移除后,监听器仍持有引用
// → element.removeEventListener('click', handler)
/* ④ 闭包持有大对象 */
function outer() {
const massive = new Array(1e7).fill('x');
return () => massive.length; // 闭包捕获 massive
}
// 只要返回的函数还被引用,massive 就永不释放
/* ⑤ 游离 DOM 引用 */
const cache = [];
cache.push(document.getElementById('node'));
// 即使 node 从 DOM 树移除,cache 仍持有它
/* ⑥ 控制台打印与 Map 缓存 */
console.log(bigObject); // DevTools 会保持引用
const map = new Map();
map.set(domNode, data); // 强引用,节点无法回收
识别场景,是治理泄漏的第一步
内存泄漏的检测与定位
看不到的东西无法优化。Chrome DevTools 提供了一整套内存分析工具,掌握它们就掌握了"内存显微镜"。
// 1. 打开 DevTools → Performance
// 2. 勾选 Memory 复选框
// 3. 录制 → 反复执行可疑操作 → 停止
// 4. 观察 JS Heap 曲线
// 锯齿形 → 正常(GC 在回收)
// 阶梯形 → 泄漏(每次操作后基线抬升)
/* 方法二:Heap Snapshot 三快照法 */
// ① 操作前:拍快照 A
// ② 执行可疑操作 10 次
// ③ 手动触发 GC(垃圾桶图标)
// ④ 拍快照 B,再操作 10 次,拍快照 C
// ⑤ 选 Comparison 视图,对比 B 与 C
// Delta 持续为正的对象 = 泄漏嫌疑
/* 方法三:Allocation Timeline */
// 实时记录内存分配栈
// 蓝色条 = 仍存活,灰色条 = 已回收
// 直接点开蓝条查看分配位置
/* 方法四:performance.memory(仅 Chrome) */
console.log(performance.memory);
// { usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit }
WeakMap / WeakSet / WeakRef:弱引用三剑客
ES6 引入的 WeakMap、WeakSet 以及 ES2021 的 WeakRef、FinalizationRegistry,为内存敏感场景提供了标准解法。它们持有的引用不阻止垃圾回收。
const wm = new WeakMap();
let node = document.querySelector('#card');
wm.set(node, { visits: 0 });
// node 被移除 DOM 并解除外部引用后
// WeakMap 中的条目会自动消失 → 不泄漏 ✅
// 对比 Map:强引用,会阻止回收 ❌
const strongMap = new Map();
strongMap.set(node, data); // node 被永久持有
/* WeakSet:存对象集合,不阻止回收 */
const visited = new WeakSet();
function walk(el) {
if (visited.has(el)) return;
visited.add(el);
// ... 遍历逻辑
}
/* WeakRef:不阻止回收的引用 */
const ref = new WeakRef(someBigObject);
// 需要时尝试取回,可能已被回收
const obj = ref.deref();
if (obj) { /* 仍然存活 */ }
/* FinalizationRegistry:回收后的回调 */
const registry = new FinalizationRegistry((id) => {
console.log(`资源 ${id} 已被回收`);
});
registry.register(bigObj, 'bigObj-1');
弱引用不是万能药,不要用它替代正常的生命周期管理
降低内存压力的编码实践
除了避免泄漏,我们还可以主动减少内存分配频率与对象体积,从源头降低 GC 压力。
// 反例:每帧创建新对象
function render() {
const pos = { x: 0, y: 0 }; // 每次都新建
}
// 正例:复用同一个对象
const posPool = { x: 0, y: 0 };
/* ② 及时解除引用 */
let bigData = await fetchLargeData();
process(bigData);
bigData = null; // 尽早释放
/* ③ 虚拟列表替代全量渲染 */
// 10000 条数据只渲染可视区域的 20 条
/* ④ 分片处理大任务,避免长任务 */
function chunkProcess(list, size = 1000) {
let i = 0;
function step() {
const end = Math.min(i + size, list.length);
for (; i < end; i++) processItem(list[i]);
if (i < list.length) requestIdleCallback(step);
}
step();
}
/* ⑤ 用 TypedArray 处理数值密集型数据 */
const buffer = new Float64Array(1000000);
// 相比普通数组,内存占用更低、无装箱开销
/* ⑥ 避免隐式类型转换与对象形状突变 */
// 反例:动态增删属性,破坏 Hidden Class
const p = {}; p.a = 1; p.b = 2; delete p.a;
// 正例:构造函数中一次性声明所有属性
function Point(x, y) { this.x = x; this.y = y; }
内存管理最佳实践总结
- 理解可达性:内存能否回收,取决于是否可从 GC Roots 到达,而非引用计数
- 成对操作原则:addEventListener / removeEventListener、setInterval / clearInterval、subscribe / unsubscribe 必须成对出现
- 组件卸载即清理:在 React、Vue 等框架中,善用 useEffect 清理函数、onUnmounted 钩子
- 缓存要有边界:任何缓存都必须有淘汰策略(LRU / TTL),无限增长的缓存等于泄漏
- 优先弱引用:需要把对象作为键做关联数据时,优先考虑 WeakMap
- 用数据说话:不要凭感觉优化,用 Performance 与 Heap Snapshot 定位真实瓶颈
- 警惕长任务:单次执行超过 50ms 的任务会阻塞渲染,也会推迟 GC 执行时机
- 真机验证:桌面浏览器内存充裕,移动端才是内存问题的真实战场
/* 能少分配就少分配 → 能早释放就早释放 */
/* 能弱引用就弱引用 → 能可测量就可优化 */
/* 一个完整的清理示例 */
class Component {
constructor(el) {
this.el = el;
this._onClick = this.handleClick.bind(this);
this.el.addEventListener('click', this._onClick);
this._timer = setInterval(() => this.tick(), 1000);
}
destroy() {
clearInterval(this._timer);
this.el.removeEventListener('click', this._onClick);
this.el = null;
this._timer = null;
this._onClick = null;
}
}