1. 从一个真实报错开始:为什么修改副本,原对象也变了?
我第一次在项目里踩到深浅拷贝的坑,是在处理一个用户配置表单的提交逻辑时。当时的需求是:用户点击“保存草稿”时,把当前所有表单字段值暂存到本地缓存;但若用户中途又点了“重置为默认”,则需恢复初始状态——注意,不是清空,而是还原到页面加载时的原始配置。
我的代码很“直觉”:
// 页面初始化时读取默认配置 const defaultConfig = { theme: 'dark', notifications: { email: true, sms: false }, features: ['a', 'b', 'c'] }; let currentConfig = defaultConfig; // 直接赋值 // 用户修改后点击“保存草稿” function saveDraft() { localStorage.setItem('draft', JSON.stringify(currentConfig)); } // 用户点击“重置为默认” function resetToDefault() { currentConfig = defaultConfig; // 再次赋值 }结果呢?用户改完主题、关掉短信通知、删掉feature 'b',点重置——页面确实“变回去了”。但等他再点一次“保存草稿”,localStorage里存的却是已经被修改过的对象。更诡异的是,连defaultConfig.notifications.sms都变成了false。
我当时盯着控制台发了三分钟呆,反复确认没写错变量名。后来加了一行调试:
console.log(defaultConfig === currentConfig); // true才猛然意识到:这根本不是“复制”,这是贴标签——两个变量名,指向内存里同一个对象地址。你撕下一张便签贴在文件夹上,再撕下另一张贴在同一位置,撕掉其中一张,文件夹还在;但你用记号笔在便签上改字,另一张便签上写的自然也跟着变——因为它们标记的是同一个东西。
这就是浅拷贝最本质的陷阱:它只复制了“引用”,没复制“实体”。而深拷贝,是要把整个文件夹连同里面每一页纸、每一个订书钉都原样再造一份,哪怕你把副本里的某页涂黑,原件依然洁白如初。
这个区别不只关乎“会不会影响原对象”,它直接决定着你的状态管理是否可靠、缓存逻辑是否可预测、组件通信是否干净。尤其在 React/Vue 的响应式系统里,一个浅拷贝误用,可能让整个 UI 树陷入不可控的重渲染风暴。所以今天这篇,我们不讲定义,不背概念,就从内存地址、堆栈结构、实际场景和手写实现四个维度,彻底拆开深浅拷贝的底层逻辑。
提示:本文所有代码示例均基于 JavaScript,但核心原理适用于任何支持引用类型的语言(如 Python、Java)。关键不在语法,而在理解“值”与“引用”在内存中的真实存在方式。
2. 内存视角:堆(Heap)与栈(Stack)如何协作完成一次赋值
要真正看懂深浅拷贝,必须把浏览器或 Node.js 的内存模型具象化。很多人说“基本类型存栈,引用类型存堆”,这句话没错,但太模糊——模糊到让你以为栈和堆是两个物理隔离的硬盘分区。其实它们是同一块内存空间里的两种管理策略,就像图书馆里“快速索引区”(栈)和“主藏书库”(堆)的关系。
我们以这段代码为例:
const user = { name: 'Alice', profile: { age: 28, city: 'Shanghai' } }; const copy = user;执行过程如下:
- 栈(Stack)分配:JavaScript 引擎在调用栈中为变量
user和copy各分配一个“槽位”(slot),每个槽位大小固定(通常 8 字节),用于存储一个内存地址。 - 堆(Heap)分配:引擎在堆内存中开辟一块连续空间,存放整个对象
{ name: 'Alice', profile: { ... } }。这块空间有唯一地址,比如0x7f8a12b4。 - 地址写入:
user槽位里写入0x7f8a12b4;copy槽位里也写入0x7f8a12b4。此时,两个变量名,共享同一个堆地址。
注意:
profile这个嵌套对象本身也是一个引用类型。它的值(即{ age: 28, city: 'Shanghai' })同样被分配在堆的另一块区域(比如0x7f8a12c8),而user.profile这个属性在user对象内部,存储的正是这个新地址0x7f8a12c8。
现在,浅拷贝发生了什么?以Object.assign({}, user)为例:
- 引擎在堆中新开辟一块空间(比如
0x7f8a12e0),用于存放新对象{}; - 然后,它把
user对象第一层的所有属性值,逐个复制过去:name: 'Alice'是字符串(基本类型),值被完整复制到新对象的name属性中;profile: 0x7f8a12c8是地址(引用类型),这个地址值0x7f8a12c8被复制过去,而不是把0x7f8a12c8地址指向的内容再复制一遍。
所以,浅拷贝后的结构是:
栈(Stack): user → 0x7f8a12b4 copy → 0x7f8a12e0 堆(Heap): 0x7f8a12b4 → { name: 'Alice', profile: 0x7f8a12c8 } 0x7f8a12e0 → { name: 'Alice', profile: 0x7f8a12c8 } ← 注意:profile 地址相同! 0x7f8a12c8 → { age: 28, city: 'Shanghai' }你修改copy.profile.city = 'Beijing',等于拿着地址0x7f8a12c8去堆里找到那块内存,把'Shanghai'改成'Beijing'。user.profile.city自然也变成'Beijing'——因为它们读的是同一块内存。
而深拷贝,目标是让copy.profile指向一个全新的地址(比如0x7f8a12f0),该地址存放着{ age: 28, city: 'Shanghai' }的完整副本。这样,两套数据彻底隔离。
关键洞察:深浅拷贝的本质差异,不在于“复制了几层”,而在于“是否对每一个引用类型的值,都递归地在堆中开辟新空间并复制其内容”。一层循环是浅,无限递归是深——但“无限”在现实中必须有边界,否则会栈溢出。
3. 实战工具链:各方案的适用边界与致命缺陷
市面上的深拷贝方案五花八门,从原生 API 到第三方库,再到手写递归。但没有银弹。每个方案都在“性能”、“完整性”、“安全性”和“兼容性”之间做取舍。下面这张表,是我过去三年在十几个中大型项目中踩坑、压测、源码分析后总结的真实对比:
| 方案 | 核心原理 | 能否处理循环引用 | 能否处理函数/undefined/symbol | 能否处理 Date/RegExp/Map/Set | 性能(10k 字段对象) | 典型缺陷 |
|---|---|---|---|---|---|---|
JSON.parse(JSON.stringify(obj)) | 序列化+反序列化 | ❌(直接报错) | ❌(丢失) | ❌(Date 变字符串,RegExp 变空对象,Map/Set 丢失) | ⚡️ 极快(~8ms) | 最大陷阱:静默丢数据。undefined、function、symbol、NaN、Infinity全部消失,Date变字符串,RegExp变{},Map/Set变空对象。线上曾因此导致用户权限校验永远失败。 |
structuredClone()(现代浏览器) | 浏览器原生克隆算法 | ✅ | ❌(function/undefined仍丢失,但symbol保留) | ✅(Date/RegExp/Map/Set/ArrayBuffer/TypedArray 全支持) | ⚡️ 快(~12ms) | 兼容性差(Chrome 98+/Firefox 94+/Safari 15.4+),Node.js 17.0+ 才支持。旧版 Safari 用户直接白屏。 |
Lodash_.cloneDeep() | 递归遍历 + 类型判断 + 特殊处理 | ✅ | ✅(function复制引用,undefined/symbol保留) | ✅(全支持) | 🐢 中等(~35ms) | 体积大(gzip 后 ~7KB),若项目已用其他工具库,易造成重复打包。对WeakMap/WeakSet无能为力(设计上就不支持)。 |
structuredClone()+ Polyfill(如core-js) | 模拟原生行为 | ✅ | ❌(Polyfill 通常不处理function) | ⚠️(部分 Polyfill 支持有限) | 🐢 中等(~40ms) | Polyfill 行为与原生不完全一致,某些边缘 case(如BigInt)表现不同,调试困难。 |
| 手写递归(带 WeakMap 缓存) | 自定义遍历 + 循环引用检测 | ✅ | ✅(可精确控制) | ✅(可扩展) | 🐢 中等(~30ms) | 开发成本高,维护难。若未正确处理Date/RegExp等,会退化为JSON.stringify级别的错误。 |
3.1 为什么JSON.stringify是“伪深拷贝”?一个血泪案例
某电商后台的商品编辑页,需要支持“版本对比”。开发同学用了JSON.parse(JSON.stringify(product))创建副本。上线后,运营反馈:商品上架时间(publishTime: new Date())一保存就变成字符串,导致前端时间组件无法解析,页面报错。
排查过程:
- 查看网络请求 payload:
"publishTime":"2023-10-15T08:30:00.000Z"—— 已是字符串; - 检查
product原始对象:publishTime确实是Date实例; - 定位到
saveDraft()方法内:const draft = JSON.parse(JSON.stringify(product));
根源在此:JSON.stringify()对Date的处理规则是调用其toISOString()方法,强制转为 ISO 字符串。这不是 bug,是规范。但业务上,publishTime需要保持Date类型,以便后续计算“距今几天”。
解决方案不是换库,而是明确需求边界:如果业务只要求“数据结构不变”,且能接受Date变字符串,那JSON.stringify依然是最快选择;但如果涉及日期计算、正则匹配、Map 键值操作,则必须升级方案。
经验:在项目初期,就应建立一份《深拷贝需求清单》,明确列出必须支持的类型(如
Date,RegExp,Map,Set,BigInt,ArrayBuffer)和必须规避的风险(如循环引用、function丢失)。以此为基准选型,避免后期推倒重来。
3.2structuredClone()的隐藏雷区:Transferable 对象的“移动语义”
structuredClone()有一个强大但危险的特性:支持Transferable对象(如ArrayBuffer,MessagePort)的零拷贝转移。这意味着,当你克隆一个包含ArrayBuffer的对象时,原对象中的ArrayBuffer会被“移动”到新对象,原对象的ArrayBuffer变成null。
const original = { data: new ArrayBuffer(1024), name: 'test' }; const cloned = structuredClone(original, { transfer: [original.data] }); console.log(original.data.byteLength); // TypeError: Cannot read property 'byteLength' of null console.log(cloned.data.byteLength); // 1024这在 Web Worker 通信中是优化利器,但在普通业务逻辑中,若你不了解此行为,就会遇到“原对象突然失效”的诡异问题。例如,一个音视频处理模块,先用ArrayBuffer加载音频数据,再克隆一份给 Web Worker 处理——若忘记transfer参数,Worker 收到的是副本;若错误加上transfer,主线程的ArrayBuffer就废了,播放直接中断。
提示:除非你明确需要 Transferable 的零拷贝能力,否则调用
structuredClone(obj)即可,不要传transfer选项。安全第一。
4. 手写深拷贝:从基础递归到工业级健壮实现
理解了原理和工具边界,最终极的能力是自己造轮子——不是为了替代 Lodash,而是为了在特殊场景下精准控制。下面,我将带你从零写出一个生产可用的深拷贝函数,每一步都解释“为什么这么写”。
4.1 第一版:基础递归(解决 80% 场景)
function deepClone(obj) { // 1. 处理 null 和基本类型:直接返回(基本类型是值传递) if (obj === null || typeof obj !== 'object') { return obj; } // 2. 区分 Array 和 Object const clone = Array.isArray(obj) ? [] : {}; // 3. 递归复制所有自有属性(不包括原型链) for (let key in obj) { if (obj.hasOwnProperty(key)) { clone[key] = deepClone(obj[key]); } } return clone; }这个版本能处理:
- 普通对象、数组;
- 嵌套任意深度的对象/数组;
undefined、null、数字、字符串、布尔值。
但它不能处理:
Date(变成空对象{});RegExp(变成空对象{});Map/Set(变成空对象{});- 循环引用(直接栈溢出)。
4.2 第二版:支持常见内置类型(覆盖 95% 场景)
我们扩展类型判断,对特殊对象进行定制化克隆:
function deepClone(obj, hash = new WeakMap()) { // 1. 处理 null 和基本类型 if (obj === null || typeof obj !== 'object') { return obj; } // 2. 检测循环引用:若已克隆过此对象,直接返回缓存副本 if (hash.has(obj)) { return hash.get(obj); } // 3. 处理 Date if (obj instanceof Date) { return new Date(obj.getTime()); } // 4. 处理 RegExp if (obj instanceof RegExp) { // 注意:flags 属性在旧版 Safari 可能不存在,需兼容 return new RegExp(obj.source, obj.flags); } // 5. 处理 Array if (Array.isArray(obj)) { const clone = []; hash.set(obj, clone); // 提前缓存,防止循环引用 for (let i = 0; i < obj.length; i++) { clone[i] = deepClone(obj[i], hash); } return clone; } // 6. 处理 Map if (obj instanceof Map) { const clone = new Map(); hash.set(obj, clone); obj.forEach((value, key) => { clone.set(deepClone(key, hash), deepClone(value, hash)); }); return clone; } // 7. 处理 Set if (obj instanceof Set) { const clone = new Set(); hash.set(obj, clone); obj.forEach(value => { clone.add(deepClone(value, hash)); }); return clone; } // 8. 处理普通对象(Object) const clone = {}; hash.set(obj, clone); for (let key in obj) { if (obj.hasOwnProperty(key)) { clone[key] = deepClone(obj[key], hash); } } return clone; }关键改进点:
- WeakMap 缓存:
hash参数用WeakMap而非普通Map,因为WeakMap的键是弱引用,不会阻止垃圾回收,避免内存泄漏。hash.set(obj, clone)在进入递归前就缓存,确保循环引用时能立即返回。 - 类型精准识别:用
instanceof而非toString.call(),因为前者更直观且性能略优(现代 JS 引擎已优化)。 - Map/Set 的键值对处理:
Map的键可以是任意类型(包括对象、函数),所以key和value都需递归克隆。
4.3 第三版:工业级加固(应对极端场景)
真实业务中,还会遇到:
BigInt(ES2020);ArrayBuffer及其视图(Uint8Array,Float64Array);Error对象;Promise(通常不深拷贝,但需明确处理);function(是否克隆?多数情况应保留引用,但需可配置)。
以下是加固后的核心片段(完整版见文末 GitHub 链接):
// 新增 BigInt 支持 if (typeof obj === 'bigint') { return obj; } // 新增 ArrayBuffer 支持 if (obj instanceof ArrayBuffer) { const clone = obj.slice(0); // slice 创建新 ArrayBuffer hash.set(obj, clone); return clone; } // 新增 TypedArray 支持(Uint8Array 等) if (ArrayBuffer.isView(obj)) { const clone = new obj.constructor(obj.buffer.slice(0), obj.byteOffset, obj.length); hash.set(obj, clone); return clone; } // 新增 Error 支持 if (obj instanceof Error) { const clone = Object.create(Object.getPrototypeOf(obj)); Object.getOwnPropertyNames(obj).forEach(key => { clone[key] = deepClone(obj[key], hash); }); return clone; } // function 处理:默认保留引用,但提供 options 控制 if (typeof obj === 'function') { if (options?.cloneFunction === true) { // 严格模式下,function.toString() 可能被篡改,此处简化处理 return eval(`(${obj.toString()})`); } return obj; // 默认不克隆,避免 this 绑定丢失 }经验:手写深拷贝不是炫技,而是为了可控。当 Lodash 的
_.cloneDeep()在某个特定对象上表现异常(如某个自定义类实例),你可以临时用自己写的版本注入调试日志,精准定位是哪个属性触发了问题。这种能力,在紧急线上故障排查时价值千金。
5. 深浅拷贝的决策树:何时用浅,何时必须深?
理论和工具都讲完了,最后回归实战:面对一个具体对象,你该如何决策?我总结了一套“三问决策树”,已在多个团队推广使用:
5.1 第一问:这个对象后续会被修改吗?
否→ 用浅拷贝(甚至直接赋值)。例如:配置常量、枚举映射表、静态模板数据。
// 安全:这些数据永不变更 const STATUS_MAP = { 0: 'pending', 1: 'success', 2: 'failed' }; const statusCopy = { ...STATUS_MAP }; // 浅拷贝足矣是→ 进入第二问。
5.2 第二问:修改是否只发生在对象的第一层属性?
是→ 浅拷贝足够。例如:表单数据对象,只修改
name、email等顶层字段,不修改嵌套的address对象内部。const formData = { name: 'Alice', email: 'a@b.com', address: { city: 'Shanghai' } }; // 用户只改 name/email,不进 address 里改 city const draft = { ...formData }; // 浅拷贝,安全否→ 进入第三问。
5.3 第三问:对象结构是否简单,且不包含特殊类型(Date/RegExp/Map/循环引用)?
是→
JSON.parse(JSON.stringify())是最快选择。例如:纯数据的 API 响应体、简单的 DTO(Data Transfer Object)。// 后端返回的用户列表,结构固定 const users = await fetch('/api/users').then(r => r.json()); const usersBackup = JSON.parse(JSON.stringify(users)); // 快,够用否→ 必须用深拷贝方案(
structuredClone或_.cloneDeep)。
5.4 一张速查表:按场景推荐方案
| 场景 | 推荐方案 | 理由 |
|---|---|---|
React/Vue 状态更新(如setState({ ...state, nested: { ...state.nested, prop: newVal } })) | 展开运算符...(浅拷贝) | 状态更新本就是浅层合并,且框架内部已做优化。深拷贝反而增加 GC 压力。 |
| 创建不可变数据快照(如 Redux DevTools、时间旅行调试) | structuredClone()(现代环境)或_.cloneDeep()(兼容环境) | 必须保证 100% 数据隔离,且需支持所有内置类型。 |
| Web Worker 通信 | structuredClone(obj, { transfer: [...] }) | 利用 Transferable 零拷贝,大幅提升大数据量传输效率。 |
| 临时备份配置供重置 | JSON.parse(JSON.stringify(config)) | 配置通常是纯 JSON 结构,无需Date/RegExp,且速度优先。 |
| 克隆一个含循环引用的复杂状态树 | 手写深拷贝(带 WeakMap 缓存) | 第三方库可能有未知 bug,手写可加日志、断点,100% 可控。 |
最后一个心得:在代码审查中,我总会关注
const xxx = {...obj}或Object.assign({}, obj)这样的浅拷贝。如果obj是从 API 获取的、结构不确定的响应数据,我会立刻追问:“这个obj里有没有嵌套对象?如果有,修改嵌套对象是否会影响其他地方?”——一个简单的提问,往往能提前拦截 70% 的深浅拷贝相关 Bug。