JavaScript深拷贝与浅拷贝及循环引用处理详解
本文深入解析JavaScript中深拷贝与浅拷贝的核心区别,涵盖基本类型与引用类型的内存机制。详细对比Array.concat、Object.assign及JSON序列化等浅拷贝实现,并重点探讨递归深拷贝在处理循环引用(父级与同级)时的栈溢出问题,提供基于父级追溯的解决方案。
深拷贝与浅拷贝的概念起源
一、为什么有深拷贝和浅拷贝?
这涉及JavaScript的数据类型分类,主要分为基本数据类型和引用数据类型。
基本类型值存储于栈内存中,占据固定的内存位置,包括Number、String、Boolean、Null、Undefined和Symbol。
引用类型值存储于堆内存中,变量保存的是指向堆内存对象的指针。除上述6种基本类型外,其余如Object、Array、Date、RegExp和Function等均属于引用类型。
由于引用类型的这一机制,当将一个引用类型的值复制给另一个变量时,实际上是将栈内存中的地址指针复制了一份。因此,两个变量指向堆内存中的同一个对象,修改其中任意一个对象的属性,另一个对象也会随之改变。

因此,深拷贝和浅拷贝的概念仅适用于引用类型。简单来说,两者的核心区别在于:
1. 层次
- 浅拷贝仅对目标对象的属性进行逐层复制,不进行递归操作,即只处理目标对象的第一层属性。
- 深拷贝则不同,它不仅复制第一层属性,还会递归地复制目标对象的所有嵌套属性。
2. 是否开辟新的栈
- 对于浅拷贝,若目标对象第一层属性为基本数据类型,则直接赋值(传值);若为引用数据类型,则直接复制栈内存中的堆地址(传址)。这意味着浅拷贝并未开辟新的栈空间,两个对象指向同一地址,修改其中一个对象的属性,另一个对象的属性也会改变。
- 深拷贝则会在堆内存中开辟新的空间,使两个对象拥有不同的地址。因此,修改其中一个对象的属性,不会影响另一个对象。
浅拷贝的实现方式
二、浅拷贝
以下是几种实现浅拷贝的方式:
1.Array.concat()
const arr = [1,2,3,4,[5,6]];
const copy = arr.concat(); \\ 利用concat()创建arr的副本
\\改变基本类型值,不会改变原数组
copy[0] = 2;
arr; //[1,2,3,4,[5,6]];
\\改变数组中的引用类型值,原数组也会跟着改变
copy[4][1] = 7;
arr; //[1,2,3,4,[5,7]];
类似效果的方法还有slice()和Array.from(),读者可自行尝试。
2.Object.assign()
const obj1 = {x: 1, y: 2};
const obj2 = Object.assign({}, obj1);
obj2.x = 2; \\修改obj2.x,改变对象中的基本类型值
console.log(obj1) //{x: 1, y: 2} //原对象未改变
console.log(obj2) //{x: 2, y: 2}
const obj1 = {
x: 1,
y: {
m: 1
}
};
const obj2 = Object.assign({}, obj1);
obj2.y.m = 2; \\修改obj2.y.m,改变对象中的引用类型值
console.log(obj1) //{x: 1, y: {m: 2}} 原对象也被改变
console.log(obj2) //{x: 2, y: {m: 2}}
深拷贝的常见方法与局限
三、深拷贝
1.JSON.parse()和JSON.stringify()
const obj1 = {
x: 1,
y: {
m: 1
}
};
const obj2 = JSON.parse(JSON.stringify(obj1));
console.log(obj1) //{x: 1, y: {m: 1}}
console.log(obj2) //{x: 1, y: {m: 1}}
obj2.y.m = 2; //修改obj2.y.m
console.log(obj1) //{x: 1, y: {m: 1}} 原对象未改变
console.log(obj2) //{x: 2, y: {m: 2}}
这种方法实现简单,能满足日常基本的深拷贝需求,且能处理JSON格式支持的所有数据类型,但存在以下缺点:
- undefined、任意函数、正则表达式类型以及symbol值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或转换为null(出现在数组中时);
- 它会抛弃对象的constructor。也就是说,深拷贝后,无论原对象的构造函数是什么,新对象都会变成Object类型;
- 如果对象中存在循环引用,该方法无法正确处理。
2.递归
function deepCopy1(obj) {
// 创建一个新对象
let result = {}
let keys = Object.keys(obj),
key = null,
temp = null;
for (let i = 0; i < keys.length; i++) {
key = keys[i];
temp = obj[key];
// 如果字段的值也是一个对象则递归操作
if (temp && typeof temp === 'object') {
result[key] = deepCopy(temp);
} else {
// 否则直接赋值给新对象
result[key] = temp;
}
}
return result;
}
const obj1 = {
x: {
m: 1
},
y: undefined,
z: function add(z1, z2) {
return z1 + z2
},
a: Symbol("foo")
};
const obj2 = deepCopy1(obj1);
obj2.x.m = 2;
console.log(obj1); //{x: {m: 1}, y: undefined, z: ƒ, a: Symbol(foo)}
console.log(obj2); //{x: {m: 2}, y: undefined, z: ƒ, a: Symbol(foo)}
循环引用问题与解决方案
四、循环引用
看似递归已完全解决问题,但还有一种情况未被考虑,即循环引用。
1.父级引用
这里的父级引用指的是,当对象的某个属性正是该对象本身时,进行深拷贝可能会在子元素->父对象->子元素...的循环中无限进行,导致栈溢出。例如以下情况:
const obj1 = {
x: 1,
y: 2
};
obj1.z = obj1;
const obj2 = deepCopy1(obj1); \\栈溢出
解决办法是:只需判断一个对象的字段是否引用了该对象或其任意父级即可。可以修改上面的deepCopy函数:
function deepCopy2(obj, parent=null) {
//创建一个新对象
let result = {};
let keys = Object.keys(obj),
key = null,
temp = null,
_parent = parent;
//该字段有父级则需要追溯该字段的父级
while(_parent) {
//如果该字段引用了它的父级,则为循环引用
if(_parent.originParent === obj) {
//循环引用返回同级的新对象
return _parent.currentParent;
}
_parent = _parent.parent
}
for(let i=0,len=keys.length;i<len;i++) {
key = keys[i]
temp = obj[key]
// 如果字段的值也是一个新对象
if(temp && typeof temp === 'object') {
result[key] = deepCopy(temp, {
//递归执行深拷贝,将同级的待拷贝对象与新对象传递给parent,方便追溯循环引用
originParent: obj,
currentParent: result,
parent: parent
});
} else {
result[key] = temp;
}
}
return result;
}
const obj1 = {
x:1
}
obj1.z = obj1;
const obj2 = deepCopy2(obj1);
2. 同级引用
假设对象obj有a、b、c三个子对象,其中子对象c中有一个属性d引用了对象obj下的子对象a。
const obj= {
a: {
name: 'a'
},
b: {
name: 'b'
},
c: {
}
};
c.d.e = obj.a;
此时c.d.e和obj.a是相等的,因为它们引用的是同一个对象。
console.log(c.d.e === obj.a); //true
如果我们调用上面的deepCopy2函数:
const copy = deepCopy2(obj);
console.log(copy.a); // 输出: {name: "a"}
console.log(copy.d.e);// 输出: {name: "a"}
console.log(copy.a === copy.d.e); // 输出: false
从以上表现可以看出,虽然copy.a和copy.d.e在字面意义上相等,但二者并非引用同一个对象。从这个角度看,对象copy和原对象obj仍存在差异。
这种情况是因为obj.a并不在obj.d.e的父级对象链上,所以deepCopy2函数无法检测到obj.d.e对obj.a也是一种引用关系,因此deepCopy2函数将obj.a深拷贝的结果赋值给了copy.d.e。
解决方案:父级的引用是一种引用,非父级的引用也是一种引用。只要记录下对象A中的所有对象,并与新创建的对象一一对应即可。
function deepCopy3(obj) {
// hash表,记录所有的对象的引用关系
let map = new WeakMap();
function dp(obj) {
let result = null;
let keys = Object.keys(obj);
let key = null,
temp = null,
existobj = null;
existobj = map.get(obj);
//如果这个对象已经被记录则直接返回
if(existobj) {
return existobj;
}
result = {}
map.set(obj, result);
for(let i =0,len=keys.length;i<len;i++) {
key = keys[i];
temp = obj[key];
if(temp && typeof temp === 'object') {
result[key] = dp(temp);
}else {
result[key] = temp;
}
}
return result;
}
return dp(obj);
}
const obj= {
a: {
name: 'a'
},
b: {
name: 'b'
},
c: {
}
};
c.d.e = obj.a;
const copy = deepCopy3(obj);
总结与展望
五、总结
其实拷贝的方式还有很多种,比如jquery中的$.extend、lodash的_.cloneDeep等。关于拷贝中还有很多问题值得深究,比如正则类型的值如何拷贝、原型上的属性如何拷贝,这些我都会慢慢研究!大家也可以思考一下~ 最后,欢迎点赞和收藏!!错误之处欢迎指正(`・ω・´)
什么时候该用JS构造器:与工厂模式、字面量的选择对比
2026-11-05 16:18:22
推荐几款轻量级JS照片墙开源库及对比
2026-11-05 15:56:19
JavaScript递归写法有哪些常见陷阱及避免方法
2026-11-05 14:53:38
JS数组浅拷贝与深拷贝怎么选?场景与方法总结
2026-11-05 14:41:02
p5.js 中 setup() 和 draw() 函数的执行机制详解
2026-11-05 12:32:38
