本文深入解析 JavaScript 中的深浅拷贝机制。通过对比基本与引用数据类型,详细阐述浅拷贝与深拷贝的本质区别。重点分析 JSON.stringify 的局限性、递归实现的原理,以及 concat、slice 和 Object.assign 等内置方法的“首层浅拷贝”特性,帮助开发者根据场景选择正确的拷贝策略。
JavaScript 深浅拷贝概述
前言
探讨深浅拷贝之前,有必要回顾 JavaScript 的数据类型体系。关于数据类型的详细分类,可参考《JavaScript基础心法——数据类型》一文,此处不再赘述。
核心要点在于:JavaScript 的数据类型明确划分为基本数据类型和引用数据类型两大类。
对于基本数据类型的赋值,不存在深浅拷贝的概念差异。我们通常讨论的深浅拷贝,仅针对引用数据类型展开。
浅拷贝与深拷贝原理
浅拷贝
所谓浅拷贝,即仅复制了对象的引用地址,而未真正复制其内部存储的值。
const originArray = [1,2,3,4,5];
const originObj = {a:'a',b:'b',c:[1,2,3],d:{dd:'dd'}};
const cloneArray = originArray;
const cloneObj = originObj;
console.log(cloneArray); // [1,2,3,4,5]
console.log(originObj); // {a:'a',b:'b',c:Array[3],d:{dd:'dd'}}
cloneArray.push(6);
cloneObj.a = {aa:'aa'};
console.log(cloneArray); // [1,2,3,4,5,6]
console.log(originArray); // [1,2,3,4,5,6]
console.log(cloneObj); // {a:{aa:'aa'},b:'b',c:Array[3],d:{dd:'dd'}}
console.log(originArray); // {a:{aa:'aa'},b:'b',c:Array[3],d:{dd:'dd'}}
上述代码展示了利用赋值操作符 = 实现的最基础浅拷贝。观察可知,当 cloneArray 和 cloneObj 发生变动时,originArray 和 originObj 也会随之同步变化,因为它们指向同一内存地址。
深拷贝
深拷贝则是对目标对象的完全独立复制。与浅拷贝仅复制一层引用不同,深拷贝会将值本身也一并复制,确保新旧对象在内存中完全隔离。
一旦完成深拷贝,两个对象便彻底独立,互不影响,即所谓的“老死不相往来”。
目前实现深拷贝的主要方法有两种:
- 利用 JSON 对象中的 parse 和 stringify
- 利用递归来实现每一层都重新创建对象并赋值
JSON 序列化实现深拷贝
JSON.stringify/parse 的方法
首先了解这两个核心方法的功能:
The JSON.stringify() method converts a JavaScript value to a JSON string.
JSON.stringify 的作用是将一个 JavaScript 值转换为 JSON 字符串格式。
The JSON.parse() method parses a JSON string, constructing the JavaScript value or object described by the string.
JSON.parse 的作用是将一个 JSON 字符串解析还原为 JavaScript 值或对象。
理解起来很直观,这本质上是 JavaScript 值与 JSON 字符串之间的相互转换过程。
既然涉及转换,它能否实现深拷贝?让我们通过实例验证。
const originArray = [1,2,3,4,5];
const cloneArray = JSON.parse(JSON.stringify(originArray));
console.log(cloneArray === originArray); // false
const originObj = {a:'a',b:'b',c:[1,2,3],d:{dd:'dd'}};
const cloneObj = JSON.parse(JSON.stringify(originObj));
console.log(cloneObj === originObj); // false
cloneObj.a = 'aa';
cloneObj.c = [1,1,1];
cloneObj.d.dd = 'doubled';
console.log(cloneObj); // {a:'aa',b:'b',c:[1,1,1],d:{dd:'doubled'}};
console.log(originObj); // {a:'a',b:'b',c:[1,2,3],d:{dd:'dd'}};
测试结果显示确实实现了深拷贝,且代码简洁方便。然而,该方法仅适用于结构简单、不含特殊类型的场景。例如以下对象便不适用此方法:
const originObj = {
name:'axuebin',
sayHello:function(){
console.log('Hello World');
}
}
console.log(originObj); // {name: "axuebin", sayHello: ƒ}
const cloneObj = JSON.parse(JSON.stringify(originObj));
console.log(cloneObj); // {name: "axuebin"}
运行后发现,在 cloneObj 中出现了属性丢失的现象。这是为什么呢?
查阅 MDN 文档找到了原因:
If undefined, a function, or a symbol is encountered during conversion it is either omitted (when it is found in an object) or censored to null (when it is found in an array). JSON.stringify can also just return undefined when passing in "pure" values like JSON.stringify(function(){}) or JSON.stringify(undefined).
这意味着,undefined、function 以及 symbol 在转换过程中会被忽略或处理为 null。
由此可见,如果对象中包含函数(这在开发中很常见),则无法使用此方法进行深拷贝。
递归实现深拷贝
递归的方法
递归实现的思路非常直接:对每一层数据执行“创建对象 -> 赋值”的操作。逻辑简单直接,代码如下:
function deepClone(source){
const targetObj = source.constructor === Array ? [] : {}; // 判断复制的目标是数组还是对象
for(let keys in source){ // 遍历目标
if(source.hasOwnProperty(keys)){
if(source[keys] && typeof source[keys] === 'object'){ // 如果值是对象,就递归一下
targetObj[keys] = source[keys].constructor === Array ? [] : {};
targetObj[keys] = deepClone(source[keys]);
}else{ // 如果不是,就直接赋值
targetObj[keys] = source[keys];
}
}
}
return targetObj;
}
我们来测试一下基础对象:
const originObj = {a:'a',b:'b',c:[1,2,3],d:{dd:'dd'}};
const cloneObj = deepClone(originObj);
console.log(cloneObj === originObj); // false
cloneObj.a = 'aa';
cloneObj.c = [1,1,1];
cloneObj.d.dd = 'doubled';
console.log(cloneObj); // {a:'aa',b:'b',c:[1,1,1],d:{dd:'doubled'}};
console.log(originObj); // {a:'a',b:'b',c:[1,2,3],d:{dd:'dd'}};
结果正常。那再测试包含函数的复杂对象:
const originObj = {
name:'axuebin',
sayHello:function(){
console.log('Hello World');
}
}
console.log(originObj); // {name: "axuebin", sayHello: ƒ}
const cloneObj = deepClone(originObj);
console.log(cloneObj); // {name: "axuebin", sayHello: ƒ}
同样成功。问题似乎解决了。
难道就这样结束了吗?当然不是。
JavaScript 内置拷贝方法分析
JavaScript 中的拷贝方法
在 JavaScript 中,数组提供了 concat 和 slice 两个方法,它们可以返回原数组的拷贝。这两个方法都不会修改原数组,而是生成并返回一个新数组。
同时,ES6 引入了 Object.assgn 方法以及 ... 展开运算符,它们也能实现对对象的拷贝。
那么,这些方法实现的是浅拷贝还是深拷贝呢?
concat
The concat() method is used to merge two or more arrays. This method does not change the existing arrays, but instead returns a new array.
该方法用于合并两个或多个数组,它不修改现有数组,而是返回一个新数组。
乍看之下,这似乎很像深拷贝,我们来实际测试一下。
const originArray = [1,2,3,4,5];
const cloneArray = originArray.concat();
console.log(cloneArray === originArray); // false
cloneArray.push(6); // [1,2,3,4,5,6]
console.log(originArray); [1,2,3,4,5];
表面上看,结果符合深拷贝的特征。
但我们需要考虑一个问题:如果对象是多层的嵌套结构,结果会如何?
const originArray = [1,[1,2,3],{a:1}];
const cloneArray = originArray.concat();
console.log(cloneArray === originArray); // false
cloneArray[1].push(4);
cloneArray[2].a = 2;
console.log(originArray); // [1,[1,2,3,4],{a:2}]
在 originArray 中,包含数组 [1,2,3] 和对象 {a:1}。如果我们直接修改数组或对象,originArray 不会受到影响;但是,当我们修改数组 [1,2,3] 或对象 {a:1} 时,发现 originArray 也发生了相应的变化。
结论:concat 仅对数组的第一层进行深拷贝,深层结构仍共享引用。
slice
The slice() method returns a shallow copy of a portion of an array into a new array object selected from begin to end (end not included). The original array will not be modified.
官方解释中直接注明了这是 a shallow copy。
但是,这是否意味着它只是浅拷贝呢?
const originArray = [1,2,3,4,5];
const cloneArray = originArray.slice();
console.log(cloneArray === originArray); // false
cloneArray.push(6); // [1,2,3,4,5,6]
console.log(originArray); [1,2,3,4,5];
同样地,我们测试多层数组的情况。
const originArray = [1,[1,2,3],{a:1}];
const cloneArray = originArray.slice();
console.log(cloneArray === originArray); // false
cloneArray[1].push(4);
cloneArray[2].a = 2;
console.log(originArray); // [1,[1,2,3,4],{a:2}]
果然,结果与 concat 一致。
结论:slice 仅对数组的第一层进行深拷贝,深层结构仍为浅拷贝。
Object.assign()
The Object.assign() method is used to copy the values of all enumerable own properties from one or more source objects to a target object. It will return the target object.
核心动作是复制。
那么,它究竟是浅拷贝还是深拷贝?
建议读者自行测试验证。
结论:Object.assign() 拷贝的是属性值。如果源对象的属性值是指向对象的引用,它仅拷贝该引用值,而非对象本身。
... 展开运算符
const originArray = [1,2,3,4,5,[6,7,8]];
const originObj = {a:1,b:{bb:1}};
const cloneArray = [...originArray];
cloneArray[0] = 0;
cloneArray[5].push(9);
console.log(originArray); // [1,2,3,4,5,[6,7,8,9]]
const cloneObj = {...originObj};
cloneObj.a = 2;
cloneObj.b.bb = 2;
console.log(originObj); // {a:1,b:{bb:2}}
结论:... 运算符实现的是对象第一层的深拷贝,后续层级仅拷贝引用值。
首层浅拷贝与自定义实现
首层浅拷贝
我们了解到,存在一种情况:对目标对象的第一层进行深拷贝,而后续层级进行浅拷贝,这种模式可称为“首层浅拷贝”。
我们可以基于此思路自定义实现一个这样的函数:
function shallowClone(source) {
const targetObj = source.constructor === Array ? [] : {}; // 判断复制的目标是数组还是对象
for (let keys in source) { // 遍历目标
if (source.hasOwnProperty(keys)) {
targetObj[keys] = source[keys];
}
}
return targetObj;
}
我们来测试一下这个自定义函数:
const originObj = {a:'a',b:'b',c:[1,2,3],d:{dd:'dd'}};
const cloneObj = shallowClone(originObj);
console.log(cloneObj === originObj); // false
cloneObj.a='aa';
cloneObj.c=[1,1,1];
cloneObj.d.dd='surprise';
经过上述修改,cloneObj 的预期结果应为 {a:'aa',b:'b',c:[1,1,1],d:{dd:'surprise'}}。那么 originObj 呢?此前我们验证了 cloneObj === originObj 为 false,说明两者引用地址不同。按理说,修改 cloneObj 不应影响 originObj。
console.log(cloneObj); // {a:'aa',b:'b',c:[1,1,1],d:{dd:'surprise'}}
console.log(originObj); // {a:'a',b:'b',c:[1,2,3],d:{dd:'surprise'}}
What happend?
结果显示,originObj 中关于 a、c 的属性未被影响,但 d 中的一个子对象却被修改了。这与我们预期的“深拷贝”不符,既然引用地址不同,为何还会相互影响?
原因如下:
- 从 shallowClone 的代码逻辑可以看出,我们仅对第一层的目标进行了深拷贝处理,而从第二层开始的目标,我们是直接利用 = 赋值操作符进行拷贝的。
- 因此,第二层及之后的目标仅复制了引用,本质上仍属于浅拷贝。
总结
总结
- 赋值运算符 = 实现的是浅拷贝,仅拷贝对象的引用值;
- JavaScript 中数组和对象自带的拷贝方法(如 concat, slice, Object.assign, ...)均属于“首层浅拷贝”;
- JSON.stringify 可实现深拷贝,但对目标对象有严格限制(不支持函数、undefined、symbol 等);
- 若需实现真正意义上的完全深拷贝,请使用递归方法。