深入浅出地讲解在JavaScript中深拷贝包含嵌套对象的数组的多种实用解决方案,解析浅拷贝失效原理,对比现代原生structuredClone、手写递归函数与JSON方案的适用边界与细节。
JS怎么深拷贝包含嵌套对象的数组?
时间:2026-11-03
浏览:537
文章目录
在 JavaScript 中深拷贝一个包含嵌套对象的数组,核心在于为数组本身以及其中每一个嵌套对象都分配全新的堆内存空间,使修改副本的任何深层属性都不会污染原数组。最直接且推荐的做法是使用现代浏览器和 Node.js 原生支持的标准 API structuredClone();如果处于旧版环境或需要应对特殊对象与循环引用,可以编写带有 WeakMap 缓存的递归克隆函数;而对于没有特殊类型与循环引用的纯数据结构,使用 JSON.parse(JSON.stringify(arr)) 则能以极少的代码完成深拷贝。
一、为什么常规方法无法深拷贝嵌套数组?
初学者常常尝试使用数组切片 arr.slice()、扩展运算符 [...arr] 或 Array.from(arr) 来复制数组。这些方法确实会返回一个“新数组”,但它们都属于浅拷贝(Shallow Copy)。
当数组的项是原始类型(如数字、字符串)时,浅拷贝看起来符合预期;但如果数组项是对象或子数组,浅拷贝只会复制该对象的内存引用地址。结果就是,新数组和原数组在对应索引位置上保存的是指向同一个堆内存对象的指针。
// 浅拷贝的陷阱示例
const original = [
{ id: 1, info: { name: 'Alice', age: 24 } }
];
// 使用扩展运算符进行浅拷贝
const shallowCopy = [...original];
// 修改浅拷贝副本内部嵌套对象的属性
shallowCopy[0].info.name = 'Bob';
// 检查原数组输出(示例输出)
console.log(original[0].info.name); // 输出: "Bob"(原数组被意外污染!)
console.log(shallowCopy[0] === original[0]); // 输出: true(指向同一内存实体)
从上面的代码可以看到,shallowCopy[0] 与 original[0] 严格相等。这意味着你在业务逻辑(如表单编辑暂存、撤销重做、组件不可变状态更新)中修改副本时,原数组的数据已经被同步篡改,极易引发难以排查的 Bug。
二、方案选择与操作步骤全景
面对包含嵌套对象的数组,开发者应当根据运行环境、数据类型复杂度和项目依赖情况,采取清晰的决策步骤。
按照上述决策流程,下面逐一展开几种主流解决方案的具体用法、完整代码及注意事项。
三、方案一:现代标准原生方法 structuredClone()
structuredClone() 是 HTML 规范中定义的结构化克隆算法在 JavaScript 全局环境下的标准实现。现代主流浏览器(Chrome 98+、Firefox 94+、Safari 15.4+)以及 Node.js(17.0.0+)均已原生内置该方法。
1. 基础用法
// 包含深层嵌套对象的原始数组
const originalList = [
{ id: 101, meta: { title: 'JavaScript 教程', tags: ['Web', 'FrontEnd'] } },
{ id: 102, meta: { title: 'Node.js 实战', tags: ['Backend'] } }
];
// 使用原生 structuredClone 进行深拷贝
const clonedList = structuredClone(originalList);
// 修改新数组中的深层属性与数组项
clonedList[0].meta.title = 'TypeScript 进阶';
clonedList[0].meta.tags.push('Programming');
// 示例输出验证
console.log(originalList[0].meta.title);
// 输出: "JavaScript 教程"(原数组完好无损)
console.log(originalList[0].meta.tags);
// 输出: ["Web", "FrontEnd"](嵌套的子数组同样实现了深拷贝)
console.log(clonedList[0] === originalList[0]);
// 输出: false(内存地址完全独立)
2. 能力边界与限制
structuredClone() 内部由底层 C++ 引擎驱动,性能优秀,且天然支持处理以下复杂场景:
- 循环引用:如果数组内部的对象相互引用或指向自身,算法会自动维护引用关系,不会死循环。
- 丰富内置对象:能够完整克隆
Date、RegExp、Map、Set、ArrayBuffer等。
注意限制:structuredClone() 无法克隆 Function(函数)、DOM 节点以及对象的原型链。如果嵌套对象中包含方法函数或 Symbol 属性,调用该方法会直接抛出 DOMException (DataCloneError) 错误。
四、方案二:轻量纯数据方案 JSON 序列化法
如果数组中的数据全部来源于接口返回的标准 JSON 数据(只包含字符串、数字、布尔值、普通对象和数组),使用 JSON.parse(JSON.stringify(arr)) 是最为简便的单行写法。
1. 基础示例
const dataset = [
{ id: 1, user: { username: 'dev_user', roles: ['admin', 'editor'] } },
{ id: 2, user: { username: 'guest_user', roles: ['viewer'] } }
];
// 单行深拷贝
const copyDataset = JSON.parse(JSON.stringify(dataset));
// 验证独立性
copyDataset[0].user.roles.push('super');
console.log(dataset[0].user.roles.length); // 示例输出: 2 (原数组不受影响)
console.log(copyDataset[0].user.roles.length); // 示例输出: 3
2. 致命缺陷与数据失真风险
虽然语法简单且兼容所有已知 JS 引擎,但该方案并不是真正的对象图遍历克隆,而是先转为文本再重新构造,存在以下缺陷:
- 忽略特定值:对象属性值为
undefined、Function或Symbol时,序列化后该键值对会被直接丢弃;若数组项本身为这些值,会被转为null。 - 日期对象降级:
Date对象会被转换成 ISO 日期字符串,拷贝后丢失 Date 实例的所有方法。 - 正则与异常对象变为空对象:
RegExp会被转为空对象{}。 - 循环引用崩溃:如果数组中的对象存在循环引用,
JSON.stringify会直接抛出TypeError: Converting circular structure to JSON。
五、方案三:手写完备递归深拷贝函数(带 WeakMap 破环)
当需要兼容低版本环境,或者数组中的嵌套对象包含复杂结构、循环引用且需要灵活定制拷贝规则时,手写一个递归深拷贝函数是最具掌控力的方式。
1. 完整实现代码
/**
* 深拷贝函数(支持包含嵌套对象的数组及循环引用)
* @param {*} target 待拷贝目标
* @param {WeakMap} map 记忆字典,用于解决循环引用
* @returns {*} 深拷贝后的新实体
*/
function deepClone(target, map = new WeakMap()) {
// 1. 处理原始类型(数值、字符串、布尔、null、undefined、Symbol、BigInt)及函数
if (target === null || typeof target !== 'object') {
return target;
}
// 2. 处理特殊内置引用类型
if (target instanceof Date) {
return new Date(target);
}
if (target instanceof RegExp) {
return new RegExp(target.source, target.flags);
}
// 3. 检查循环引用:若当前对象已被克隆过,直接返回已有副本
if (map.has(target)) {
return map.get(target);
}
// 4. 根据当前目标类型初始化容器(数组或普通对象)
const isArray = Array.isArray(target);
const cloneTarget = isArray ? [] : {};
// 记录映射关系到 WeakMap
map.set(target, cloneTarget);
// 5. 遍历键名并递归拷贝子项
if (isArray) {
for (let i = 0; i < target.length; i++) {
cloneTarget[i] = deepClone(target[i], map);
}
} else {
// 获取对象自身所有可枚举属性
const keys = Object.keys(target);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
cloneTarget[key] = deepClone(target[key], map);
}
}
return cloneTarget;
}
2. 测试带有循环引用和嵌套对象的数组
// 构建包含嵌套对象和循环引用的测试数组
const complexArray = [
{
id: 1,
detail: { createdAt: new Date(), pattern: /^[a-z]+$/i },
selfRef: null
}
];
// 构造循环引用:将自身挂载到属性上
complexArray[0].selfRef = complexArray[0];
// 执行手写深拷贝
const deepResult = deepClone(complexArray);
// 验证结果(示例输出)
console.log(deepResult[0] !== complexArray[0]); // 输出: true (深拷贝成功)
console.log(deepResult[0].detail.createdAt instanceof Date); // 输出: true (保留Date实例)
console.log(deepResult[0].selfRef === deepResult[0]); // 输出: true (闭环引用正确保持)
console.log(deepResult[0].selfRef !== complexArray[0]); // 输出: true (新环与旧环隔离)
为什么使用 WeakMap 而不是 Map?
WeakMap 的键必须是对象,并且持有的是“弱引用”。在递归拷贝巨大规模的嵌套对象时,一旦克隆流程结束,这些临时对象在没有其他引用的情况下可以被垃圾回收器(GC)及时清理,避免造成内存泄漏。
六、方案四:工程化环境中的第三方库(Lodash cloneDeep)
在大型企业级前端项目或后端 Node.js 服务中,通常已有成熟的工具库。lodash 提供的 cloneDeep 经历了多年的社区测试,涵盖了原型链、TypedArray、Buffer、Map、Set 以及各种冷门边界情况。
// 推荐按需引入,避免将整个 lodash 库打包进来
import cloneDeep from 'lodash/cloneDeep';
const listWithNested = [
{ id: 1, config: { theme: 'dark', retries: 3 } },
{ id: 2, config: { theme: 'light', retries: 0 } }
];
const safeCopy = cloneDeep(listWithNested);
safeCopy[0].config.theme = 'system';
// 示例输出
console.log(listWithNested[0].config.theme); // 输出: "dark"
七、各种方案综合能力与边界总结
为了便于在不同业务场景中快速选型,下表对常见深拷贝实现方式进行了横向对比:
| 解决方案 | 嵌套对象/数组 | 循环引用 | Date / 正则 | 额外打包体积 | 推荐使用场景 |
|---|---|---|---|---|---|
| structuredClone | 完整支持 | 原生支持 | 完整支持 | 0(原生API) | 现代浏览器 / Node.js 17+ 首选 |
| 手写递归 + WeakMap | 完整支持 | 支持 | 可按需定制 | 极小(纯代码片段) | 无额外依赖要求、定制需求或面试实现 |
| JSON 序列化法 | 基础支持 | 直接报错 | 降级/丢失 | 0 | 结构简单、确认无特殊类型的纯数据 |
| Lodash cloneDeep | 完整支持 | 完备支持 | 完备支持 | 约 15KB(未Tree-shake时) | 大型已有Lodash依赖的复杂工程 |
八、常见误区与避坑建议
- 误用
Array.prototype.concat()或slice(0):这两个 API 与扩展运算符一样,只在数组顶层产生新数组,嵌套层级依然是浅拷贝。 - 使用
Array.prototype.map()时未递归:写成arr.map(item => ({ ...item }))仅能解决单层嵌套对象的拷贝;如果对象内部还嵌套了子对象(如item.user.address.city),更深层的属性依然是共享引用的。 - 频繁深拷贝大数组的性能损耗:深度递归与内存分配在面对成千上万条记录的超大数组时会带来显著的 CPU 和内存开销。如果在前端状态管理中遇到性能瓶颈,应考虑使用结构共享(Structural Sharing)库(如
Immer),在保持不可变性的同时仅复制被修改的节点分支。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。