JavaScript浅拷贝与深拷贝实战避坑指南
2026/9/16 16:08:09 网站建设 项目流程

1. 这不是选择题,是现场排障单

“浅拷贝还是深拷贝?”——这问题刚在团队群里弹出来,我就放下咖啡杯点了已读。不是因为多懂,而是上周三凌晨两点,线上订单导出功能突然开始把用户地址栏里的“北京市朝阳区”错写成“上海市浦东新区”,排查了四小时才定位到:一个被反复赋值的配置对象,底层引用没切断,上游改了城市字段,下游导出时顺手带走了。没人怪你写错了逻辑,但所有人都会问:“你拷贝的时候,到底拷的是‘影子’还是‘本体’?”

这就是浅拷贝和深拷贝的真实战场:它不常出现在面试题里,却高频埋伏在表单联动、状态管理、缓存更新、数据回滚、API响应处理这些每天都在跑的业务链路上。你用Object.assign快速复制一个表单初始值,结果提交后发现校验规则被悄悄覆盖;你用JSON.parse(JSON.stringify(obj))做“万能深拷贝”,结果遇到Date对象变成字符串、undefined字段消失、MapSet直接报错;你升级到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);

执行完这段代码,内存里发生了什么?

  • originalshallowCopy是两张不同的房产证;
  • 它们指向的“主楼”(即对象自身)是分开的:shallowCopy.name改成“李四”,original.name不变;
  • 但它们都写着同一套“单元房”的地址:shallowCopy.addressoriginal.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.nameoriginal.name各自独立;
  • deepCopy.address是全新分配的内存块,内容是{ city: "北京", district: "朝阳" },但和original.address地址不同;
  • deepCopy.hobbies是新数组,里面两个字符串是原始值(字符串是基本类型,直接复制值),但数组本身是新实例。

所以deepCopy.address.city = "上海"只影响副本,original完全不受干扰。

但“全部重盖”说起来简单,做起来全是细节雷区。深拷贝不是魔法,它必须解决三个硬核问题:

  1. 循环引用:对象 A 的属性指向对象 B,B 的某个属性又指回 A。无限递归拷贝会爆栈;
  2. 特殊内置类型DateRegExpMapSetTypedArrayErrorPromise等,它们的构造方式和序列化逻辑各不相同;
  3. 不可枚举属性与原型链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 描述)。电商商品列表页的筛选条件对象,每次点击“清除筛选”只需重置categorypriceRange这几个顶层 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
含 Datenew Date('2023-01-01')0.9Date → 字符串"2023-01-01T00:00:00.000Z"
含 RegExp/abc/g0.7RegExp → 空对象{}
含 Mapnew Map([['a', 1]])0.6报错TypeError: Converting circular structure to JSON
含 undefined{ a: 1, b: undefined }0.5b字段消失
含 function{ fn: () => {} }0.4fn字段消失

关键结论

  • 它根本不是深拷贝,而是“JSON 安全序列化+重建”。任何无法被 JSON 表示的值都会被丢弃或转换;
  • MapSetBigIntSymbolundefinedfunction全军覆没;
  • 循环引用直接崩溃,毫无容错;
  • 性能尚可,但稳定性极差,仅推荐用于纯 POJO(Plain Old JavaScript Object)且你 100% 确认不含特殊类型的场景,比如从后端 API 拿到的、结构固定的用户资料。

注意:JSON.stringify的第二个参数replacer可以定制序列化逻辑,比如把Date转成时间戳,但这需要你手动维护类型映射表,且无法解决Map等根本性缺失。我们曾为兼容旧版 IE,在replacer里写了 200 行代码处理各种类型,最后发现不如直接上structuredClone

3.2 structuredClone():现代浏览器的“官方答案”,但落地要查户口

structuredClone是 WHATWG 标准的一部分,2022 年起逐步进入主流浏览器。它基于 HTML 结构化克隆算法,能正确处理DateRegExpMapSetTypedArrayBlobFileListImageData等 20+ 种类型,且原生支持循环引用检测。

浏览器兼容性(截至 2024 年 6 月)

