摘要
在JavaScript的日常开发和面试中,“深拷贝”与“浅拷贝”无疑是高频词汇。它们不仅是理解JavaScript内存管理和数据操作的关键,更是区分初级与高级前端工程师的重要标志。本文将以面试官的视角,从JavaScript的内存分配机制入手,深入剖析深拷贝与浅拷贝的本质区别、各种实现方式及其背后的原理,并通过丰富的代码示例和面试场景,帮助读者彻底掌握这一核心概念,从而在面试中游刃有余,并在实际开发中避免潜在的“坑”。
1. 从内存机制理解赋值、引用与拷贝
要理解深浅拷贝,首先需要对JavaScript的数据类型和内存分配有一个清晰的认识。
1.1 简单数据类型与复杂数据类型
JavaScript中的数据类型分为两大类:
-
简单数据类型(基本数据类型) :
Number、String、Boolean、Undefined、Null、Symbol(ES6新增)、BigInt(ES2020新增)。- 存储方式:这些类型的值直接存储在栈内存中。当进行赋值操作时,是直接将值复制一份给新变量。
-
复杂数据类型(引用数据类型) :
Object(包括Array、Function、Date、RegExp等)。- 存储方式:这些类型的值存储在堆内存中,而变量本身存储的是一个指向堆内存中实际值的引用地址(指针),存储在栈内存中。当进行赋值操作时,复制的是这个引用地址,而不是堆内存中的实际值。
1.2 赋值操作的本质
-
简单数据类型赋值:
let a = 10; let b = a; // b 复制了 a 的值,b 现在是 10 b = 20; // 修改 b 不会影响 a console.log(a); // 10 console.log(b); // 20此时,
a和b在栈内存中各自拥有独立的10和20。 -
复杂数据类型赋值(引用) :
let obj1 = { name: "Alice" }; let obj2 = obj1; // obj2 复制了 obj1 的引用地址,两者指向同一个堆内存对象 obj2.name = "Bob"; // 通过 obj2 修改了堆内存中的对象 console.log(obj1.name); // "Bob" (obj1 也受到了影响) console.log(obj2.name); // "Bob"此时,
obj1和obj2在栈内存中存储的是同一个引用地址,它们共同指向堆内存中的同一个对象。因此,通过任何一个变量修改对象,都会影响到另一个变量。这正是
readme.md中提到的“赋值和引用的概念”以及“简单数据类型和复杂数据类型内存分配不一样”的核心。
2. 浅拷贝(Shallow Copy):只拷贝一层
浅拷贝是指创建一个新对象,这个新对象拥有原始对象的所有属性值。如果属性值是基本数据类型,那么新对象和原始对象各自拥有独立的属性值;但如果属性值是引用数据类型,那么新对象和原始对象会共享同一个引用地址,即它们指向堆内存中的同一个对象。
面试官开场白:以Object.assign()为例
面试中,面试官常常会以Object.assign()作为切入点,考察你对浅拷贝的理解。
2.1 Object.assign()
Object.assign()方法用于将一个或多个源对象的所有可枚举属性复制到目标对象,并返回修改后的目标对象。它常用于对象的浅拷贝或属性合并。
语法:Object.assign(target, ...sources)
示例分析(来自1.js) :
// 1.js
const target = { a: 1 };
const source = { b: 2 };
const result = Object.assign(target, source);
console.log(result); // { a: 1, b: 2 }
console.log(target); // { a: 1, b: 2 } (target 被修改了)
console.log(source); // { b: 2 } (source 未被修改)
result.a = 11;
console.log(result); // { a: 11, b: 2 }
console.log(target); // { a: 11, b: 2 } (target 仍然和 result 保持一致)
console.log(source); // { b: 2 }
核心要点:
-
返回的是目标对象:
Object.assign()返回的是被修改后的target对象,而不是一个全新的对象。因此,result和target指向同一个对象。 -
浅拷贝特性:
- 如果源对象属性是基本数据类型,则直接复制值。
- 如果源对象属性是引用数据类型,则复制其引用地址。这意味着如果源对象中包含嵌套对象或数组,修改拷贝后的对象中的嵌套引用类型属性,会影响到原始对象。
浅拷贝的“安全性问题” :
当浅拷贝的对象中包含引用类型属性时,修改拷贝后的对象中的这些引用类型属性,会影响到原始对象,这可能导致数据不一致或难以追踪的bug。这就是readme.md中提到的“浅拷贝有安全性问题”。
2.2 其他浅拷贝方法
除了Object.assign(),还有其他常见的浅拷贝方法:
-
数组的
Array.prototype.concat()和Array.prototype.slice():const arr1 = [1, 2, { a: 3 }]; const arr2 = arr1.concat(); // 浅拷贝 const arr3 = arr1.slice(); // 浅拷贝 arr2[2].a = 4; console.log(arr1[2].a); // 4 (arr1 也受到了影响) -
扩展运算符(Spread Operator
...) :const obj1 = { a: 1, b: { c: 2 } }; const obj2 = { ...obj1 }; // 浅拷贝 obj2.b.c = 3; console.log(obj1.b.c); // 3 (obj1 也受到了影响) const arr4 = [1, 2, { d: 4 }]; const arr5 = [...arr4]; // 浅拷贝 arr5[2].d = 5; console.log(arr4[2].d); // 5 (arr4 也受到了影响)
这些方法都只能实现浅拷贝,即只能拷贝对象或数组的第一层属性。当遇到嵌套的引用类型时,它们仍然会共享同一个引用。
3. 深拷贝(Deep Copy):彻底的独立
深拷贝是指创建一个新对象,这个新对象与原始对象完全独立,没有任何引用共享。无论原始对象有多少层嵌套的引用类型,深拷贝都会递归地复制所有层级的属性,确保新对象和原始对象互不影响。
3.1 JSON.parse(JSON.stringify()):最简单的深拷贝方法(但有局限性)
readme.md中提到了“JSON.parse(JSON.stringify()) 最简单的方法”,这确实是实现深拷贝的一种常见且简洁的方式。其原理是:
JSON.stringify():将JavaScript对象转换为JSON字符串。JSON.parse():将JSON字符串解析为JavaScript对象。
通过这种方式,原始对象的所有属性都会被序列化成字符串,再反序列化成新的对象,从而切断了所有引用关系,实现了深拷贝。
示例:
const obj = {
a: 1,
b: {
c: 2
}
};
const deepCopyObj = JSON.parse(JSON.stringify(obj));
deepCopyObj.b.c = 3;
console.log(obj.b.c); // 2 (原始对象未受影响)
console.log(deepCopyObj.b.c); // 3
3.2 JSON.parse(JSON.stringify())的局限性
尽管JSON.parse(JSON.stringify())简单易用,但它存在明显的局限性,这也是面试中常考的“坑”:
-
不能拷贝函数(
Function) :JSON.stringify()在遇到函数时会将其忽略。const objWithFunc = { a: 1, fn: () => console.log("hello") }; const copy = JSON.parse(JSON.stringify(objWithFunc)); console.log(copy); // { a: 1 } (fn 丢失) -
不能拷贝
undefined:JSON.stringify()在遇到undefined时会将其忽略。const objWithUndefined = { a: undefined, b: 2 }; const copy = JSON.parse(JSON.stringify(objWithUndefined)); console.log(copy); // { b: 2 } (a 丢失) -
不能拷贝
Symbol类型的属性:JSON.stringify()在遇到Symbol类型的键值对时会将其忽略。const sym = Symbol("foo"); const objWithSymbol = { [sym]: "value", a: 1 }; const copy = JSON.parse(JSON.stringify(objWithSymbol)); console.log(copy); // { a: 1 } (Symbol 属性丢失) -
不能拷贝循环引用的对象:如果对象中存在循环引用(即对象A引用对象B,对象B又引用对象A),
JSON.stringify()会报错。const objA = {}; const objB = {}; objA.b = objB; objB.a = objA; // 循环引用 // JSON.stringify(objA); // TypeError: Converting circular structure to JSON -
不能拷贝
RegExp、Date对象:JSON.stringify()会将其转换为字符串,而不是保留其原始类型。const objWithSpecial = { date: new Date(), reg: /abc/g }; const copy = JSON.parse(JSON.stringify(objWithSpecial)); console.log(copy.date); // 字符串形式的日期 console.log(copy.reg); // {} (空对象,RegExp丢失) -
不能拷贝不可枚举的属性:
JSON.stringify()只会序列化对象自身的可枚举属性。
这些局限性使得JSON.parse(JSON.stringify())只适用于那些不包含特殊类型(函数、undefined、Symbol、循环引用、RegExp、Date等)的纯数据对象。当遇到这些情况时,就需要“手写实现高级深拷贝”,这正是面试官最喜欢考察的环节。
4. 手写深拷贝:递归与循环引用处理
手写深拷贝是面试中考察候选人对数据结构、递归、类型判断以及内存管理理解深度的重要题目。一个完善的深拷贝函数需要考虑以下几点:
-
基本数据类型:直接返回。
-
引用数据类型:
- 对象和数组:需要递归遍历其内部属性/元素。
- 特殊对象:如
Date、RegExp等,需要特殊处理以保留其类型。 - 函数、
Symbol、undefined:根据需求决定是否忽略或特殊处理。
-
循环引用:这是手写深拷贝的难点,需要一个机制来记录已经拷贝过的对象,避免无限递归。
4.1 基础版深拷贝(不处理循环引用)
function deepClone(obj) {
// 1. 处理基本数据类型和null
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 2. 处理Date对象
if (obj instanceof Date) {
return new Date(obj);
}
// 3. 处理RegExp对象
if (obj instanceof RegExp) {
return new RegExp(obj);
}
// 4. 处理数组或对象
const newObj = Array.isArray(obj) ? [] : {};
for (let key in obj) {
// 确保是对象自身的属性,而不是原型链上的属性
if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = deepClone(obj[key]); // 递归调用
}
}
return newObj;
}
这个基础版本可以处理大部分情况,但无法处理循环引用。当遇到循环引用时,deepClone函数会陷入无限递归,最终导致栈溢出。
4.2 进阶版深拷贝(处理循环引用)
为了处理循环引用,我们需要引入一个Map或WeakMap来存储已经拷贝过的对象及其对应的副本。在每次递归拷贝之前,先检查当前对象是否已经在Map中存在,如果存在,则直接返回其对应的副本,从而避免重复拷贝和无限递归。
function deepCloneWithCircular(obj, hash = new WeakMap()) {
// 1. 处理基本数据类型和null
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 2. 处理循环引用:如果对象已存在于hash中,直接返回其副本
if (hash.has(obj)) {
return hash.get(obj);
}
// 3. 处理Date对象
if (obj instanceof Date) {
return new Date(obj);
}
// 4. 处理RegExp对象
if (obj instanceof RegExp) {
return new RegExp(obj);
}
// 5. 处理数组或对象
const newObj = Array.isArray(obj) ? [] : {};
// 在递归之前,将当前对象及其副本存入hash,防止循环引用
hash.set(obj, newObj);
for (let key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = deepCloneWithCircular(obj[key], hash); // 递归调用,并传递hash
}
}
return newObj;
}
WeakMap的优势:
这里使用WeakMap而不是Map是因为WeakMap的键是弱引用。这意味着如果原始对象没有其他引用,垃圾回收机制可以回收它,而不会因为WeakMap中存在对它的引用而阻止回收,从而避免内存泄漏。
4.3 考虑更多特殊情况(可选)
一个更完善的深拷贝函数可能还需要考虑:
Symbol属性:如果需要拷贝Symbol属性,可以使用Object.getOwnPropertySymbols()。- 不可枚举属性:如果需要拷贝不可枚举属性,可以使用
Object.getOwnPropertyDescriptors()或Object.getOwnPropertyNames()。 - 原型链:如果需要保留原型链,可以使用
Object.getPrototypeOf()和Object.setPrototypeOf()。
这些通常在面试中作为加分项,具体实现会更加复杂。
5. 总结(上篇)
在JavaScript中,深浅拷贝是理解内存管理和数据操作的基石。浅拷贝只复制对象的第一层属性,而深拷贝则递归复制所有层级的属性,确保新旧对象完全独立。Object.assign()、扩展运算符等是常见的浅拷贝方法,而JSON.parse(JSON.stringify())虽然简单,但存在明显的局限性。当遇到函数、undefined、Symbol、循环引用等特殊情况时,我们需要手写深拷贝函数,并通过WeakMap等机制来处理循环引用问题。
理解这些概念和实现方式,不仅能帮助你应对面试中的挑战,更能让你在实际开发中写出更健壮、更安全的代码。在下一篇中,我们将结合更多实际代码示例,深入探讨深浅拷贝在不同业务场景下的应用,以及如何选择最合适的拷贝策略。