JavaScript 深拷贝全解析:从栈与堆内存机制到安全对象复制实践

178 阅读6分钟

引言

在 JavaScript 开发中,对象的复制是一个看似简单却极易出错的问题。很多初学者甚至中级开发者都曾因“浅拷贝”导致数据意外修改而陷入调试困境。本文将从底层内存机制出发,系统讲解 栈内存与堆内存的区别它们如何协同工作,并深入剖析 什么是真正的深拷贝,以及如何在实际开发中安全地实现它。


一、栈内存 vs 堆内存:程序运行的“骨架”与“血肉”

要真正理解深拷贝,必须先了解 JavaScript(以及其他高级语言)是如何管理内存的。程序运行时,内存主要分为两个区域:栈(Stack)堆(Heap)

1. 栈内存:自动管理的“储物架”

  • 特点

    • 遵循 先进后出(LIFO, Last In First Out) 原则。
    • 存储 函数调用上下文局部变量基本数据类型(如 numberstringbooleanundefinednullsymbolbigint)。
    • 内存分配和释放由系统 自动完成,效率极高。
  • 生命周期

    • 当一个函数被调用时,其局部变量会被压入栈中;
    • 函数执行完毕后,整个栈帧被弹出,内存自动释放。

✅ 举例:

function foo() {
  let a = 10;        // a 存在栈中
  let b = "hello";   // b 也存在栈中
}
foo(); // 执行结束后,a 和 b 自动销毁

2. 堆内存:手动(或半自动)管理的“大仓库”

  • 特点

    • 用于存储 复杂数据结构,如对象(Object)、数组(Array)、函数(Function)等。
    • 内存分配灵活,但访问速度略慢于栈。
    • 在 JavaScript 中,堆内存由 垃圾回收机制(GC) 管理,而非程序员手动释放。
  • 生命周期

    • 对象一旦创建,就存在于堆中;
    • 只有当没有任何引用指向该对象时,垃圾回收器才会将其回收。

✅ 举例:

let user = { name: "Alice", age: 25 };
// 对象 { name: "Alice", age: 25 } 存在于堆中
// 变量 user(在栈中)保存的是该对象的内存地址(引用)

二、栈与堆的协作:引用机制揭秘

JavaScript 中的对象操作本质上是 通过引用进行的。这种设计极大提升了性能,但也带来了“共享副作用”的风险。

关键关系:

  1. 栈存引用,堆存实体
    当你声明一个对象变量时,变量本身(引用)存储在栈中,而对象的实际内容存储在堆中
  2. 赋值即复制引用
    如果你将一个对象赋值给另一个变量,实际上只是复制了栈中的引用地址,两个变量指向同一个堆内存位置。

❗ 危险示例(浅拷贝陷阱):

const users = [
  { id: 1, name: '张三' ,hometown:'北京'},
  { id: 2, name: '李四' ,hometown:'上海'},
  { id: 3, name: '王五' ,hometown:'广州'}
];

const  data = users; 

data.hobbies = ['篮球','足球','跑步'];
console.log(data, users);

此时运行代码结果如下:

 data内容的修改同时发生在users和data上

  1. 生命周期解耦
    栈中变量的销毁(如函数结束)不会立即删除堆中的对象,只有当所有引用都消失后,对象才会被 GC 回收。

三、什么是深拷贝?为什么需要它?

定义

深拷贝(Deep Copy) 是指:递归地复制对象及其所有嵌套属性,在堆内存中创建一个全新的、完全独立的对象副本。新对象与原对象没有任何引用关联。

目标

  • 修改副本 不影响原对象
  • 副本拥有 完整的数据结构副本,包括嵌套对象、数组等。

四、实现深拷贝的常用方法

方法 1:JSON 序列化 + 反序列化(最简单但有限制)

这是前端开发中最常用的“伪深拷贝”技巧:

var users; 
var data;  
users = [  { id: 1, name: '张三' ,hometown:'北京'},  { id: 2, name: '李四' ,hometown:'上海'},  { id: 3, name: '王五' ,hometown:'广州'}]; 

