js实现深拷贝的核心答案是通过递归复制对象所有层级并切断引用关系,以确保副本与原数据完全独立。最简单的方法是使用json.parse(json.stringify(obj)),适用于仅含基本类型和普通对象的“纯净”数据,但会丢失函数、undefined、symbol等,且无法处理循环引用;更通用的方案是编写递归函数,通过weakmap记录已拷贝对象来避免循环引用,并显式处理date、regexp、map、set等特殊类型;现代javascript提供了structuredclone()原生api,能高效处理多种复杂类型和循环引用,但不支持函数和dom节点;对于复杂场景或需高度定制化,推荐使用lodash的_.clonedeep()等成熟库。浅拷贝仅复制顶层引用,嵌套对象仍共享内存,修改会导致原对象变化,因此在需要完全隔离数据的场景下必须使用深拷贝。不同策略适用于不同场景:数据简单时用json方法,支持现代浏览器且不含函数时优先structuredclone(),复杂结构或需兼容旧环境时采用自定义递归函数,大型项目则推荐第三方库以保证稳定性和开发效率。
JS实现深拷贝,核心在于创建一个全新且独立的副本,不仅复制原始值,还要递归复制所有嵌套的对象和数组,确保修改副本不会影响到原始数据。这不像浅拷贝那样只复制引用,深拷贝能够彻底切断新旧数据之间的联系,让你在处理复杂数据结构时更加安心。
解决方案
要实现JS的深拷贝,其实有几种主流思路,每种都有它的适用场景和局限性。
首先,最简单粗暴,也是很多人第一个想到的方法,就是利用
JSON.parse(JSON.stringify(obj))
。这招对于那些只包含基本类型(字符串、数字、布尔值、null)以及纯粹的数组和对象的数据结构来说,简直是神来之笔,一行代码搞定。它先把对象序列化成JSON字符串,再反序列化回来,过程中就自然地“切断”了所有引用。但话说回来,它缺点也挺明显:函数、
undefined
、
Symbol
、
Date
对象、
RegExp
对象等特殊类型都会在序列化过程中丢失或被转换,比如
Date
会变成字符串,函数和
undefined
直接就没了。更要命的是,它处理不了循环引用,直接就报错给你看。所以,这方法更适合那些“纯净”的数据。
// 简单但有局限性的深拷贝 function simpleDeepClone(obj) { try { return JSON.parse(JSON.stringify(obj)); } catch (e) { console.warn("JSON.stringify/parse 无法处理此对象,可能存在循环引用或特殊类型。", e); return null; // 或者抛出错误,根据需求来 } } // 示例 const obj1 = { a: 1, b: { c: 2 }, d: new Date(), e: function() {} }; const clonedObj1 = simpleDeepClone(obj1); console.log(clonedObj1); // { a: 1, b: { c: 2 }, d: '2023-10-27T12:00:00.000Z' } - d 变成了字符串,e 没了
接着,更通用、也更考验功力的方法是编写一个递归拷贝函数。这才是深拷贝的“正道”。它的核心思想就是遍历对象的每一个属性,如果属性值是基本类型,直接拷贝;如果属性值是对象或数组,就递归调用自身,直到所有嵌套层级都被复制。这里面最大的坑,也是最需要解决的问题,就是循环引用。如果对象A引用了B,B又引用了A,不处理就会陷入无限递归,栈溢出。解决办法通常是使用一个
Map
或
WeakMap
来记录已经拷贝过的对象,避免重复拷贝和循环引用。
// 递归深拷贝,处理循环引用 function deepClone(obj, hash = new WeakMap()) { if (obj === null || typeof obj !== 'object') { return obj; // null 或 非对象类型直接返回 } // 处理循环引用 if (hash.has(obj)) { return hash.get(obj); } // 处理特殊对象类型 if (obj instanceof Date) { return new Date(obj); } if (obj instanceof RegExp) { return new RegExp(obj); } // 如果还有其他需要特殊处理的内置对象,比如Map, Set, Symbol等,可以在这里添加 // 创建新对象或新数组 const cloneObj = Array.isArray(obj) ? [] : {}; hash.set(obj, cloneObj); // 存储已处理的对象,防止循环引用 // 遍历并递归拷贝属性 for (const key in obj) { // 确保只处理对象自身的属性,而不是原型链上的 if (Object.prototype.hasOwnProperty.call(obj, key)) { cloneObj[key] = deepClone(obj[key], hash); } } return cloneObj; } // 示例:处理循环引用 const original = {}; original.a = original; // 循环引用 original.b = { c: 1 }; const clonedOriginal = deepClone(original); console.log(clonedOriginal.a === clonedOriginal); // true,说明循环引用被正确处理,指向的是新的克隆对象中的自己 console.log(clonedOriginal.b === original.b); // false,嵌套对象被深拷贝了 // 示例:处理Date和Function const objWithSpecialTypes = { date: new Date(), func: () => console.log('hello'), reg: /abc/g, sym: Symbol('test') }; const clonedSpecial = deepClone(objWithSpecialTypes); console.log(clonedSpecial.date instanceof Date); // true console.log(clonedSpecial.date !== objWithSpecialTypes.date); // true console.log(clonedSpecial.func === objWithSpecialTypes.func); // true,函数通常是直接引用拷贝,因为其行为通常不需克隆 console.log(clonedSpecial.reg instanceof RegExp); // true // Symbol默认不会被枚举,所以这里不会被拷贝,除非你特殊处理 console.log(clonedSpecial.sym); // undefined
最后,现代JavaScript提供了一个更原生的方案:
structuredClone()
。这是一个相对较新的API,旨在提供一个内置的、高效的深拷贝机制。它能处理很多复杂的数据类型,包括循环引用、
Date
、
RegExp
、
Map
、
Set
、
ArrayBuffer
、
Blob
、
File
、
ImageData
等,而且性能通常比手动递归实现要好。但它也有局限性,比如不能克隆函数、DOM节点、Error对象等。
// 使用 structuredClone (现代浏览器支持) function structuredCloneWrapper(obj) { if (typeof structuredClone === 'function') { try { return structuredClone(obj); } catch (e) { console.warn("structuredClone 无法处理此对象,可能包含不可克隆的类型 (如函数、DOM节点)。", e); return null; } } else { console.warn("当前环境不支持 structuredClone API,请考虑使用其他深拷贝方法。"); return deepClone(obj); // 回退到自定义递归方法 } } // 示例 const objStructured = { a: 1, b: { c: 2 }, d: new Date(), map: new Map([ ['key', 'value'] ]) }; objStructured.self = objStructured; // 循环引用 const clonedStructured = structuredCloneWrapper(objStructured); console.log(clonedStructured.d instanceof Date); // true console.log(clonedStructured.map instanceof Map); // true console.log(clonedStructured.self === clonedStructured); // true,循环引用处理 // clonedStructured.func = () => {}; // 如果原始对象有函数,会报错
当然,你也可以选择使用成熟的第三方库,比如Lodash的
_.cloneDeep()
。这些库通常经过了大量的测试和优化,能够处理各种复杂的边缘情况,省去了自己造轮子的麻烦。
为什么浅拷贝不能满足所有需求?
浅拷贝,顾名思义,它只是“表面”的拷贝。当你的对象里只有基本类型(比如数字、字符串、布尔值)时,浅拷贝确实能创建一个独立的副本,因为这些类型都是按值传递的。但一旦对象中包含了另一个对象或数组(也就是引用类型),浅拷贝就只是复制了这些嵌套对象的“引用地址”。
这意味着什么呢?打个比方,你有一份文件,浅拷贝就像是给这份文件创建了一个快捷方式。你通过快捷方式打开文件,修改了里面的内容,那么原始文件里的内容也同样被修改了,因为它们指向的是同一个实际的文件。
const originalObj = { name: "Alice", details: { age: 30, city: "New York" }, hobbies: ["reading", "hiking"] }; // 使用扩展运算符进行浅拷贝 const shallowCopy = { ...originalObj }; // 修改浅拷贝中的基本类型属性 shallowCopy.name = "Bob"; console.log(originalObj.name); // 输出 "Alice" - 基本类型互不影响 // 修改浅拷贝中的嵌套对象属性 shallowCopy.details.age = 31; console.log(originalObj.details.age); // 输出 31 - 糟糕!原始对象也被修改了 // 修改浅拷贝中的数组属性 shallowCopy.hobbies.push("coding"); console.log(originalObj.hobbies); // 输出 ["reading", "hiking", "coding"] - 再次中招!
你看,当修改
shallowCopy.details.age
时,
originalObj.details.age
也跟着变了。同样,往
shallowCopy.hobbies
里加东西,
originalObj.hobbies
也多了个元素。这在很多场景下都是不希望发生的行为,因为它打破了数据的独立性,可能导致意料之外的副作用,尤其是在函数传递参数、状态管理(如React、Vue)等场景中,这种“共享引用”的特性很容易埋下隐患。所以,当你需要确保一个对象的所有嵌套层级都与原对象完全独立时,浅拷贝就完全不够用了,必须祭出深拷贝。
哪些数据类型在深拷贝时需要特别注意?
深拷贝并不是简单地把所有东西都复制一遍就完事了,有些特殊的数据类型在处理时会带来一些挑战,或者说,它们有自己的“脾气”。
首先是函数(Functions)。通常情况下,我们深拷贝一个对象时,里面的函数属性并不会被“克隆”一份新的代码。大多数深拷贝实现,包括
JSON.parse(JSON.stringify())
和
structuredClone()
,都会直接忽略函数。自定义的递归拷贝函数一般也只是直接拷贝函数的引用。这是因为函数本质上是可执行的代码块,通常我们不希望复制它们的代码逻辑,而是希望它们在新的对象中仍然指向同一个功能。如果你真的需要克隆函数,那往往意味着你的设计可能需要重新考虑,或者你需要非常特殊的序列化/反序列化机制(比如把函数转换成字符串再
eval
回来,这在安全性和性能上都有很大风险,不推荐)。
其次是日期对象(Date objects)和正则表达式(RegExp objects)。
JSON.parse(JSON.stringify())
在处理它们时会把它们变成字符串,失去了原有的对象类型。比如一个
Date
对象会变成
"2023-10-27T12:00:00.000Z"
这样的字符串。自定义的递归拷贝函数需要显式地检查这些类型,然后使用它们各自的构造函数来创建新的实例,比如
new Date(originalDate)
或
new RegExp(originalRegExp)
。
structuredClone()
在这方面表现得就很好,它能正确地克隆这些内置对象。
再来是
undefined
、
NaN
、
Infinity
。
JSON.stringify()
在处理
undefined
时,如果它是对象的属性值,那么整个键值对都会被忽略;如果它是数组的元素,则会变成
null
。
NaN
和
Infinity
在
JSON.stringify()
后会变成
null
。这显然不是我们想要的结果。自定义递归拷贝需要直接处理这些原始值。
structuredClone()
则能正确地保留它们。
还有
Symbol
类型。
JSON.stringify()
会直接忽略
Symbol
属性,因为
Symbol
是不可枚举的。自定义递归拷贝函数如果使用
for...in
或
Object.keys()
,也会忽略
Symbol
属性。你需要使用
Object.getOwnPropertySymbols()
来获取
Symbol
属性,然后单独拷贝。
structuredClone()
可以处理可序列化的
Symbol
。
最后,也是最让人头疼的循环引用。当一个对象直接或间接引用了自身时,比如
obj.a = obj
,或者
obj1.a = obj2; obj2.b = obj1;
,如果你的递归拷贝函数没有妥善处理,就会陷入无限循环,最终导致栈溢出。解决这个问题的标准做法是维护一个
Map
或
WeakMap
来记录已经拷贝过的对象。在拷贝一个对象之前,先检查这个
Map
,如果已经拷贝过,就直接返回之前拷贝的那个副本,而不是再次进入递归。这不仅解决了循环引用,也能提高拷贝效率,避免重复工作。
// 针对特殊类型在递归拷贝中的处理示例片段 function deepCloneWithSpecialTypes(obj, hash = new WeakMap()) { if (obj === null || typeof obj !== 'object') { return obj; } if (hash.has(obj)) { return hash.get(obj); } // 处理日期对象 if (obj instanceof Date) { return new Date(obj); } // 处理正则表达式 if (obj instanceof RegExp) { return new RegExp(obj); } // 处理Map对象 if (obj instanceof Map) { const newMap = new Map(); hash.set(obj, newMap); obj.forEach((value, key) => { newMap.set(deepCloneWithSpecialTypes(key, hash), deepCloneWithSpecialTypes(value, hash)); }); return newMap; } // 处理Set对象 if (obj instanceof Set) { const newSet = new Set(); hash.set(obj, newSet); obj.forEach(value => { newSet.add(deepCloneWithSpecialTypes(value, hash)); }); return newSet; } // 其他类型(如ArrayBuffer, TypedArray等)可以继续添加 // ... const cloneObj = Array.isArray(obj) ? [] : {}; hash.set(obj, cloneObj); // 拷贝常规属性 for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { cloneObj[key] = deepCloneWithSpecialTypes(obj[key], hash); } } // 拷贝 Symbol 属性 (需要单独处理,因为 for...in 不会遍历它们) Object.getOwnPropertySymbols(obj).forEach(symbol => { cloneObj[symbol] = deepCloneWithSpecialTypes(obj[symbol], hash); }); return cloneObj; }
针对不同场景的深拷贝策略?
选择哪种深拷贝方法,很大程度上取决于你的具体需求、数据结构的复杂性以及对浏览器兼容性的要求。没有一种“万能”的深拷贝方案能完美覆盖所有场景。
场景一:数据结构简单,不含函数、Date、RegExp、循环引用等特殊类型。
- 策略:
JSON.parse(JSON.stringify(obj))
- 优点: 简单、快捷、代码量少。
- 缺点: 局限性大,不适用于复杂场景。
- 适用场景: 配置对象、纯粹的数据传输对象(DTO),确保数据中只有可JSON序列化的基本类型、数组和普通对象。
场景二:需要处理大多数内置类型(Date, RegExp, Map, Set, TypedArray等),可能存在循环引用,且目标环境支持现代JS特性,但不涉及函数或DOM节点。
- 策略:
structuredClone()
- 优点: 原生支持,性能通常优于手动实现,能处理多种复杂类型和循环引用,无需引入第三方库。
- 缺点: 无法克隆函数、DOM节点、Error对象等不可序列化的类型;浏览器兼容性(虽然现在主流浏览器支持度很高,但旧环境可能不支持)。
- 适用场景: Web Workers间传递数据(
postMessage
底层就用到了类似机制),前端应用中需要对复杂状态进行快照或回滚,且这些状态不包含函数。
场景三:数据结构复杂,包含各种特殊类型(包括函数),存在循环引用,对兼容性有较高要求,或者需要高度定制化拷贝逻辑。
- 策略: 自定义递归拷贝函数
- 优点: 灵活性最高,可以根据需求定制化处理各种特殊类型(比如你决定函数是拷贝引用还是忽略,或者如何处理自定义类的实例),可以处理循环引用,兼容性好(只要JS环境支持基本语法)。
- 缺点: 实现相对复杂,需要考虑的边缘情况多,容易出错,可能需要更多调试。
- 适用场景: 构建自己的UI组件库、框架,或者处理后端返回的、结构非常复杂且包含多种异构数据类型的数据,对拷贝行为有精确控制需求。
场景四:大型项目,追求开发效率和代码健壮性,不介意引入第三方依赖。
- 策略: 使用成熟的第三方库,如Lodash的
_.cloneDeep()
。
- 优点: 功能强大,经过大量测试,能处理几乎所有边缘情况(包括函数、DOM节点等,尽管对DOM节点的处理通常也是浅拷贝引用),性能优化良好,API简洁。
- 缺点: 增加项目依赖体积。
- 适用场景: 企业级应用开发,希望利用社区成熟方案快速解决问题,减少自研成本和风险。
在实际开发中,我通常会先考虑
structuredClone()
,如果它的限制(比如不能拷贝函数)对我当前的需求是致命的,我才会退而求其次,考虑自己实现一个带有循环引用处理的递归函数,或者直接引入Lodash。而
JSON.parse(JSON.stringify())
,我只会把它用在那些我百分百确定数据结构非常简单、纯净的场景下。选择合适的工具,才能事半功倍。
评论(已关闭)
评论已关闭