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

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

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

  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。
  • 使用 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,写出更优雅、更高效的代码。

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


立即进入

相关推荐
波力海苔夹心脆6759 小时前
C# WPF 资源字典详解:ResourceDictionary、MergedDictionaries 与自定义控件案例
开发语言·c#·wpf·入门教程·resource·资源字典
不会算法的笨小孩10 小时前
异常的使用以及会出现的问题
开发语言·c++·c++11·异常
Penge66610 小时前
《Go 后端开发者学 TypeScript:typescript 类型断言》
javascript
Penge66610 小时前
《Go 后端开发者学 TypeScript:Symbol》
javascript
Hhy_110712 小时前
《C++深度解构06》手撕 string —— 从底层内存管理到深浅拷贝彻底吃透
c语言·开发语言·c++·学习·visual studio
starzy199012 小时前
深入理解Scala函数定义:从方法到函数的类型跨越
开发语言·scala
starzy199012 小时前
深入理解Scala递归函数:从栈溢出到尾调用优化
开发语言·scala
三*一12 小时前
Mapbox‑GL JS 多边形折点编辑工具:实现原理、功能拆解与踩坑总结
开发语言·javascript·ecmascript
YSL07012412 小时前
C++基础
开发语言·c++
若亦_12 小时前
Java中关于this的三种用法
java·开发语言·intellij-idea