浏览器最低支持版本备注
Chrome98+
Firefox94+
Safari16.4+⚠️ iOS 16.4+ / macOS 13.3+,旧版需 polyfill
Edge98+
Node.js17.0+✅(需启用--experimental-vm-modules

实测数据(同上环境)

对象类型数据规模耗时(ms)是否成功特殊处理
普通对象3层嵌套,10个字段0.3
含 Datenew Date('2023-01-01')0.4Date 实例完整保留
含 RegExp/abc/g0.5RegExp 实例完整保留
含 Mapnew Map([['a', 1], ['b', 2]])0.6Map 键值对完整保留
含循环引用obj.a = obj0.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 版,最新一版支持DateRegExpMapSetArrayObject,并用 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; }

为什么生产环境不用?

  • 维护成本高:新增类型(如BigIntArrayBuffer)需手动扩展;
  • 性能不如原生: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.customMethodundefined,因为JSON只序列化可枚举自有属性。

实操技巧:如果必须用 JSON 方案,对Date统一转为时间戳(date.getTime()),对NumbertoFixed(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.propertiesbaseSchema.properties指向同一对象。修改schema.properties.xxx,等于修改了源头。

修复方式

  • 浅拷贝后,对所有嵌套对象做防御性深拷贝:const schema = { ...baseSchema, properties: deepClone(baseSchema.properties) }
  • 或者,从设计上杜绝共享:baseSchema不直接暴露,而是提供工厂函数createSchema(),每次返回全新对象。

关键认知:浅拷贝的安全边界,只存在于“你确认所有嵌套层级都不会被修改”。一旦有任意一层被写入,就必须深拷贝或重构。

5. 常见问题速查表与终极决策树

5.1 高频问题与一招解决

问题现象根本原因快速诊断解决方案
structuredClone报错DataCloneError: Function is not supported对象含functionsymbolconsole.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/Setconsole.log(obj instanceof Map)改用structuredClone或 Lodash
内存暴涨,页面卡顿深拷贝对象过大或频繁调用Chrome DevTools → Memory → Heap Snapshot 对比改用 Immerproduce,或只拷贝必要子树
Safari 旧版白屏structuredClone未定义console.log(typeof structuredClone)加载前检测,不可用时 fallback 或提示

5.2 选型决策树:5 步锁定最优解

面对一个拷贝需求,按顺序回答这 5 个问题:

  1. 对象是否含functionsymbolundefined
    → 是:structuredClone不可用,JSON会丢数据,选 Lodash 或手写过滤(移除函数再 JSON);
    → 否:进入下一步。

  2. 是否必须支持 Safari < 16.4 或 IE?
    → 是:structuredClone不可用,选 Lodash(兼容性好)或JSON(仅纯数据);
    → 否:进入下一步。

  3. 数据结构是否复杂(含MapSetDate、循环引用)?
    → 是:JSON不行,structuredClone是首选;
    → 否(纯 POJO):JSON方案足够快且安全。

  4. 拷贝频率是否高频(如每秒多次)或对象是否超大(>1MB)?
    → 是:structuredClone和 Lodash 都可能慢,优先用 Immerproduce
    → 否:任选,structuredClone推荐。

  5. 能否重构为不可变数据流?
    → 是:放弃拷贝,用 Immer 或 Redux Toolkit 的createReducer
    → 否:按前 4 步选型。

我的个人经验:在新项目中,默认用structuredClone;老项目升级,先加safeStructuredClone封装;涉及高频编辑的模块,直接上 Immer。拷贝不是目的,数据隔离和可预测性才是。

6. 最后一点真实体会

写这篇文章时,我翻出了三年前的周报,里面有一句:“本周解决深拷贝导致的表单状态污染问题,耗时 16 小时。”现在回头看,那 16 小时里,有 12 小时在试各种cloneDeep方案,剩下 4 小时才意识到:问题不在拷贝不够深,而在状态管理模型本身——我们让同一个对象在 7 个组件间流转、修改、缓存,却指望一次拷贝解决所有副作用。

所以,与其死磕“浅还是深”,不如多花十分钟画一张数据流向图:这个对象从哪来?谁会改它?改了之后谁会读?有没有更好的隔离方式?有时候,一行const next = { ...current }能解决问题;有时候,重构整个状态树,才能一劳永逸。

深拷贝是工具,不是信仰。它该被用在刀刃上,而不是成为掩盖设计缺陷的创可贴。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询