💡 前言:为什么会有深浅拷贝的纠结?
JS 将数据分为简单数据类型(原始值) 和 复杂数据类型(引用值) 。
- 简单类型:直接存放在栈(Stack)中,赋值是"值"的完全复制,不存在深浅拷贝的概念。
- 复杂类型 :具体内容存放在堆(Heap) 中,栈 中只存了一个指向堆内存的地址指针。
正因为复杂数据类型的这种"地址引用"特性,当我们试图复制一个对象或数组时,普通的赋值操作(如 let obj2 = obj1)仅仅是复制了栈内存中的指针,导致两个变量指向同一块堆内存。
深浅拷贝,正是为了解决复杂数据类型在复制时"要不要断开原对象引用"的问题而诞生的。
一、 浅拷贝
1. 什么是浅拷贝?
浅拷贝会创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。
- 如果属性是简单类型 ,拷贝的就是基本类型的值;
- 如果属性是复杂类型(引用类型) ,拷贝的就是内存地址。因此,如果其中一个对象改变了这个地址指向的对象,就会影响到另一个对象。
简单来说:浅拷贝只复制了对象的第一层结构,更深层次的引用类型依然共享同一个堆内存。
2. 常见实现方式
在 JS 中,以下操作都属于浅拷贝:
Object.assign()
JavaScript
const original = { name: 'Alice', details: { age: 25 } };
const copy = Object.assign({}, original);
copy.name = 'Bob'; // 修改第一层(简单类型)
copy.details.age = 30; // 修改第二层(引用类型)
console.log(original.name); // 'Alice' (没变,说明第一层隔离了)
console.log(original.details.age); // 30 (变了!说明深层依然共享)
扩展运算符(...)
JavaScript
const arr1 = [1, 2, { role: 'admin' }];
const arr2 = [...arr1];
arr2[2].role = 'user';
console.log(arr1[2].role); // 'user' (原数组被连锁修改)
数组方法 Array.prototype.slice() / concat()
JavaScript
const originalArr = [[1, 2], 3];
const copyArr = originalArr.slice();
copyArr[0][0] = 99;
console.log(originalArr[0][0]); // 99
二、 深拷贝
1. 什么是深拷贝?
深拷贝会开辟一块完全独立的堆内存空间,不仅复制目标对象的第一层属性,还会递归地将对象中所有层级的引用类型属性全部复制一遍,直到全都是简单数据类型为止。
简单来说:深拷贝实现修改新对象绝对不会影响旧对象。
2. 常见实现方式
投机取巧法:JSON.parse(JSON.stringify())
这是开发中最常用的快捷方式。先将对象转成字符串(此时断开了所有引用),再转回对象(重新在堆中开辟空间)。
JavaScript
const original = { name: 'Alice', details: { age: 25 } };
const deepCopy = JSON.parse(JSON.stringify(original));
deepCopy.details.age = 30;
console.log(original.details.age); // 25 (成功隔离,原对象没变!)
⚠️ 致命缺陷:
- 会忽略
undefined、Symbol、Function。 - 遇到
RegExp(正则表达式)会变成空对象{}。 - 遇到
Date对象会变成字符串。 - 无法解决循环引用(对象内部属性引用了对象自己,会导致报错)。
现代标准解法:structuredClone()
现代浏览器和 Node.js(v17+)内置的原生深拷贝 API。它克服了 JSON 方法的绝大多数缺陷。
JavaScript
const original = {
date: new Date(),
reg: /abc/g,
details: { age: 25 }
};
const deepCopy = structuredClone(original);
console.log(deepCopy.date instanceof Date); // true (保留了内置对象类型)
⚠️ 局限: 依然不能拷贝 Function 和 DOM 节点。
手写递归深拷贝
JavaScript
function deepClone(obj, hash = new WeakMap()) {
// 1. 如果是基本数据类型(String, Number, Boolean, Undefined, Symbol, BigInt)或者是 null
// 直接返回其值,因为它们在栈中赋值本身就是安全的
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 2. 处理特殊的内置引用对象:Date 和 RegExp
// 如果直接遍历它们,会丢失原本的日期值或正则规则,变成空对象
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj);
// 3. 解决循环引用(Circular Reference)
// 如果这个对象之前已经被拷贝过一次,直接去 hash 缓存表里拿出来返回,不再重复拷贝
if (hash.has(obj)) {
return hash.get(obj);
}
// 4. 初始化克隆对象
// 利用 obj.constructor() 可以动态保持原对象的原型链和类型(自动区分是 {} 还是 [])
const cloneObj = new obj.constructor();
// 5. 将当前对象和对应的克隆对象存入 WeakMap 缓存表中
// 必须在开始遍历子属性之前存入,否则遇到循环引用时无法命中缓存
hash.set(obj, cloneObj);
// 6. 遍历对象的所有属性(包括普通的 String 键和特殊的 Symbol 键)
// Reflect.ownKeys() 可以完美替代 Object.keys() + Object.getOwnPropertySymbols()
Reflect.ownKeys(obj).forEach(key => {
// 递归调用 deepClone:如果子属性还是对象,就继续深拷贝;如果是基本类型,直接赋值
// 同时要把 hash 表继续往下传递
cloneObj[key] = deepClone(obj[key], hash);
});
// 7. 返回拷贝完成的对象
return cloneObj;
}
测试代码
JavaScript
// 1. 构造一个包含各种奇葩类型的测试对象
const target = {
num: 1,
str: 'hello',
bool: true,
undef: undefined,
nullVal: null,
date: new Date(),
reg: /^\d+$/gi,
arr: [10, 20, { score: 100 }],
obj: { name: '内部对象' },
[Symbol('id')]: '这是一个Symbol属性' // Symbol 键
};
// 2. 制造循环引用:让 target 的 circle 属性指向它自己
target.circle = target;
// 3. 执行深拷贝
const result = deepClone(target);
// 4. 验证隔离性与正确性
result.arr[2].score = 999;
result.obj.name = '被修改了';
console.log(target.arr[2].score); // 100 ------ 原对象没变,说明数组内部的对象成功断开引用!
console.log(target.obj.name); // '内部对象' ------ 成功隔离!
console.log(result.circle === result); // true ------ 成功处理了循环引用,没有死循环报错!
console.log(result.date instanceof Date); // true ------ 正确保留了 Date 类型
总结
正是因为复杂数据类型存储的是堆内存地址,才导致了我们在复制时需要区分"仅仅复制地址(浅拷贝)"还是"复制整座仓库(深拷贝)"。
- 性能启示 :由于深拷贝需要递归遍历并频繁在堆内存中开辟新空间,它的内存和时间开销远大于浅拷贝。因此,在处理海量数据时,除非确有必要,否则应当尽量避免滥用深拷贝。