深拷贝与浅拷贝:从内存视角看JS变量拷贝的陷阱与解决之道

53 阅读7分钟

深拷贝与浅拷贝:从内存视角看JS变量拷贝的陷阱与解决之道

当我们初学JavaScript时,变量赋值是第一个接触的概念。let a = b;,这看起来简单直接,但背后隐藏着关于内存操作的巨大差异。理解这些差异,是避免许多隐蔽Bug、写出稳健代码的关键。今天,我们就从栈内存堆内存的视角出发,彻底搞懂JavaScript中的值拷贝引用拷贝(浅拷贝)以及如何实现真正的深拷贝

一、 舞台:程序运行的内存世界

任何程序的运行都需要内存空间。JavaScript引擎在执行代码时,会将内存划分为几个区域,其中与我们今日主题最相关的是栈(Stack) ​ 和堆(Heap)

1.1 栈内存:有序高效的“快捷酒店”

栈内存用于存储固定大小、生命周期短的变量

  • 特点

    • 连续存储:像一排整齐的酒店房间,每个房间大小固定,地址连续,管理起来非常高效。
    • 快速访问:由于地址连续,通过变量名查找值速度极快。
    • 后进先出(LIFO) :数据的存入和取出遵循严格的顺序,当前函数的局部变量在执行完毕后会自动被清除。
  • 居住客:基本数据类型(Number, String, Boolean, Null, Undefined, Symbol, BigInt)和指向堆内存中对象的引用(地址指针)

可以把栈内存想象成一个高档酒店的前台登记表。它不记录客人的所有行李细节,只记录客人的姓名(变量名) ​ 和对应的房间号(变量的值或堆内存地址)

1.2 堆内存:动态灵活的“大型仓库”

堆内存用于存储大小不固定、生命周期较长的变量,主要是对象(Object),包括数组(Array)、函数(Function)等。

  • 特点

    • 动态分配:像一个大仓库,可以根据需要申请任意大小的空间,非常弹性
    • 访问稍慢:存储不连续,需要通过栈中存储的“地址”去查找,好比根据仓库的提货单去找对应的货物。
    • 需要垃圾回收:不再被引用的对象会被JavaScript的垃圾回收机制自动清除,释放内存。
  • 居住客:所有引用类型的值本身。

继续上面的比喻,堆内存就是酒店身后的大型行李仓库。客人的所有行李(对象的属性和方法)都存放在这个仓库里。前台登记表(栈)上只写明了“客人大明的行李存放在A区101号货架”。

image.png

二、 剧情展开:值拷贝 vs 引用拷贝

理解了内存舞台,我们来看看变量赋值时上演的不同剧情。

2.1 场景一:基本数据类型的赋值(值拷贝)

let a = 1; // 在栈内存中开辟空间,存储值 1
let b = a; // 在栈内存中开辟【新空间】,将 a 的值 【复制一份】 给 b

console.log(a); // 1
console.log(b); // 1

b = 2; // 修改 b,只是在栈中修改了 b 名下的那个房间的值

console.log(a); // 1 - a 的值未受影响
console.log(b); // 2

内存解析

  1. 1.let a = 1;:栈内存中分配一个空间,标识为a,存入值1
  2. 2.let b = a;:栈内存中再分配一个新空间,标识为b,然后将a的值1复印一份,存入b的空间。
  3. 3.b = 2;:找到栈中b的空间,将里面的值修改为2。这个过程完全不影响a的空间。

这就是值拷贝。因为基本数据类型占用空间小、固定,直接复制整个值是最简单、最高效的方式。双方 thereafter 互不影响。

2.2 场景二:引用类型的赋值(引用拷贝/浅拷贝)

这里我们使用您文档中的例子:

const users = [{
    id: 1,
    name: '小明',
    hometown: '南昌'
}, {
    id: 2,
    name: '小红',
    hometown: '南昌'
}];

// 动态地向堆内存中的数组添加新元素
users.push({
    id: 3,
    name: '小芳',
    hometown: '进贤'
});

const data = users; // 关键一步!
data[0].hobbies = ['篮球', '看烟花']; // 通过 data 修改数组内的对象

console.log(data);
console.log(users); // 惊讶!users 也被修改了!

