写好 JavaScript 不仅要理解作用域和原型链,更要摸清数据在内存中的流转方式。本文将从深浅拷贝出发,延伸到垃圾回收机制,最后通过 WeakMap 与 WeakSet 揭示弱引用的巧妙设计------帮你打通 JavaScript 内存管理的"任督二脉"。
前言
你是否遇到过这样的 bug:明明复制了一个对象,修改副本却把原对象也改了?或者写了一个长期运行的应用,内存占用不断攀升,最终页面卡死?这些问题背后,都指向同一个核心主题------JavaScript 的内存与引用。
本文将围绕三个密切相关的话题展开:
- 深浅拷贝:理解值传递与引用传递的本质差异。
- 垃圾回收:掌握引擎如何自动清理无用内存。
- WeakMap / WeakSet:利用弱引用优化内存敏感的场景。
三者看似独立,实则环环相扣。让我们一起深入底层,写出更健壮、更高效的代码。
一、深浅拷贝:复制背后的内存真相
1.1 数据类型与内存存储
JavaScript 数据类型分为两类:
- 原始类型 :
string、number、boolean、null、undefined、symbol、bigint。它们直接存储在栈内存中,赋值时复制的是"值"本身。 - 引用类型 :
object(包括数组、函数、日期等)。它们的实际数据存储在堆内存中,变量保存的只是一个内存地址(指针)。
javascript
let a = 42; // 栈中存值 42
let b = a; // 将 42 复制给 b,独立
b = 100;
console.log(a); // 42 ✅ 不受影响
let obj1 = { name: 'Alice' };
let obj2 = obj1; // 复制的是地址,obj2 和 obj1 指向同一块堆内存
obj2.name = 'Bob';
console.log(obj1.name); // 'Bob' ❌ 原对象被修改了
这就是"浅拷贝"产生的根源------只复制了引用,没有复制真正的对象。
1.2 浅拷贝的实现与局限
浅拷贝会创建一个新对象,但只复制第一层属性。如果属性值是原始类型,则互不影响;如果属性值是引用类型,则新旧对象共享该引用。
常见浅拷贝方法:
javascript
// 展开运算符
const copy1 = { ...original };
// Object.assign
const copy2 = Object.assign({}, original);
// 数组的 slice / concat
const arrCopy = originalArr.slice();
// 手写浅拷贝
function shallowClone(obj) {
const result = Array.isArray(obj) ? [] : {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
result[key] = obj[key];
}
}
return result;
}
局限演示:
javascript
const user = {
name: 'Alice',
address: { city: 'Beijing', zip: 100000 }
};
const copy = { ...user };
copy.address.city = 'Shanghai';
console.log(user.address.city); // 'Shanghai' ❌ 内部对象仍被共享
1.3 深拷贝:彻底分离
深拷贝会递归复制所有层级的属性,生成完全独立的对象。
方法一:JSON.parse(JSON.stringify(obj))(常用但有坑)
javascript
const deepCopy = JSON.parse(JSON.stringify(user));
局限性:
- 无法复制
undefined、函数、Symbol。 - 无法处理循环引用(会报错)。
- 会丢失
Date、RegExp、Map、Set等特殊对象的结构(变成普通对象或字符串)。
javascript
const obj = {
fn: () => console.log('hi'),
date: new Date()
};
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy); // { date: "2025-..." } 函数丢失,日期变字符串
方法二:递归实现(处理基础类型 + 数组/普通对象)
javascript
function deepClone(value, hash = new WeakMap()) {
// 处理原始类型和 null
if (value === null || typeof value !== 'object') return value;
// 处理循环引用
if (hash.has(value)) return hash.get(value);
// 处理数组和对象
const result = Array.isArray(value) ? [] : {};
hash.set(value, result);
for (let key in value) {
if (value.hasOwnProperty(key)) {
result[key] = deepClone(value[key], hash);
}
}
return result;
}
方法三:structuredClone(现代浏览器的原生深拷贝)
javascript
const clone = structuredClone(original);
支持大多数内置类型(Date、RegExp、Map、Set、ArrayBuffer 等),也能处理循环引用。但不支持函数、Symbol、DOM 节点。
选择建议 :日常简单数据用
JSON方法;复杂场景用structuredClone或成熟的库(如 Lodash 的_.cloneDeep)。
二、垃圾回收:引擎如何自动管理内存
2.1 可达性概念
JavaScript 的内存管理是自动的,其核心思想是可达性 :从根对象(如 window、globalThis、执行栈中的变量)出发,能够通过引用链访问到的对象,就是"可达"的,不会被回收;反之,不可达的对象会被标记为垃圾,择机清除。
javascript
let obj = { data: new Array(10000) }; // obj 可达
obj = null; // 原先的对象没有引用了 → 变为不可达 → 等待回收
2.2 垃圾回收算法:标记清除(Mark-Sweep)
现代 JavaScript 引擎(V8、SpiderMonkey)主要采用标记清除算法,配合分代回收、增量标记等优化。
- 标记阶段:从根对象开始,递归遍历所有可达对象,并打上标记。
- 清除阶段:遍历堆内存,将没有标记的对象内存释放。
引用计数(早期 IE 使用)存在循环引用问题,已不再作为主流方案:
javascript
function leak() {
let a = {};
let b = {};
a.ref = b;
b.ref = a; // 互相引用,计数永远不为 0 → 内存泄漏
}
2.3 常见内存泄漏场景
即使有垃圾回收,不当的代码仍会造成内存泄漏。
-
意外的全局变量
javascriptfunction foo() { bar = 'leak'; // 未声明,挂在全局 } -
未清理的定时器或事件监听
javascriptsetInterval(() => { // 引用了 DOM 元素或其他大对象,组件销毁后未清除定时器 }, 1000); -
闭包持有大数组
javascriptfunction outer() { const bigData = new Array(1000000); return function inner() { console.log(bigData.length); // inner 一直引用 bigData }; } const fn = outer(); // bigData 无法释放 -
离屏 DOM 引用
javascriptlet detachedDiv = document.getElementById('removed'); document.body.removeChild(detachedDiv); // detachedDiv 变量仍然引用该 DOM,导致无法回收
2.4 如何主动辅助垃圾回收
- 将不再使用的变量赋值为
null。 - 使用
WeakMap或WeakSet(见下一节)。 - 在开发工具 Performance 面板中记录内存快照,分析 retained size。
三、WeakMap 与 WeakSet:弱引用的智慧
3.1 弱引用的含义
弱引用不会阻止垃圾回收器回收一个对象。也就是说,如果一个对象只被 WeakMap 或 WeakSet 引用,而没有其他强引用,那么它随时可能被回收。
与之对应,Map 和 Set 持有的是强引用------只要键/值还在 Map/Set 中,对象就不会被回收。
3.2 WeakMap 的特性与 API
- 键必须是对象(不能是原始值)。
- 键是弱引用,值可以是任意类型。
- 不可迭代,没有
size属性,无法forEach。这确保了回收时机对外不可知。
javascript
let obj = { name: 'data' };
const wm = new WeakMap();
wm.set(obj, 'some metadata');
obj = null; // 原始对象失去强引用
// 下一次 GC 后,wm 中的对应条目会自动消失
3.3 典型应用场景
场景一:存储 DOM 元素的私有数据
javascript
const elementData = new WeakMap();
document.querySelectorAll('.btn').forEach(btn => {
elementData.set(btn, { clicks: 0 });
btn.addEventListener('click', () => {
const data = elementData.get(btn);
data.clicks++;
console.log(`Clicked ${data.clicks} times`);
});
});
// 当 btn 被从 DOM 移除且无其他引用时,关联的元数据会自动回收,无需手动清理。
场景二:缓存计算结果(避免内存膨胀)
javascript
const cache = new WeakMap();
function process(obj) {
if (!cache.has(obj)) {
const result = /* 昂贵计算 */ obj.name + ' processed';
cache.set(obj, result);
}
return cache.get(obj);
}
// 当 obj 不再使用时,缓存条目自动消失,防止缓存无限增长。
场景三:保存私有字段(结合闭包)
javascript
const _private = new WeakMap();
class Person {
constructor(name) {
_private.set(this, { name });
}
getName() {
return _private.get(this).name;
}
}
// 外部无法访问私有数据,且 Person 实例销毁后私有数据自动回收
3.4 WeakSet 简介
WeakSet 的值只能是对象,且弱引用。没有 size、不可迭代。常用于标记对象是否"处理过",避免重复处理,同时不影响垃圾回收。
javascript
const processed = new WeakSet();
function handle(item) {
if (processed.has(item)) return;
processed.add(item);
// 执行处理逻辑...
}
3.5 Map/Set 与 WeakMap/WeakSet 对比表
| 特性 | Map / Set | WeakMap / WeakSet |
|---|---|---|
| 键类型 | 任意值 | 必须是对象 |
| 引用类型 | 强引用 | 弱引用 |
| 可迭代 | 是(keys()等) |
否 |
size 属性 |
有 | 无 |
| 内存泄漏风险 | 需手动删除 | 自动避免(前提是无强引用) |
| 典型用途 | 缓存、集合运算 | DOM 关联、私有数据、临时标记 |
四、三者关联:一张图串起内存管理
┌─────────────────┐ ┌─────────────────┐
│ 深浅拷贝 │ │ 垃圾回收 │
│ ───────────── │ │ ──────────── │
│ • 值 vs 引用 │ │ • 可达性 │
│ • 浅拷贝共享 │ ──→ │ • 标记清除 │
│ • 深拷贝隔离 │ │ • 内存泄漏场景 │
└────────┬────────┘ └────────┬────────┘
│ │
│ (深拷贝断开引用链) │ (弱引用不阻止回收)
│ │
▼ ▼
┌─────────────────────────────────────────┐
│ WeakMap / WeakSet │
│ ─────────────────────────────────── │
│ • 键对象弱引用,配合 GC 自动回收 │
│ • 解决缓存/事件监听中的内存泄漏 │
└─────────────────────────────────────────┘
- 深浅拷贝决定了对象之间是否共享引用------错误的拷贝方式可能导致意外的共享(或性能开销)。
- 垃圾回收自动清理不可达对象,但开发者需要避免"无意识"的强引用(如全局变量、闭包)。
- WeakMap / WeakSet 提供了"自愿被回收"的引用方式,是解决特定内存问题(如 DOM 缓存、私有数据)的利器。
理解这三者,你就能写出既符合逻辑、又对内存友好的 JavaScript 代码。
结语
从深浅拷贝的"引用陷阱",到垃圾回收的"自动幕后",再到 WeakMap/WeakSet 的"弱引用魔法"------JavaScript 的内存模型并不玄学,它有着清晰的设计原则。希望这篇文章能帮助你在日常开发中:
- 正确选择拷贝方式,避免副作用。
- 主动排查内存泄漏,提升应用稳定性。
- 在合适的场景使用
WeakMap/WeakSet,写出更优雅、更高效的代码。
内存管理是优秀前端工程师的分水岭之一。现在,你已经拿到了跨越它的钥匙。🔑
