从影子到实体:浅拷贝与深拷贝的解析
前言
在 JavaScript 中,存在多种复制对象的方法,那么什么样的复制方式为浅拷贝,什么样为深拷贝呢?下面我们就来了解一下。
先了解数据存储
在js中,变量的值可以分为两种类型:原始类型和引用类型。原始类型包括数字、字符串、布尔值、null 和 undefined,它们都是按值存储的,即每个变量都有一个独立的内存空间用于存储值。而引用类型则是指对象、数组、函数等复杂数据类型,它们在内存中以引用的方式存储,即变量存储的是对象的地址,而非对象本身。
原始类型的存储结构
对于原始类型的值,JavaScript 引擎会将它们直接存储在栈中。栈是一种线性结构,采用后进先出(LIFO)的方式存储数据。每个栈帧包含了当前函数的局部变量及其参数,当函数执行完毕后,该栈帧被弹出,其中的数据也随之消失。因此,原始类型的值具有不可变性,即一旦赋值就无法更改。
例如,当我们定义一个数字变量 a
时,JavaScript 引擎会在栈中分配一段内存空间,并将其值(假设为 10
)存储在其中:
let a = 10;
// 会在栈中分配一段内存空间,存储值为 10 的数字变量 a
当我们将 a
的值赋给另一个变量 b
时,JavaScript 引擎会在栈中为 b
分配一段新的内存空间,并将 a
的值(即 10
)复制到该空间中:
let b = a;
// 会在栈中分配一段新的内存空间,存储值为 10 的数字变量 b
// 并将 a 的值(即 10)复制到该空间中
由于原始类型的值具有不可变性,因此对 a
或 b
的修改都只会影响到对应的变量,而不会影响到另一个变量:
a = 20;
console.log(b); // 10
引用类型的存储结构
对于引用类型的值,JavaScript 引擎会在堆中为其分配一段内存空间,并将其地址存储在栈中。堆是一种非线性结构,以链表的形式存储数据,每个节点包含了对象的属性和方法等信息。当我们创建一个对象时,JavaScript 引擎会在堆中为其分配一段连续的内存空间,并返回该空间的地址作为对象的引用。
例如,当我们定义一个对象变量 obj
时,JavaScript 引擎会在堆中为其分配一段内存空间,并在栈中存储该空间的地址:
let obj = { name: 'John', age: 30 };
// 会在堆中分配一段内存空间,存储 { name: 'John', age: 30 } 对象
// 并在栈中存储该空间的地址,即 obj 变量的值
当我们将 obj
的值赋给另一个变量 obj2
时,JavaScript 引擎会将 obj
的地址复制给 obj2
,即 obj2
和 obj
指向了同一个对象:
let obj2 = obj;
// 会将 obj 的地址复制给 obj2,它们指向同一个对象
由于 obj
和 obj2
指向同一个对象,在修改对象的属性时,两者都会受到影响:
obj.age = 40;
console.log(obj2.age); // 40
这就是浅拷贝的实现机制,即复制了对象的引用,而不是对象本身。如果我们想要实现深拷贝,则需要递归地遍历对象的所有属性和嵌套对象,并为它们分配新的内存空间。这样可以确保复制的对象与原始对象完全独立,互不影响。
所以,接下来我们详细介绍一下浅拷贝和深拷贝!
浅拷贝
浅拷贝是指创建一个新对象或数据结构,并将原始对象的引用复制给新对象。换句话说,浅拷贝只复制了对象的引用而不是对象本身。这意味着新旧对象仍然共享相同的内存地址,对其中一个对象的修改会影响到另一个对象。
常见浅拷贝方式
-
Object.create(x)
: 这种方式通过创建一个新对象,并将原始对象的属性复制到新对象中来进行浅拷贝。const x = { a: 1, b: 2 }; const clone = Object.create(x); console.log(clone); // {} console.log(clone.a); // 1 console.log(clone.b); // 2
-
Object.assign({}, a)
: 使用Object.assign()方法可以将一个或多个源对象的属性复制到目标对象中。在这种方式中,我们创建了一个空对象作为目标对象,并将原始对象的属性复制到新对象中。const a = { x: 1, y: 2 }; const clone = Object.assign({}, a); console.log(clone); // { x: 1, y: 2 }
-
[].concat(x)
: 使用数组的concat()方法可以将一个或多个数组连接起来,并返回一个新的数组。const x = [1, 2, 3]; const clone = [].concat(x); console.log(clone); // [1, 2, 3]
-
[...arr]
: 使用扩展运算符(spread operator)可以将一个可迭代对象(如数组)展开为独立的元素,并用于创建一个新的数组。const arr = [1, 2, 3]; const clone = [...arr]; console.log(clone); // [1, 2, 3]
-
slice()
: 数组的slice()方法可以返回一个新数组,包含原始数组的指定部分。const arr = [1, 2, 3]; const clone = arr.slice(); console.log(clone); // [1, 2, 3]
浅拷贝方式只能复制一层对象或数组的属性,如果原始对象或数组中包含引用类型的属性,则复制的结果仍然是引用,修改其中一个对象或数组会影响到其他对象或数组。
手写实现浅拷贝
实现原理
-
借助for in 遍历原对象,将原对象的属性值增加到新对象中
-
因为for in 会遍历到对象隐式具有的属性,通常要使用obj.hasOwnProperty(key)来判断要拷贝的属性是否是自身显示具有的属性
实现代码
function shallow(obj) {
let newObj = {}
for (let key in obj) {
if (obj.hasOwnProperty(key)) { //判断是否是自身显示属性
newObj[key] = obj[key];
}
}
return newObj;
}
深拷贝
深拷贝是指创建一个全新的对象或数据结构,并递归地复制原始对象的所有值和嵌套对象,而不是仅仅复制引用。这样,新对象在内存中有一个独立的副本,对任何对象的修改都不会影响到其他对象。
常见深拷贝方式
JSON.parse(JSON.stringify(obj))
:
执行 JSON.stringify(obj)
会将对象序列化为 JSON 格式的字符串。然后执行 JSON.parse(jsonStr)
方法将 JSON 字符串解析为新的对象。由于 JSON 格式的字符串是一个新的字符串,因此解析出来的对象与原始对象没有任何关联,即完成了深拷贝操作。
示例:
const obj = {
name: 'John',
age: 25,
func: function() {
console.log('Hello');
}
};
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy);
缺点和限制:
-
不能识别 BigInt:BigInt 是 JavaScript 中的一种数据类型,表示任意精度的整数。但是在使用
JSON.stringify()
时,BigInt 类型的值会被转换为字符串,导致丢失精度。在进行拷贝时,被转换为字符串的 BigInt 值会变成普通的字符串类型。 -
不能拷贝函数、Symbol、undefined 类型的值:
JSON.stringify()
会忽略函数、Symbol 和 undefined 类型的属性值,因此在进行拷贝时这些值会被丢失。 -
不能处理循环引用:如果原始对象中存在循环引用(即某个属性的值引用了对象自身),则在进行拷贝时会导致无限递归,最终抛出异常。
structuredClone()
:
structuredClone()
是 HTML5 中新增的 API,可以实现深拷贝操作,支持拷贝不同类型的数据,包括函数、Symbol 和循环引用等。
示例:
// 创建一个对象
const obj = {
name: "John",
age: 30,
hobbies: ["reading", "coding"],
greet: function() {
console.log("Hello!");
}
};
// 使用 structuredClone() 方法进行深拷贝
const clonedObj = structuredClone(obj);
// 输出原始对象和深拷贝后的对象
console.log(obj);
console.log(clonedObj);
// 修改原始对象的属性
obj.name = "Jane";
obj.hobbies.push("swimming");
// 输出修改后的原始对象和深拷贝后的对象
console.log(obj);
console.log(clonedObj);
缺点和限制:
-
在同一个页面中执行
structuredClone()
操作,会对内存使用造成较大影响。 -
该方法只能在浏览器(目前只有谷歌浏览器)环境中使用,无法在 Node.js 等非浏览器环境下使用。
手写实现深拷贝
实现原理
-
借助for in 遍历原对象,将原对象的属性值增加到新对象中
-
因为for in 会遍历到对象隐式具有的属性,通常要使用obj.hasOwnProperty(key)来判断要拷贝的属性是否是自身显示具有的属性
-
如果遍历到的属性值是原始值类型,直接往新对象中赋值,如果是引用类型,递归创建新的子对象
实现代码
function deepClone(obj) {
if (obj === null || typeof obj !== "object") {
return obj;
}
const clone = Array.isArray(obj) ? [] : {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
if (typeof obj[key] === "function") {
clone[key] = Function(`return ${obj[key].toString()}`)();
} else {
clone[key] = deepClone(obj[key]);
}
}
}
return clone;
}
只是简易版,对于不同类型数据,还需进行详细划分!
赋值、浅拷贝与深拷贝的区别
总结如下表:
特点 | 赋值 | 浅拷贝 | 深拷贝 |
---|---|---|---|
基本概念 | 将一个变量的值(或引用)赋给另一个变量 | 创建一个新对象,并将原始对象的属性值复制到新对象,但不复制对象引用的对象本身 | 创建一个新对象,并将原始对象的所有属性值及其引用的对象都进行复制,形成完全独立的新对象 |
指针关系 | 赋值后,两个变量指向同一个内存地址(对于引用类型) | 浅拷贝后,新对象与原始对象具有相同的属性值(对于引用类型,指向相同的内存地址) | 深拷贝后,新对象与原始对象在内存中是独立的,互不干扰 |
内存占用 | 无额外内存开销(对于基本类型),引用类型仅增加一个引用 | 可能有额外的内存开销,具体取决于对象的复杂度 | 会有额外的内存开销,因为创建了完全独立的新对象及其引用的对象 |
引用类型处理 | 引用类型变量赋值后,两个变量共享同一个对象 | 引用类型属性浅拷贝后,两个对象共享同一个子对象(如果属性是引用类型) | 引用类型属性深拷贝后,两个对象的子对象也是完全独立的 |
修改影响 | 修改原始对象会影响所有指向它的变量 | 修改浅拷贝后的对象,可能会影响原始对象(如果修改了引用类型的属性) | 修改深拷贝后的对象,不会影响原始对象 |
最后
掌握深拷贝和浅拷贝,不要轻易掉进复制陷阱里!
转载自:https://juejin.cn/post/7371358964547682319