一、JavaScript 深拷贝
在JavaScript中,深拷贝对象的方式有多种,包括:
1.1 手写递归方法实现(简易版)
遍历对象的所有属性,如果属性是对象,则递归调用深拷贝方法。这是一种常见且简单的方法,但需要注意处理循环引用的情况,否则会陷入死循环。
function deepCopy(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
let copy = Array.isArray(obj) ? [] : {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
copy[key] = deepCopy(obj[key]);
}
}
return copy;
}
但很明显这个实现存在很多缺陷:
- typeof 类型判断不够精确问题
- 递归嵌套过多性能问题
- 循环引用问题
后文会给出更合理的实现。
1.2 JSON 方法
利用 JSON.stringify 和 JSON.parse 方法进行深拷贝。
先将对象转换为字符串,然后再将字符串转换为新的对象。
function deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
这种方法简单易行,但不支持复杂对象,比如包含函数、正则表达式等。
具体来说,有如下局限性:
-
不支持循环引用:如果对象中存在循环引用,比如对象A中的某个属性引用了对象A本身,那么在执行深拷贝时会抛出错误,因为JSON.stringify不支持循环引用的对象。
-
不支持特殊对象:JSON.stringify方法会忽略特殊对象的属性,比如函数、正则表达式、Date对象、undefined等。在执行深拷贝时,这些特殊对象的属性会丢失。下面是一个包含了 JavaScript 所有类型的属性和示例值的对象:
const obj = {
string: "Hello, world!",
number: 42,
boolean: true,
nullValue: null,
undefinedValue: undefined,
symbol: Symbol("symbol"),
array: [1, 2, 3],
object: { key: "value" },
function: function() { console.log("Function") },
date: new Date(),
regexp: /pattern/,
map: new Map([["key", "value"]]),
set: new Set([1, 2, 3]),
//buffer: Buffer.from("buffer"),
promise: Promise.resolve("resolved"),
error: new Error("Error"),
bigint: BigInt(123),
typedArray: new Int32Array([1, 2, 3]),
nan: NaN,
infinity: Infinity,
negativeInfinity: -Infinity
};
这个对象包含了字符串、数字、布尔值、null、undefined、Symbol、数组、对象、函数、日期、正则表达式、Map、Set、Buffer、Promise、Error、BigInt、TypedArray、NaN、Infinity、-Infinity 等所有 JavaScript 的数据类型,每个属性都包含了对应的示例值。
obj 值结果:
通过 JSON.stringify 转换:
// 1. Uncaught TypeError: Do not know how to serialize a BigInt
// 把 BigInt 去掉后
{
"string": "Hello, world!",
"number": 42,
"boolean": true,
"nullValue": null,
"array": [
1,
2,
3
],
"object": {
"key": "value"
},
"date": "2024-03-24T03:58:09.400Z",
"regexp": {},
"map": [
[
"key",
"value"
]
],
"set": [
1,
2,
3
],
"promise": {},
"error": {},
"typedArray": {
"0": 1,
"1": 2,
"2": 3
},
"nan": null,
"infinity": null,
"negativeInfinity": null
}
// 可以发现 JSON.stringify
// 1. 不支持 BigInt 类型
// 2. undefined、function、symbol 在转换后会被忽略
// 3. 正则表示转换成对象
// 4. 日期转换成 utc 字符串
// 5. nan、、infinity、negativeInfinity 在转换后变成 null
-
不支持自定义对象类型:JSON.stringify和JSON.parse只能处理JavaScript原生对象类型,对于自定义的对象类型,需要提前序列化和反序列化处理。
-
性能问题:对于大型对象和嵌套层级较深的对象,使用JSON.stringify和JSON.parse方法进行深拷贝可能会导致性能问题,因为它需要将整个对象转换为字符串,并且再从字符串转换为新的对象。
因此,在实际应用中,如果遇到上述局限性,可以选择其他深拷贝方法,比如手写递归方法实现
、lodash库提供的_.cloneDeep方法
等。
2.3 Lodash 库
Lodash是一个流行的JavaScript工具库,提供了_.cloneDeep方法来实现深拷贝。它支持复杂对象的深度拷贝,同时也能很好地处理循环引用等情况。
const _ = require('lodash');
let deepCopy = _.cloneDeep;
具体代码可以阅读官方源码
二、实现深拷贝:递归法和迭代法
自己实现的话,则上面的问题都要考虑到:
- 解决 typeof 类型判断不够精确问题
- 解决递归嵌套过多性能问题
- 避免循环引用问题
- 支持特殊对象
- 支持自定义对象
// 递归写法
const sameObj = {
c: 1
}
class CustomObj {
constructor(value) {
this.value = value;
}
}
let input = {
a: {
b: sameObj,
d: 2
},
e: [1, 2, 3],
keyForSameObj: null,
self: null,
keyForCustomObj: null,
}
input.keyForSameObj = sameObj;
input.self = input;
input.keyForCustomObj = new CustomObj('test');
const isObjectAndArray = (obj) => {
const type = Object.prototype.toString.call(obj).slice(8, -1);
return type === 'Object' || type === 'Array';
}
// 1. 确定递归函数的参数和返回值
const deepCopy = (obj, map = new WeakMap()) => { // map 缓存用于解决循环引用问题
// 2. 确定终止条件
if (obj === null || obj instanceof CustomObj || !isObjectAndArray(obj)) {
// 属性值为null或者基本类型
return obj;
}
// 3. 确定每层递归逻辑
let newObj = Array.isArray(obj) ? [] : {};
if (map.has(obj)) {
// map 缓存用于解决循环引用问题
return map.get(obj);
}
map.set(obj, newObj);
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
newObj[key] = deepCopy(obj[key], map);
}
}
return newObj;
}
const res = deepCopy(input);
console.log(res.a.b === res.keyForSameObj); // true
console.log(res.self.self.self); // 即input自身
// 迭代写法
// 解决递归层级过深导致内存溢出问题
const sameObj = {
c: 1
}
class CustomObj {
constructor(value) {
this.value = value;
}
}
let input = {
a: {
b: sameObj,
d: 2
},
e: [1, 2, 3],
keyForSameObj: null,
self: null,
}
input.keyForSameObj = sameObj;
input.self = input;
input.keyForCustomObj = new CustomObj('test');
const deepCopyIterative = (input) => {
const stack = [];
const map = new WeakMap();
const isObjectAndArray = (obj) => {
const type = Object.prototype.toString.call(obj).slice(8, -1);
return type === 'Object' || type === 'Array';
};
const copy = (obj) => {
let newObj = Array.isArray(obj) ? [] : {};
map.set(obj, newObj);
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
if (obj[key] instanceof CustomObj) {
// 如果值是 CustomObj 的实例,则直接引用
newObj[key] = obj[key];
} else if (isObjectAndArray(obj[key])) {
// 如果属性值是对象或数组,将其入栈等待处理
stack.push({
parent: newObj,
key: key,
value: obj[key],
});
} else {
newObj[key] = obj[key];
}
}
}
return newObj;
};
const rootCopy = copy(input);
while (stack.length > 0) {
const { parent, key, value } = stack.pop();
if (!map.has(value)) {
// 如果值没有被复制过,进行复制并记录映射
parent[key] = copy(value);
} else {
// 如果值已经被复制过,直接使用映射的副本
parent[key] = map.get(value);
}
}
return rootCopy;
};
const res = deepCopyIterative(input);
console.log(res.a.b === res.keyForSameObj); // true
console.log(res.self.self.self); // 即input自身
输出结果:
注意,在上述实现中,WeakMap 的作用是解决循环引用的问题。循环引用指的是对象或数组中的某个属性或元素引用了对象或数组本身,导致递归复制时出现无限循环的情况。通过使用 map,可以检测是否已经复制过某个对象,如果已经复制过,则直接返回该对象的副本,而不是继续递归复制,即复制可以正常结束,这样可以避免进入无限循环的情况,保证深拷贝的正常进行。
关于 Map 和 WeakMap 的区别具体可以阅读 WHAT - JavaScript 弱引用和强引用
最后贴一下 Lodash 的关于 Object 和 Array 的判断实现:
//function isObject(value) {
// var type = typeof value;
// return value != null && (type == 'object' || type == 'function');
//}
//var isArray = Array.isArray;