// 深拷贝,是指在堆内存中,重新分配一个内存空间,存储拷贝的对象,而不是引用地址。
// 序列化 :把对象转换为字符串 JSON.stringify()
// 反序列化 :把字符串转换为对象 JSON.parse()
var data = JSON.parse(JSON.stringify(users));

data[0]['hobbies'] = ['篮球','足球','跑步'];
console.log(data, users);

运行结果:

优点

  • 代码简洁,一行搞定;
  • 对纯 JSON 兼容的数据结构非常有效。

致命缺陷

问题说明
函数丢失function 会被忽略(JSON.stringify 不处理函数)
undefined 丢失属性值为 undefined 的字段会被删除
Symbol 键丢失Symbol 作为 key 无法被序列化
循环引用崩溃对象自引用会导致 JSON.stringify 报错
Date 变字符串new Date() 会被转为 ISO 字符串,不再是 Date 对象
RegExp、Error 等特殊对象失效转为普通对象或空对象

方法 2:手写递归深拷贝(更健壮)

为了克服 JSON 方法的局限,我们可以手动实现一个支持更多类型的深拷贝函数:

// 手写递归深拷贝
users = [
  { id: 1, name: '张三' ,hometown:'北京'},
  { id: 2, name: '李四' ,hometown:'上海'},
  { id: 3, name: '王五' ,hometown:'广州'}
];

function deepClone(obj, hash = new WeakMap()) {
  // 处理 null 和非对象类型
  if (obj === null || typeof obj !== "object") return obj;

  // 防止循环引用
  if (hash.has(obj)) return hash.get(obj);

  // 处理 Date
  if (obj instanceof Date) return new Date(obj);

  // 处理 RegExp
  if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);

  // 处理 Array 和 Object
  const cloned = Array.isArray(obj) ? [] : {};

  // 记录引用,防止循环
  hash.set(obj, cloned);

  // 递归拷贝所有属性(包括 Symbol)
  Reflect.ownKeys(obj).forEach(key => {
    cloned[key] = deepClone(obj[key], hash);
  });

  return cloned;
}
const data = deepClone(users);
data[0]['hobbies'] = ['篮球','足球','跑步'];
console.log(data, users);

运行结果:

优势

  • 支持 DateRegExpSymbol 键;
  • 能处理循环引用(通过 WeakMap 缓存已拷贝对象);
  • 保留函数(若需要可扩展);
  • 更接近“真正”的深拷贝。

💡 提示:生产环境中建议使用成熟库(如 Lodash 的 _.cloneDeep),避免重复造轮子。


方法 3:使用第三方库(推荐生产环境)

  • Lodash: _.cloneDeep(value)
  • jQuery: $.extend(true, {}, obj)(已不推荐)
  • structuredClone() (现代浏览器原生支持)

新标准:structuredClone()

ES2022 引入了全局函数 structuredClone(),专为深拷贝设计:

const copy = structuredClone(original);

支持:

  • 循环引用
  • DateRegExpMapSetArrayBuffer
  • undefinedSymbol(部分限制)

注意:

  • 仍不支持函数、DOM 节点等;
  • 需要较新浏览器(Chrome 98+,Node.js 17+)

五、总结:何时用哪种拷贝?

场景推荐方法
简单对象,无函数/日期/循环引用JSON.parse(JSON.stringify(obj))
需要兼容旧环境,且结构复杂手写递归 or Lodash _.cloneDeep
现代项目,追求标准与性能structuredClone()
仅需第一层拷贝(浅拷贝){...obj}Object.assign({}, obj)

六、结语

深拷贝不仅是语法技巧,更是对 内存模型数据所有权 的深刻理解。掌握栈与堆的协作机制,能帮助我们写出更安全、更高效的代码。在实际开发中,请根据数据结构的复杂度和运行环境,选择最合适的拷贝策略。

记住
浅拷贝是“共用一本日记”,
深拷贝是“誊抄一本新日记”。
别让别人的涂改,毁了你的原始记录!