内存解析

  1. 1.const users = [...]

    • •首先,在堆内存中开辟空间,存储整个数组及其内部的各个对象。假设地址为0x123ABC
    • •然后,在栈内存中分配空间给变量users,存储的是堆内存地址0x123ABC(即一个引用)。
  2. 2.const data = users;

    • •这是在栈内存中分配一个新空间给变量data
    • •然后,将users栈中的值(即地址0x123ABC复制一份,交给data
    • •现在,usersdata这两个栈内存中的变量,持有的是同一个堆内存地址

);

javascript data[0].hobbies = ['篮球', '看烟花']; 

console.log(data[0].hobbies); // ['篮球', '看烟花'] 

console.log(users[0].hobbies); // undefined - 原对象毫发无损

原理剖析

  1. JSON.stringify(users)(序列化)

    • 传入原对象users
    • 这个方法会遍历users对象的所有可枚举属性,将其转换成一个JSON格式的字符串
    • 这个字符串是全新的,存在于内存中的另一个位置,它完整地描述了对象的结构和数据,但已经和原来的users引用脱钩。
  2. JSON.parse(jsonString)(反序列化)

    • 将上一步生成的JSON字符串作为参数传入。
    • 这个方法会解析这个字符串,并依据其中的信息,在堆内存中全新地创建一个对象,并分配新的地址。
    • 最后,将这个新对象的地址赋值给变量data

至此,datausers指向的是堆内存中两个完全独立、结构相同的对象。无论对data进行多么深入的修改,都不会影响到users

javascript const original = { 
name: "小明", 
birthday: new Date('2000-01-01'), // Date 对象 
sayHi: function() { 
console.log('Hi!'); 
}, // 函数 
undefinedProp: undefined, // undefined 
}; 
const shallowCopy = original; // 浅拷贝 
const deepCopy = JSON.parse(JSON.stringify(original)); // 深拷贝 console.log(original.birthday.getFullYear()); // 2000 // console.log(deepCopy.birthday.getFullYear()); // 报错!deepCopy.birthday 是字符串,不是 Date 对象 console.log(deepCopy.sayHi); // undefined,函数丢失 
console.log(deepCopy.undefinedProp); // undefined,属性本身都丢失了

3.2 JSON法的局限性(重要!)

正如您文档3中指出的,JSON.stringify并非完美,它在序列化过程中会“净化”数据,导致以下信息丢失:

  • 函数(Function):会被忽略。
  • undefined:会被忽略。
  • Symbol:会被忽略。
  • 特殊对象
    • Date对象:会被转换成日期字符串,反序列后无法自动转回Date对象。
    • RegExpMapSet等:序列化后可能得到空对象{},结构丢失。
  • 循环引用:如果对象内部的属性互相引用,形成环(例如 obj.self = obj),JSON.stringify会直接报错。

四、 总结与实践建议

拷贝方式特点性能使用场景
赋值(=)浅拷贝。只复制引用,共享堆内存数据。极高需要多个变量指向同一对象时。
浅拷贝只拷贝对象的第一层属性。如果属性是基本类型,则拷贝值;如果是引用类型,则拷贝地址。对象结构简单,且无嵌套对象/数组时。
深拷贝完全拷贝所有层级,创建完全独立的对象。较低(取决于数据大小)常用场景:需要独立修改对象副本而不影响原对象时,如状态管理、数据快照、函数参数处理等。

最佳实践建议

  • 默认警惕:对对象和数组进行赋值时,第一时间思考这是否是你想要的行为(共享还是独立?)。
  • 选择合适的工具
  • 如果对象是可序列化的(不含函数、undefined等),且不需要处理循环引用,JSON法是最简单的选择。
  • 如果项目已使用Lodash等工具库,直接使用_.cloneDeep
  • 如果环境支持且数据类型在structuredClone范围内,可以优先考虑它。
  • 对于极复杂的拷贝需求(如包含函数、循环引用),可能需要结合多种方法或实现自定义的拷贝逻辑。

理解内存管理是程序员进阶的必经之路。希望这篇从内存视角剖析拷贝问题的文章,能帮助你彻底理解=号背后的秘密,从而在开发中游刃有余,避免踩坑。