JavaScript 内存与引用:深究深浅拷贝、垃圾回收与 WeakMap/WeakSet

写好 JavaScript 不仅要理解作用域和原型链,更要摸清数据在内存中的流转方式。本文将从深浅拷贝出发,延伸到垃圾回收机制,最后通过 WeakMap 与 WeakSet 揭示弱引用的巧妙设计------帮你打通 JavaScript 内存管理的"任督二脉"。

前言

你是否遇到过这样的 bug:明明复制了一个对象,修改副本却把原对象也改了?或者写了一个长期运行的应用,内存占用不断攀升,最终页面卡死?这些问题背后,都指向同一个核心主题------JavaScript 的内存与引用

本文将围绕三个密切相关的话题展开:

  • 深浅拷贝:理解值传递与引用传递的本质差异。
  • 垃圾回收:掌握引擎如何自动清理无用内存。
  • WeakMap / WeakSet:利用弱引用优化内存敏感的场景。

三者看似独立,实则环环相扣。让我们一起深入底层,写出更健壮、更高效的代码。


一、深浅拷贝:复制背后的内存真相

1.1 数据类型与内存存储

JavaScript 数据类型分为两类:

  • 原始类型stringnumberbooleannullundefinedsymbolbigint。它们直接存储在栈内存中,赋值时复制的是"值"本身。
  • 引用类型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
  • 无法处理循环引用(会报错)。
  • 会丢失 DateRegExpMapSet 等特殊对象的结构(变成普通对象或字符串)。
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);

支持大多数内置类型(DateRegExpMapSetArrayBuffer 等),也能处理循环引用。但不支持函数、Symbol、DOM 节点。

选择建议 :日常简单数据用 JSON 方法;复杂场景用 structuredClone 或成熟的库(如 Lodash 的 _.cloneDeep)。


二、垃圾回收:引擎如何自动管理内存

2.1 可达性概念

JavaScript 的内存管理是自动的,其核心思想是可达性 :从根对象(如 windowglobalThis、执行栈中的变量)出发,能够通过引用链访问到的对象,就是"可达"的,不会被回收;反之,不可达的对象会被标记为垃圾,择机清除。

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 常见内存泄漏场景

即使有垃圾回收,不当的代码仍会造成内存泄漏。

  1. 意外的全局变量

    javascript 复制代码
    function foo() {
      bar = 'leak'; // 未声明,挂在全局
    }
  2. 未清理的定时器或事件监听

    javascript 复制代码
    setInterval(() => {
      // 引用了 DOM 元素或其他大对象,组件销毁后未清除定时器
    }, 1000);
  3. 闭包持有大数组

    javascript 复制代码
    function outer() {
      const bigData = new Array(1000000);
      return function inner() {
        console.log(bigData.length); // inner 一直引用 bigData
      };
    }
    const fn = outer(); // bigData 无法释放
  4. 离屏 DOM 引用

    javascript 复制代码
    let detachedDiv = document.getElementById('removed');
    document.body.removeChild(detachedDiv);
    // detachedDiv 变量仍然引用该 DOM,导致无法回收

2.4 如何主动辅助垃圾回收

  • 将不再使用的变量赋值为 null
  • 使用 WeakMapWeakSet(见下一节)。
  • 在开发工具 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,写出更优雅、更高效的代码。

内存管理是优秀前端工程师的分水岭之一。现在,你已经拿到了跨越它的钥匙。🔑


立即进入

相关推荐
Mr_Xuhhh2 小时前
Java泛型进阶:从基础到高级特性完全指南
开发语言·windows·python
He1955013 小时前
wordpress搭建块
开发语言·wordpress·古腾堡·wordpress块
老天文学家了3 小时前
蓝桥杯备战Python
开发语言·python
赫瑞3 小时前
数据结构中的排列组合 —— Java实现
java·开发语言·数据结构
初夏睡觉4 小时前
c++1.3(变量与常量,简单数学运算详解),草稿公放
开发语言·c++
升职佳兴4 小时前
C盘爆满自救:3步无损迁移应用数据到E盘(含回滚)
c语言·开发语言
ID_180079054734 小时前
除了 Python,还有哪些语言可以解析 JSON 数据?
开发语言·python·json
cyclv4 小时前
无网络地图展示轨迹,地图瓦片下载,绘制管线
前端·javascript
周末也要写八哥4 小时前
多进程和多线程的特点和区别
java·开发语言·jvm