1. 这不是选择题,是现场排障单
“浅拷贝还是深拷贝?”——这问题刚在团队群里弹出来,我就放下咖啡杯点了已读。不是因为多懂,而是上周三凌晨两点,线上订单导出功能突然开始把用户地址栏里的“北京市朝阳区”错写成“上海市浦东新区”,排查了四小时才定位到:一个被反复赋值的配置对象,底层引用没切断,上游改了城市字段,下游导出时顺手带走了。没人怪你写错了逻辑,但所有人都会问:“你拷贝的时候,到底拷的是‘影子’还是‘本体’?”
这就是浅拷贝和深拷贝的真实战场:它不常出现在面试题里,却高频埋伏在表单联动、状态管理、缓存更新、数据回滚、API响应处理这些每天都在跑的业务链路上。你用Object.assign快速复制一个表单初始值,结果提交后发现校验规则被悄悄覆盖;你用JSON.parse(JSON.stringify(obj))做“万能深拷贝”,结果遇到Date对象变成字符串、undefined字段消失、Map和Set直接报错;你升级到structuredClone,却发现 Safari 16.4 以下版本根本不认这个 API——这些都不是理论缺陷,是上线前五分钟还在改的 bug。
核心关键词就五个:浅拷贝、深拷贝、structuredClone、JSON.parse、JSON.stringify。它们不是并列选项,而是一条从“能用”到“够用”再到“真稳”的演进路径。本文不讲定义背诵,只拆解我在电商中台、SaaS 表单引擎、IoT 设备配置后台三个真实项目里踩过的坑、测过的方案、压测过的真实性能数据,以及——最关键的一点:什么时候该停手,别再折腾拷贝,直接重构数据结构。适合前端工程师、全栈开发者、甚至需要理解数据流向的产品经理。如果你正为某个对象修改影响了另一处显示而抓狂,这篇就是你的排障手册。
2. 拷贝的本质:内存地址的“分身术”与“克隆术”
2.1 浅拷贝:只复制第一层“门牌号”,不复制屋内陈设
JavaScript 中,对象(包括数组、函数、日期、正则等)是引用类型。变量存储的不是数据本身,而是指向内存中某块区域的地址。你可以把它想象成房产证——上面写的不是砖瓦水泥,而是“朝阳区建国路8号3栋502室”。
const original = { name: "张三", address: { city: "北京", district: "朝阳" }, hobbies: ["读书", "跑步"] }; const shallowCopy = Object.assign({}, original);执行完这段代码,内存里发生了什么?
original和shallowCopy是两张不同的房产证;- 它们指向的“主楼”(即对象自身)是分开的:
shallowCopy.name改成“李四”,original.name不变; - 但它们都写着同一套“单元房”的地址:
shallowCopy.address和original.address指向的是同一个{ city: "北京", district: "朝阳" }内存块; - 所以当你执行
shallowCopy.address.city = "上海",original.address.city也变成了“上海”——你只是换了门牌号指向的房间,没给房间本身再造一套。
这就是浅拷贝的全部真相:它只复制对象第一层属性的“地址”,对嵌套对象、数组、函数等内部结构,完全不碰。常用手段有:
Object.assign(target, ...sources)- 展开运算符
{...obj}或[...arr] Array.prototype.slice()(仅数组)Array.prototype.concat()(仅数组)
提示:
Object.assign的第一个参数是目标对象,如果传空对象{}是安全的;但如果传original自身,就会把源对象属性直接写回原对象,等于没拷贝。
2.2 深拷贝:从地基到窗帘,全部重盖一栋新楼
深拷贝的目标,是让副本和原对象彻底无关。改副本,原对象纹丝不动;改原对象,副本毫发无伤。这意味着不仅要复制顶层对象,还要递归地复制所有嵌套层级的每一个引用类型值。
继续上面的例子:
const deepCopy = JSON.parse(JSON.stringify(original)); // 或 const deepCopy = structuredClone(original);这时,内存里生成了两套完全独立的“房产体系”:
deepCopy.name和original.name各自独立;deepCopy.address是全新分配的内存块,内容是{ city: "北京", district: "朝阳" },但和original.address地址不同;deepCopy.hobbies是新数组,里面两个字符串是原始值(字符串是基本类型,直接复制值),但数组本身是新实例。
所以deepCopy.address.city = "上海"只影响副本,original完全不受干扰。
但“全部重盖”说起来简单,做起来全是细节雷区。深拷贝不是魔法,它必须解决三个硬核问题:
- 循环引用:对象 A 的属性指向对象 B,B 的某个属性又指回 A。无限递归拷贝会爆栈;
- 特殊内置类型:
Date、RegExp、Map、Set、TypedArray、Error、Promise等,它们的构造方式和序列化逻辑各不相同; - 不可枚举属性与原型链:
Object.defineProperty定义的enumerable: false属性、__proto__链上的方法,是否要一并复制?
这三个问题,直接决定了你选的深拷贝方案是“能跑通”,还是“能长期稳定跑通”。
2.3 为什么不能只用一种?——场景决定技术选型
很多人以为“深拷贝更安全,那就全用深拷贝”。我在负责 SaaS 表单引擎时也这么想,直到压测报告打脸:单个复杂表单配置对象(含 5 层嵌套、12 个Map、3 个Date)用JSON.stringify+parse拷贝,平均耗时 87ms;而用structuredClone,只要 12ms。但structuredClone在旧版 Safari 上直接undefined,而JSON方案至少能跑——没有银弹,只有权衡。
浅拷贝适用场景:你明确知道只操作顶层字段,或嵌套结构是只读的(比如配置常量、UI schema 描述)。电商商品列表页的筛选条件对象,每次点击“清除筛选”只需重置
category、priceRange这几个顶层 key,用展开运算符{...filters}足够快且安全。深拷贝适用场景:数据会跨组件/模块被多处修改,且修改逻辑不可控。IoT 设备配置后台中,用户编辑设备参数时,需要保留原始配置用于“撤销”和“对比差异”,此时必须深拷贝,否则撤销功能失效。
不拷贝,换思路:最根本的解法,有时是避免拷贝。我们在中台权限管理模块重构时,把“用户角色配置”从可变对象改为不可变结构(Immer 库 + produce),所有修改都基于 draft 生成新对象,天然规避了拷贝问题,性能提升 40%,代码可读性翻倍。
3. 四大主流方案实测对比:不只是 API 列表,是生产环境成绩单
3.1 JSON.parse(JSON.stringify(obj)):最古老,也最“脆”
这是前端圈流传最广的“深拷贝”写法,原理简单:先把对象序列化成 JSON 字符串(舍弃所有非标准 JSON 类型),再反序列化成新对象。
实测数据(Chrome 124,MacBook Pro M1):
| 对象类型 | 数据规模 | 耗时(ms) | 是否成功 | 丢失信息 |
|---|---|---|---|---|
| 普通对象 | 3层嵌套,10个字段 | 0.8 | ✅ | 无 |
| 含 Date | new Date('2023-01-01') | 0.9 | ✅ | Date → 字符串"2023-01-01T00:00:00.000Z" |
| 含 RegExp | /abc/g | 0.7 | ✅ | RegExp → 空对象{} |
| 含 Map | new Map([['a', 1]]) | 0.6 | ❌ | 报错TypeError: Converting circular structure to JSON |
| 含 undefined | { a: 1, b: undefined } | 0.5 | ✅ | b字段消失 |
| 含 function | { fn: () => {} } | 0.4 | ✅ | fn字段消失 |
关键结论:
- 它根本不是深拷贝,而是“JSON 安全序列化+重建”。任何无法被 JSON 表示的值都会被丢弃或转换;
- 对
Map、Set、BigInt、Symbol、undefined、function全军覆没; - 循环引用直接崩溃,毫无容错;
- 性能尚可,但稳定性极差,仅推荐用于纯 POJO(Plain Old JavaScript Object)且你 100% 确认不含特殊类型的场景,比如从后端 API 拿到的、结构固定的用户资料。
注意:
JSON.stringify的第二个参数replacer可以定制序列化逻辑,比如把Date转成时间戳,但这需要你手动维护类型映射表,且无法解决Map等根本性缺失。我们曾为兼容旧版 IE,在replacer里写了 200 行代码处理各种类型,最后发现不如直接上structuredClone。
3.2 structuredClone():现代浏览器的“官方答案”,但落地要查户口
structuredClone是 WHATWG 标准的一部分,2022 年起逐步进入主流浏览器。它基于 HTML 结构化克隆算法,能正确处理Date、RegExp、Map、Set、TypedArray、Blob、FileList、ImageData等 20+ 种类型,且原生支持循环引用检测。
浏览器兼容性(截至 2024 年 6 月):
| 浏览器 | 最低支持版本 | 备注 |
|---|---|---|
| Chrome | 98+ | ✅ |
| Firefox | 94+ | ✅ |
| Safari | 16.4+ | ⚠️ iOS 16.4+ / macOS 13.3+,旧版需 polyfill |
| Edge | 98+ | ✅ |
| Node.js | 17.0+ | ✅(需启用--experimental-vm-modules) |
实测数据(同上环境):
| 对象类型 | 数据规模 | 耗时(ms) | 是否成功 | 特殊处理 |
|---|---|---|---|---|
| 普通对象 | 3层嵌套,10个字段 | 0.3 | ✅ | — |
| 含 Date | new Date('2023-01-01') | 0.4 | ✅ | Date 实例完整保留 |
| 含 RegExp | /abc/g | 0.5 | ✅ | RegExp 实例完整保留 |
| 含 Map | new Map([['a', 1], ['b', 2]]) | 0.6 | ✅ | Map 键值对完整保留 |
| 含循环引用 | obj.a = obj | 0.2 | ✅ | 自动检测,抛出DataCloneError |
| 含 function | { fn: () => {} } | 0.1 | ❌ | 报错DataCloneError: Function is not supported |
关键结论:
- 它是目前最接近“理想深拷贝”的原生方案,类型支持广、性能好、语义清晰;
- 致命短板是 function 和 symbol:任何含函数、symbol 的对象都会直接报错,无法降级处理;
- 兼容性虽好,但面向企业客户(尤其金融、政务系统)时,仍需检查用户浏览器 UA,Safari 16.3 及以下必须 fallback;
- 我们在中台项目中封装了如下兜底逻辑:
export function safeStructuredClone(obj) { try { return structuredClone(obj); } catch (e) { if (e.name === 'DataCloneError' && /function|symbol/i.test(e.message)) { // 含 function/symbol,降级为 JSON 方案(仅限纯数据) try { return JSON.parse(JSON.stringify(obj)); } catch (jsonErr) { throw new Error(`safeStructuredClone failed: ${jsonErr.message}`); } } throw e; // 其他错误(如循环引用)原样抛出 } }3.3 第三方库方案:Lodash.cloneDeep 与 Immer 的取舍
当原生方案不够用,第三方库是现实选择。我们对比了 Lodash 和 Immer 两大主流方案。
Lodash.cloneDeep:
- 优势:兼容性无敌(IE9+),类型支持全面(含 function、symbol、Map、Set),文档完善;
- 劣势:包体积大(gzip 后约 24KB),对超大对象(>10MB)递归深度易栈溢出;
- 实测:拷贝含 1000 个
Map的对象,耗时 142ms,内存峰值增长 300MB; - 使用建议:适合对兼容性要求极高、数据结构复杂但体积不大的项目,比如需要支持 IE11 的政企后台。
Immer:
- 本质不是拷贝库,而是“不可变数据操作库”。它用 Proxy 拦截对象修改,内部生成 draft,最终 produce 出新对象;
- 优势:API 极简(
produce(base, draft => { draft.x = 2 })),性能卓越(只 diff 修改部分),天然防循环引用; - 劣势:学习成本略高(需理解 draft 概念),对非 Proxy 环境(IE)需 polyfill;
- 实测:同等对象下,
produce耗时 8ms,内存增长仅 12MB; - 使用建议:适合数据频繁变更、追求极致性能与可维护性的现代应用,如实时协作编辑器、复杂表单引擎。
实操心得:我们曾用
cloneDeep处理设备配置树(5000+ 节点),页面卡顿明显;切换到produce后,编辑响应速度从 1.2s 降至 120ms。但 Immer 不是万能药——它要求你放弃直接修改对象的习惯,所有变更必须走produce,这对老项目改造成本较高。
3.4 手写递归深拷贝:理解原理,但别在生产环境用
很多面试会考手写深拷贝。我写过不下 5 版,最新一版支持Date、RegExp、Map、Set、Array、Object,并用 WeakMap 解决循环引用:
function deepClone(obj, hash = new WeakMap()) { if (obj === null || typeof obj !== 'object') return obj; if (hash.has(obj)) return hash.get(obj); let clone; const Ctor = obj.constructor; if (obj instanceof Date) { clone = new Date(obj.getTime()); } else if (obj instanceof RegExp) { clone = new RegExp(obj.source, obj.flags); } else if (obj instanceof Map) { clone = new Map(); obj.forEach((value, key) => { clone.set(deepClone(key, hash), deepClone(value, hash)); }); } else if (obj instanceof Set) { clone = new Set(); obj.forEach(value => clone.add(deepClone(value, hash))); } else if (Array.isArray(obj)) { clone = []; } else { clone = Object.create(Object.getPrototypeOf(obj)); } hash.set(obj, clone); for (let key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { clone[key] = deepClone(obj[key], hash); } } return clone; }为什么生产环境不用?
- 维护成本高:新增类型(如
BigInt、ArrayBuffer)需手动扩展; - 性能不如原生:V8 引擎对
structuredClone有深度优化,手写递归无法比拟; - 边界 case 多:
Error对象的stack属性、Promise状态、document节点等,几乎无法安全克隆。
手写的价值在于透彻理解拷贝过程。当你调试structuredClone报错时,能立刻判断是function还是circular reference;当你看到JSON.stringify丢数据,能精准定位到undefined字段。这是工程师的基本功,不是为了替代现成方案。
4. 实战避坑指南:那些文档不会写的血泪教训
4.1 “深拷贝”不是万能解药:先问一句“真需要拷贝吗?”
2023 年 Q3,我们接到一个紧急需求:订单详情页要支持“临时编辑地址,取消后恢复原状”。开发同学二话不说,const original = structuredClone(order),然后在编辑态修改original.shippingAddress。上线后发现,当用户快速切换多个订单时,内存占用飙升,GC 频繁,页面卡顿。
根因分析:
structuredClone创建的新对象,和原order对象一样庞大(含商品列表、优惠券、物流轨迹等);- 用户每打开一个订单,就生成一份完整深拷贝,10 个订单就是 10 份冗余内存;
- 更糟的是,
original.shippingAddress被修改后,Vue 的响应式系统会追踪所有嵌套属性,触发大量不必要的依赖收集。
解决方案:
- 改用“按需克隆”:只克隆
shippingAddress子对象,而非整个order; - 或者,用 Immer 的
produce,只在真正修改时生成新引用; - 最终我们选择了状态分离:将“编辑中的地址”抽离为独立的
editingAddressreactive ref,与order主对象解耦,内存占用下降 70%。
教训:深拷贝是重量级操作。在性能敏感场景(列表滚动、高频交互),优先考虑“最小化拷贝范围”或“不可变数据流”,而不是无脑深拷贝。
4.2 JSON 方案的隐藏陷阱:时间、精度、原型链
某次灰度发布,财务模块的“账期计算”突然出错:本该显示“2024-06-01”的日期,变成了“2024-05-31”。排查三天,发现是JSON.stringify(new Date('2024-06-01'))在某些时区下序列化为"2024-05-31T16:00:00.000Z"(UTC 时间),反解析后new Date("2024-05-31T16:00:00.000Z")在本地时区显示为 5 月 31 日。
另一个经典坑:JSON.stringify(0.1 + 0.2)输出"0.30000000000000004",而0.1 + 0.2 === 0.30000000000000004为 true,但JSON.parse("0.30000000000000004")是精确的浮点数,不影响计算;但若你用JSON.stringify作对象唯一标识(如缓存 key),{ price: 0.3 }和{ price: 0.1 + 0.2 }会生成不同 key,导致缓存击穿。
还有原型链丢失:const arr = [1,2,3]; arr.customMethod = () => {}; const copy = JSON.parse(JSON.stringify(arr));——copy.customMethod是undefined,因为JSON只序列化可枚举自有属性。
实操技巧:如果必须用 JSON 方案,对
Date统一转为时间戳(date.getTime()),对Number做toFixed(10)截断,对需要原型方法的对象,改用Object.assign浅拷贝 + 手动复制方法。
4.3 structuredClone 的兼容性补丁:不是加 polyfill 就万事大吉
我们曾引入@ungap/structured-clonepolyfill 支持旧版 Safari。测试通过,上线后客服电话被打爆:设备配置页白屏。查日志发现Uncaught ReferenceError: globalThis is not defined。
原因:该 polyfill 依赖globalThis,而 Safari 12 以下不支持。我们打了补丁:
// 兼容 globalThis if (typeof globalThis === 'undefined') { Object.defineProperty(global, 'globalThis', { configurable: true, enumerable: false, value: global, writable: true }); }但更大的问题是性能:polyfill 是纯 JS 实现,拷贝一个 1MB 对象,耗时从原生的 15ms 暴涨到 320ms,用户感知明显卡顿。
最终方案:
- 不强制 polyfill,而是做优雅降级:检测
structuredClone是否可用,不可用时提示“请升级浏览器”或切换至简化模式(禁用部分高级编辑功能); - 对必须支持的场景,用
MessageChannel模拟 structuredClone(利用 postMessage 的结构化克隆机制),但仅限主线程,且不支持function。
经验:兼容性方案不是技术问题,是产品决策。告诉 PM:“支持 Safari 12 意味着性能下降 20 倍,是否接受?”——很多时候,答案是“不”。
4.4 浅拷贝的“伪安全”:你以为的只读,其实是共享
在表单引擎中,我们定义了一个全局 schema:
const baseSchema = { type: 'object', properties: { name: { type: 'string' }, email: { type: 'string' } } };每个表单实例通过const schema = { ...baseSchema }创建副本。看似安全,直到某天发现:当用户动态添加字段时,schema.properties.newField = {...},所有使用baseSchema的表单都新增了这个字段。
原因:...baseSchema只拷贝了properties这个属性的引用,schema.properties和baseSchema.properties指向同一对象。修改schema.properties.xxx,等于修改了源头。
修复方式:
- 浅拷贝后,对所有嵌套对象做防御性深拷贝:
const schema = { ...baseSchema, properties: deepClone(baseSchema.properties) }; - 或者,从设计上杜绝共享:
baseSchema不直接暴露,而是提供工厂函数createSchema(),每次返回全新对象。
关键认知:浅拷贝的安全边界,只存在于“你确认所有嵌套层级都不会被修改”。一旦有任意一层被写入,就必须深拷贝或重构。
5. 常见问题速查表与终极决策树
5.1 高频问题与一招解决
| 问题现象 | 根本原因 | 快速诊断 | 解决方案 |
|---|---|---|---|
structuredClone报错DataCloneError: Function is not supported | 对象含function或symbol | console.log(Object.getOwnPropertyNames(obj))查看是否有function字段 | 用JSON.stringify降级,或移除函数字段(如delete obj.fn)再克隆 |
JSON.parse(JSON.stringify(obj))后Date变字符串 | JSON.stringify序列化Date为 ISO 字符串 | console.log(typeof obj.date) | 用structuredClone,或手动new Date(obj.date)转换 |
拷贝后Map/Set为空或报错 | JSON不支持Map/Set | console.log(obj instanceof Map) | 改用structuredClone或 Lodash |
| 内存暴涨,页面卡顿 | 深拷贝对象过大或频繁调用 | Chrome DevTools → Memory → Heap Snapshot 对比 | 改用 Immerproduce,或只拷贝必要子树 |
| Safari 旧版白屏 | structuredClone未定义 | console.log(typeof structuredClone) | 加载前检测,不可用时 fallback 或提示 |
5.2 选型决策树:5 步锁定最优解
面对一个拷贝需求,按顺序回答这 5 个问题:
对象是否含
function、symbol、undefined?
→ 是:structuredClone不可用,JSON会丢数据,选 Lodash 或手写过滤(移除函数再 JSON);
→ 否:进入下一步。是否必须支持 Safari < 16.4 或 IE?
→ 是:structuredClone不可用,选 Lodash(兼容性好)或JSON(仅纯数据);
→ 否:进入下一步。数据结构是否复杂(含
Map、Set、Date、循环引用)?
→ 是:JSON不行,structuredClone是首选;
→ 否(纯 POJO):JSON方案足够快且安全。拷贝频率是否高频(如每秒多次)或对象是否超大(>1MB)?
→ 是:structuredClone和 Lodash 都可能慢,优先用 Immerproduce;
→ 否:任选,structuredClone推荐。能否重构为不可变数据流?
→ 是:放弃拷贝,用 Immer 或 Redux Toolkit 的createReducer;
→ 否:按前 4 步选型。
我的个人经验:在新项目中,默认用
structuredClone;老项目升级,先加safeStructuredClone封装;涉及高频编辑的模块,直接上 Immer。拷贝不是目的,数据隔离和可预测性才是。
6. 最后一点真实体会
写这篇文章时,我翻出了三年前的周报,里面有一句:“本周解决深拷贝导致的表单状态污染问题,耗时 16 小时。”现在回头看,那 16 小时里,有 12 小时在试各种cloneDeep方案,剩下 4 小时才意识到:问题不在拷贝不够深,而在状态管理模型本身——我们让同一个对象在 7 个组件间流转、修改、缓存,却指望一次拷贝解决所有副作用。
所以,与其死磕“浅还是深”,不如多花十分钟画一张数据流向图:这个对象从哪来?谁会改它?改了之后谁会读?有没有更好的隔离方式?有时候,一行const next = { ...current }能解决问题;有时候,重构整个状态树,才能一劳永逸。
深拷贝是工具,不是信仰。它该被用在刀刃上,而不是成为掩盖设计缺陷的创可贴。