资讯动态

JavaScript深拷贝与浅拷贝:从引用类型到structuredClone的完整方案

发布时间:2026/10/3 4:45:49 来源:尧图企业网站定制
1. 先说区别它是从“变量里存什么”开始的1.1 一个能稳定复现的浅拷贝事故现场上周我接到一个线上Bug用户在后台编辑器里改了某个表单的默认值结果另一个用户刚进页面时也带着这个被改过的默认值。同事第一反应是“接口没传好”后来查了半天问题出在一行看似无辜的代码上const newConfig originalConfig;这行代码没有报错也没有警告但它的行为是newConfig和originalConfig变成了同一个对象的两个名字。用户A改的是新对象里的theme.color用户B读到的是用户A已经改过的值。原因很简单——对象是引用类型赋值传的是“指向同一块内存的地址”不是把整块数据复制一份。很多前端从写页面到写业务都踩过这个坑。它和“深拷贝、浅拷贝的区别”看起来像基础题但真到了生产环境一个遗漏就是事故。本文不打算背书概念我会从一次真实事故讲起拆到内存层面再给出一版能直接参考的手写深拷贝并和structuredClone、lodash.cloneDeep做个对比最后聊一些我踩过的边界问题。1.2 栈与堆原始类型和引用类型到底存在哪要理解浅拷贝先得知道变量里“存的到底是什么”。JavaScript 的数据类型可以粗分成两大类原始类型string、number、boolean、null、undefined、symbol、bigint引用类型Object、Array、Function、Date、RegExp、Map、Set以及由它们组成的复杂结构原始类型直接存在“栈”上变量名对应的就是真实值。比如let a 1; let b a;a和b是两个独立的内存位置各自保存数字1改b不会影响a。引用类型不一样。变量名本身存在栈里但栈里保存的不是对象本体而是一个“指向堆内存的引用”。对象本体放在“堆”里栈里的引用相当于门牌号。const obj1 { name: 小明 }; const obj2 obj1; obj2.name 小红; console.log(obj1.name); // 小红这个例子最能说明问题obj1和obj2门牌号指向同一个房间你通过obj2进去改了房间里的人名再通过obj1进去看人当然也变了。这就是“赋值不是拷贝”。浅拷贝、深拷贝则是在复制对象层面做的事。它们的目标是创建一个新的对象而不是给原对象多起一个名字。1.3 赋值、浅拷贝、深拷贝的一行代码对照用比较直观的方式把三者排开看操作方式复制了一层的值复制的数据和原数据是否完全隔离直接赋值不复制只是多了一个引用否浅拷贝复制了第一层的属性值第一层独立嵌套层仍共享深拷贝递归复制每一层的属性值完全独立这里“第一层属性值”是关键。对一个普通对象来说第一层属性值如果是字符串、数字那浅拷贝已经够了如果是数组、对象这样的引用类型浅拷贝只会把这个“引用地址”复制过去嵌套对象还是同一个。所以判断浅拷贝是否够用本质上就是一个问题你的数据是不是只有一层只要超过一层请按照事故发生的概率来决定要不要给它做深拷贝。2. 浅拷贝工具全家桶它们都只复制了一层2.1 Object.assign、展开语法、数组内置方法ES6 之后前端拿到一个对象要“复制一份”通常第一个想到的就是这三样Object.assign({}, target)展开语法{ ...target }数组上的slice()、concat()、[...arr]它们都是浅拷贝的典型代表。写法不同底层逻辑却高度一致遍历第一层可枚举属性把值复制到新对象上。const origin { name: 张三, address: { city: 上海 } }; // 浅拷贝 const copy1 Object.assign({}, origin); const copy2 { ...origin }; console.log(copy1 origin); // false第一层是新的 console.log(copy1.name origin.name); // true原始类型值一样 console.log(copy1.address origin.address); // true嵌套对象还是同一个copy1 origin是false说明它确实创建了新对象但copy1.address origin.address同时也是true说明嵌套对象根本没复制。这就是浅拷贝的“一半独立”。数组的例子更容易踩坑const arr [{ id: 1 }, { id: 2 }]; const newArr arr.slice(); newArr[0].id 99; console.log(arr[0].id); // 99slice()把数组外壳复制了一份但里面的对象元素仍然是原数组里的那些引用。2.2 怎么快速判断一个拷贝是不是浅拷贝我在实际开发里会用一个“两层法”来快速判断拷贝后修改新对象的第一层属性再修改新对象里某个嵌套对象属性看原对象是否跟着变。第一层变了原对象不变说明外壳是新的第二层变了原对象也变了说明嵌套层没有隔离。两步下来这个拷贝是什么性质就一目了然了。不需要记 API 文档跑一下就能得出结论。还有一个细节值得注意Object.assign拷贝的是属性的值如果属性本身是getter它触发的是读取行为而不是把getter的定义复制过去。const obj {}; Object.defineProperty(obj, size, { get() { return 100; }, enumerable: true }); const copy Object.assign({}, obj); console.log(copy.size); // 100但是 copy 上是一个普通值属性这个行为有时是好事有时位置不对就会让人困惑后面讲手写深拷贝时我会再提到。2.3 浅拷贝从来不是“没用”它适合哪些场景谈深拷贝之前得为浅拷贝说句公道话浅拷贝不是 Bug它只是能力边界清晰。适合浅拷贝的场景通常是这几类对象只有一层属性都是原始类型拷贝后不需要嵌套隔离。你要做的只是不可变更新比如 React 里更新 state 的某个对象字段只需要替换最外层引用让组件触发重新渲染。你想保留内部子对象的引用例如做跨模块共享配置反而希望嵌套对象是同一份。比如下面的函数式更新浅拷贝就够了setState(prev ({ ...prev, count: prev.count 1 }));这里prev的嵌套结构没有被修改只是把最外层state换成了新对象浅拷贝天然满足要求。很多刚接触不可变数据的人一上来就把所有地方改成深拷贝最后性能反而下降还引入了奇怪的问题。3. JSON.parse(JSON.stringify())最便宜但不完美的深拷贝3.1 它解决了什么问题工作原理是什么说到深拷贝绝大多数人第一反应是const clone JSON.parse(JSON.stringify(origin));这套写法的核心是利用了 JSON 的数据表达能力。JSON.stringify先把 JavaScript 对象序列化成 JSON 字符串此时对象的结构被打散成文本JSON.parse再把这个字符串解析成一个全新的 JavaScript 对象。新对象的内存分配和原对象没有直接关系嵌套层级再深也是“全新”的。对于纯数据对象——例如从接口拿到的数组、由字符串和数字组成的配置、普通的嵌套 JSON 结构——它确实是最简单、最不容易写错的深拷贝方案。我在处理接口响应缓存时经常用它一行代码省掉几十行手写逻辑。3.2 八大翻车现场Date、RegExp、undefined、Symbol……但它“不完美”不是夸张而是有一张非常长的坑位列表。以下都是我实际遇到过的第一函数直接被丢弃。对象里的method() {}经过JSON.stringify后这个属性会直接消失。第二undefined被丢弃。属性值为undefined时序列化结果里根本没有这个键。第三Symbol被丢弃。Symbol 作为属性键时JSON.stringify直接忽略。第四Date对象变成字符串。JSON.stringify会调用 Date 的toJSON把它变成 ISO 字符串所以深拷贝出来的不是Date类型。第五RegExp变成空对象。正则会序列化成{}这是个很隐蔽的坑。第六NaN、Infinity变成null。第七BigInt会直接抛错TypeError: Do not know how to serialize a BigInt。第八遇到循环引用会抛错。对象里如果有obj.self objstringify会直接报Converting circular structure to JSON。我整理了一张对照表原始数据JSON 法拷贝结果是否合理函数属性消失不合理undefined属性消失不合理Symbol 键属性消失不合理DateISO 字符串不合理RegExp空对象 {}不合理NaN / Infinitynull有可能可接受BigInt直接抛错不合理循环引用直接抛错不合理Map / Set空对象 {}不合理3.3 什么时候可以用什么时候绝对不能碰我的判断标准很简单数据是否来自“可 JSON 化的世界”。如果数据是后端接口返回的用户资料、商品列表、报表配置字段不外乎字符串、数字、布尔值、嵌套普通对象和数组那JSON.parse(JSON.stringify())完全可以胜任。它快、稳、肉眼可读。但如果数据里混了Date、RegExp、Map、Set、函数、undefined、Symbol、类实例或是对象内部存在循环引用就绝对不能依赖这个方案。一个典型案例是前端想深拷贝一个从全局状态里拿到的复杂对象里面包含了Date时间范围和函数回调JSON 法会把Date变成字符串函数消失最后逻辑悄悄出错。这种 Bug 比浅拷贝更难查因为它不会报错只会让数据处理结果和预期相差一点等用户反馈时已经到了很后面。4. 手写深拷贝从递归到能处理绝大多数类型4.1 第一版普通对象和数组的递归如果面试题是“手写深拷贝”标准答案是递归遍历对象的每一层。先从一个足够能说明思路的版本开始function deepClone(source) { if (typeof source ! object || source null) { return source; } const target Array.isArray(source) ? [] : {}; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] deepClone(source[key]); } } return target; }这个版本的逻辑是如果遇到普通值直接返回如果遇到数组或对象就新建一个容器递归处理每一个属性。hasOwnProperty是为了不把原型链上的属性拷贝进来避免出现意外。但它的问题也很明显遇到Datetypeof source是object然后会走到target[key]最终把 Date 的每一个枚举属性拷过去得到的是一个没有内部时间的假 Date。遇到RegExp同理。遇到Map、Set遍历key in source拿不到它们内部的数据。遇到循环引用直接无限递归栈溢出。4.2 特殊类型Date、RegExp、Map、Set 怎么拷要给特殊类型写分支核心原则是保持原类型的内部数据特殊处理而不是把它当普通对象去遍历属性。处理方式如下Date用new Date(source.getTime())复制时间戳。RegExp用source.source拿正则源码用source.flags拿修饰符再new RegExp(source.source, source.flags)。Map新建new Map()然后递归克隆每一个键值对。Set新建new Set()递归克隆每一个元素。ArrayBuffersource.slice(0)复制底层二进制数据。这些特殊类型不能依靠for...in遍历因为它们的数据不是普通属性而是内部槽位。我经常类比成“你复制一个游戏存档不能只复制存档文件的外壳必须把存档里的数据流也完整传过去”。4.3 循环引用不解决就会爆栈WeakMap 登场循环引用是什么最简单的例子const obj {}; obj.self obj; // 或者 const a {}; const b { a }; a.b b;对象里的某个属性直接或间接指向自己。真实项目里这种结构比想象中常见树形控件里某个节点的parent、图结构网络里的边、全局缓存里的双向指针都可能产生循环。如果递归深拷贝不带记忆功能遇到obj.self obj就会一直递归下去直到调用栈爆掉。解决方法是维护一个WeakMap把“原对象”和“克隆后的新对象”关联起来。递归处理时先查一下这个原对象是不是已经克隆过如果是直接返回已克隆的新对象这样就打断了循环。function deepClone(source, map new WeakMap()) { if (typeof source ! object || source null) { return source; } if (map.has(source)) { return map.get(source); } let target; if (source instanceof Date) { target new Date(source.getTime()); } else if (source instanceof RegExp) { target new RegExp(source.source, source.flags); } else if (source instanceof Map) { target new Map(); } else if (source instanceof Set) { target new Set(); } else if (Array.isArray(source)) { target []; } else { target {}; } map.set(source, target); if (source instanceof Map) { source.forEach((value, key) { target.set(deepClone(key, map), deepClone(value, map)); }); } else if (source instanceof Set) { source.forEach(value { target.add(deepClone(value, map)); }); } else { for (const key of Reflect.ownKeys(source)) { target[key] deepClone(source[key], map); } } return target; }这里WeakMap还有一个好处它不会阻止垃圾回收。如果使用普通Map保存原对象到克隆对象的映射当原对象不再使用时Map仍然持有引用就会导致内存泄漏。用WeakMap当原对象没有其他引用时键值对可以被回收。这是很多手写实现容易忽略的点。4.4 细节进化Symbol 键、原型、getter 到底管不管上面的代码用了Reflect.ownKeys(source)它比Object.keys强的地方是能拿到 Symbol 属性键。如果业务里经常用 Symbol 定义私有属性这步就很有必要。拿for...in只会遍历可枚举的属性Object.keys只能拿字符串键Reflect.ownKeys会把自身所有键都取出来包括不可枚举的字符串键和 Symbol 键。不过要注意不可枚举属性也被ownKeys拿到了target[key] ...赋值时会变成可枚举的普通属性这和原对象不完全一致。追求完美的话还需要用Object.defineProperty复制属性的描述符但多数业务场景不需要精细到这个粒度。原型是否要保留取决于需求。如果要保留可以加一行Object.setPrototypeOf(target, Object.getPrototypeOf(source));但我要提醒一句如果source是一个类的实例直接用{}做目标再手动拷贝属性新对象是普通Object的实例不是原来那个类的实例。它的constructor指向Object调用类里的原型方法就会失败。这也是为什么很多深拷贝库默认不保留原型而是只拷贝“看得见的数据”。保留原型需要额外处理constructor指向和原型链上的属性复杂度会明显上升。getter 的处理同样是个取舍。Reflect.ownKeys拿到的属性是描述符source[key]会触发 getter克隆的是 getter 返回的值不是 getter 函数。如果你需要保留属性的访问器定义必须读取Object.getOwnPropertyDescriptor(source, key)再通过Object.defineProperty写到目标对象上。这样是更完整的拷贝但代码量会上一个台阶。我一般只有在做框架级工具、或是对拷贝完整性有严格要求的开源项目时才会做这一步。4.5 完整版深拷贝代码与关键说明基于上面的讨论下面这个版本足够覆盖绝大多数业务场景function deepClone(source, map new WeakMap()) { if (source null || typeof source ! object) { return source; } if (map.has(source)) { return map.get(source); } let target; if (source instanceof Date) { target new Date(source.getTime()); } else if (source instanceof RegExp) { target new RegExp(source.source, source.flags); } else if (source instanceof Map) { target new Map(); } else if (source instanceof Set) { target new Set(); } else if (Array.isArray(source)) { target []; } else if (ArrayBuffer.isView(source)) { // 处理 TypedArray比如 Uint8Array target new source.constructor(source); } else { target Object.create(Object.getPrototypeOf(source)); } map.set(source, target); if (source instanceof Map) { source.forEach((value, key) { target.set(deepClone(key, map), deepClone(value, map)); }); return target; } if (source instanceof Set) { source.forEach(value { target.add(deepClone(value, map)); }); return target; } for (const key of Reflect.ownKeys(source)) { if (Object.prototype.hasOwnProperty.call(source, key)) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); } } return target; }Object.create(Object.getPrototypeOf(source))比直接用{}更稳它让克隆出来的普通对象带上原对象的原型。再配合Object.getOwnPropertyDescriptor连不可枚举属性和 getter 描述符都能保下来。这里的代价是代码复杂度更高但可控。这套代码已经能在不少真实项目里顶一阵子但它仍然不处理函数。函数是引用类型但复制一个函数通常没有意义函数内部依赖闭包克隆函数等于创建一个独立函数体却无法复制闭包环境。所以我在拷贝时保持“函数直接返回原引用”的策略业务上把它当成不可变的值处理。5. 工程化选择structuredClone、lodash.cloneDeep 还是自己写5.1 浏览器原生的 structuredClone 到底能拷什么2022 年后浏览器里多了原生的structuredCloneconst copy structuredClone(origin);它的实现是浏览器引擎级别的结构化克隆算法也支持Transferable对象如ArrayBuffer。它正确支持Date、RegExp、Map、Set、ArrayBuffer、TypedArray能处理循环引用性能也不错。但它不是万能的。如果你克隆的对象里包含函数、Symbol 属性、DOM 节点structuredClone会抛DataCloneError。它的设计目标是结构化数据函数和 Symbol 不在支持范围内。所以这个 API 最适合的场景是数据基本来自接口、且包含很多特殊类型比如配置信息里的Date和RegExp或者需要克隆一个带循环引用的对象。使用时有三个实际注意点它无法拷贝对象的原型链克隆出来的 Class 实例通常变成普通对象。这和手写版不保留深原型链的性质差不多。它不会触发 getter而是按结构化数据读取内部值。多数情况下这反而是好事。它不能拷贝函数如果你的全局状态里有eventHandler、callback只能把这些字段单独再赋值回去。const original { count: 1, date: new Date(), tags: new Set([a, b]), nested: { value: 100 } }; const copy structuredClone(original); console.log(copy.date instanceof Date); // true console.log(copy.tags instanceof Set); // true5.2 lodash.cloneDeep 为什么是“老干部”级解法lodash.cloneDeep是目前前端生态里使用范围最广的深拷贝工具。它成熟、稳定对边界情况的处理很细致。内部实现使用栈结构迭代避免深层次递归导致调用栈溢出对Date、RegExp、Map、Set、TypedArray、ArrayBuffer都有对应分支还会拷贝 Symbol 属性、不可枚举属性、保留原型链循环引用也通过标记机制处理。在真实项目里如果代码库本来就有lodash那我通常直接用它不为一个深拷贝功能额外写几层工具函数。哪怕是从零开始只要数据复杂度高、精度要求高lodash.cloneDeep也比手写更省事。我自己的经验是“手写深拷贝”主要用于面试、学习原理、造轮子生产环境里除非是刻意避免依赖否则真没必要重复造轮子。5.3 自己实现还是用库我现在的经验判断我是这么取舍的场景推荐方案纯 JSON 数据无特殊类型JSON.parse(JSON.stringify())现代浏览器、数据含特殊类型、无函数structuredClone项目已有 lodash且数据复杂度高lodash.cloneDeep需要用函数属性、保留 getter、精确原型链手写定制版本面试题 / 学习原理手写递归版本有人说structuredClone出现后lodash.cloneDeep应该被淘汰。我不太同意。structuredClone不能拷函数和 Symbol 属性而业务里“状态对象带一个函数回调”太常见了。这种情况下lodash.cloneDeep的容错能力更强。它不会帮你复制函数的调用逻辑但至少不会因为对象里有一个函数就直接抛错。5.4 性能和边界情况的对查表深拷贝的性能不在于“拷贝了多少层”而在于“总共有多少属性、多少嵌套节点”。做性能对比时JSON.parse(JSON.stringify())在纯 JSON 数据上经常是最快的structuredClone因为要走引擎原生路径也很快lodash.cloneDeep因为做了大量类型判断略慢一点我前面写的通用手写版在复杂类型上也不慢但和专门优化过遍历的lodash相比还有差距。性能不是第一决策因素。真正决定方案的是“数据里有没有函数、Symbol、循环引用、特殊类型”以及“万一拷贝失败丢的是数据还是用户信任”。我在实际项目中见过有人为了省几毫秒用 JSON 方法深拷贝一个多语言配置结果配置里的RegExp全部变成{}导致国际化判断全部失效。这种代价不是性能能补回来的。内存方面也要注意深拷贝的内存占用通常是原数据的一份完整副本。拷贝一个有十几万个节点的树内存会直接翻倍。如果只是部分节点需要隔离不要无脑全量深拷贝下面第六章会展开说。6. 深拷贝之外的一些冷知识和反直觉行为6.1 深拷贝之后两个对象“长得一样”不代表深相等实现深拷贝后很多人会下意识地认为clone origin应该是true——因为内容一样。但实际结果是false。对象相等性在 JavaScript 里不是按内容比较的。{} {}是false即使它们的内容完全相同。深拷贝创建的是一个新的内存对象它和原对象是两个东西只是数据值碰巧一样而已。如果要比较拷贝是否成功不能用要自己写一个递归深度比较或者用lodash.isEqual、JSON.stringify在纯 JSON 数据下可凑合。我踩过一次这样的坑写完深拷贝之后测试用例里写了expect(clone).toEqual(origin)Jest 的toEqual是深度比较所以通过了但后来有人改成expect(clone).toBe(origin)马上就失败了。这不是实现问题而是对“深拷贝”理解不够一致。业务代码里如果依赖引用相等做缓存判断需要格外留意。6.2 拷贝函数、拷贝类实例、拷贝 Promise 都是伪需求前面一直提到函数不拷贝是因为“拷贝函数”在业务上是个伪需求。函数可以复制一份引用但函数体内的闭包变量是原函数创建时绑定的。你复制了函数体却复制不了闭包状态也复制不了函数上的原型链上下文。如果非要制造“独立的函数副本”唯一能做的只是function clone() { return source(...arguments) }这相当于包了一层调用壳本质还是引用同一个函数。Promise 同理。拷贝一个 Promise 得到的是另一个 Promise但内部要 resolve 还是 reject、依赖的外部异步任务状态完全无法复制。你只是多了一个可以接.then的壳而已。类实例如果带私有字段或依赖构造时的副作用也很难被可靠复制。深拷贝的适用范围应该限制在“纯数据”上函数和 Promise 应该手动保留同一个引用。6.3 多层嵌套其实可以“按需拷贝”别什么都全量拷贝深拷贝不是银弹。一个全链路都做深拷贝的项目往往会在性能、内存、引用关系上同时吃亏。我处理复杂表单时有一个习惯先确认哪些字段会被修改再决定拷贝粒度。如果用户只会改detail.info.name那我可以用const newDetail { ...state.detail, info: { ...state.detail.info, name: 新值 } };这种“结构化共享”的方式只在需要改变的那几层创建新对象其他层依然复用原数据。它比深拷贝性能好也避免了嵌套层级太多时不可变数据的实现复杂度。前端的不可变数据思想本来就不是“拷贝所有数据”而是“保留未改变部分替换已改变部分”。如果数据里有一个超大数组例如 100MB 的日志列表深拷贝会消耗大量内存这时候应该改成copy-on-write思路。React 状态管理里经常用的immer库本质上也是用这一套代写复杂结构更新避免对不可变数据做低效的全量深拷贝。6.4 最后分享一个攒下来的排查技巧排查“是不是浅拷贝”导致的问题我有一个固定三步法找到赋值点。全局搜索赋值的对象引用尤其查初始化、缓存、默认参数这类代码。复现操作路径。通过修改第二个对象的第一层和第二层属性观察第一个对象是否变化。临时打印引用地址。前面说对象没有内置地址打印但可以用一个低成本的方案把一个唯一的不可枚举字段临时注入看两个对象是否共享同一份const mark Symbol(debug); origin[mark] Math.random(); console.log(copy[mark]);如果copy上有同样的值说明这个字段的引用共享了如果copy里没有说明那段路径至少走了一次浅拷贝。这个方法比断点更直观尤其适合数据经过多层函数传递时快速定位复制链路。深拷贝这个主题说难不难说简单也不简单。写代码时一个“反正数据一样”的偷懒后面可能要花三个小时去追查线上问题。我把自己的实际选择标准总结成一句话数据有多复杂拷贝方案就有多复杂先列清楚你的数据类型再决定用 JSON、原生 API、lodash 还是手写而不是拿到对象就无脑深拷贝。这样踩坑的概率会小很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