
搞懂深浅拷贝前端开发才算真正入门。这不是危言耸听。我见过太多线上事故表面上看是“某个对象被意外修改了”追到根上全是因为拷贝没做对——该深拷贝的地方用了浅拷贝嵌套对象共享了内存引用一处改动全线崩盘。反过来也有有人只要碰到对象赋值就无脑深拷贝大列表渲染直接卡到白屏性能问题又冒出来。浅拷贝和深拷贝本质上是搞懂JavaScript里“值”和“引用”的关系。一个对象赋给另一个变量到底是在复制数据还是在共享同一块内存搞不清这个写代码就像在走钢丝。这篇文章我把这块内容掰开揉碎讲清楚从底层原理到工程实践从JSON方案的坑到手写递归方案再到现代浏览器原生的structuredClone一次性把深浅拷贝捋顺。无论是刚入门的新手还是写过几年项目但没系统梳理过的老手都能拿到可以直接用的结论和套路。1. 先搞清楚为什么要拷贝原始值与引用值的底层差异很多人写了好几年JavaScript遇到嵌套对象还是靠“试”来写代码——先复制一下跑一下发现改了原数据再换种方式试。这样太累了。深浅拷贝的一切都源于JavaScript数据类型在内存里的两种存储方式。1.1 原始类型和引用类型的本质区别原始类型包括字符串、数字、布尔值、null、undefined、Symbol和BigInt它们直接存储在栈内存里。你复制一个原始值就是在栈上新开一个位置把值完整抄一份过去两个变量从此互不相干。引用类型包括对象、数组、函数以及Map、Set、Date、RegExp这些内置对象。它们本身的数据放在堆内存中栈内存里存的是一个指向堆内存的地址。你复制一个对象变量实际上只复制了那个地址两个变量仍然指向同一个堆对象。我用一个生活化的例子解释。原始值就像一张纸上的数字你抄一遍两张纸上各写各的改一张不影响另一张。引用值更像一把钥匙钥匙可以配好几把但配出来的钥匙开的都是同一扇门你用这把钥匙进去换了家具另一把钥匙打开门看到的家具也全变了。1.2 为什么浅拷贝会“改一处坏一片”浅拷贝的本质是只复制第一层复制后的对象里嵌套的子对象仍然是同一个引用。看这个经典例子const user { name: 张三, address: { city: 杭州, district: 西湖区 } }; const copy { ...user }; copy.name 李四; copy.address.city 滨江区; console.log(user.address.city); // 输出 滨江区 console.log(user.name); // 输出 张三第一层的name独立开了改copy的name不影响user。但address这个子对象copy和user里存的是同一个内存地址所以一旦修改了嵌套层级的属性原始对象跟着变。这正是浅拷贝最容易埋雷的地方。React项目里的useState、Vue项目里的reactive数据如果一不小心把嵌套对象浅拷贝了一份就丢到别处去改组件之间的状态会莫名其妙地串在一起而且这种Bug极难排查因为看起来哪里都没写错数据却一直在“灵异变化”。1.3 深拷贝要解决的本质问题深拷贝的目标是递归复制所有层级的属性每一层都生成全新的对象最终得到的副本与原数据在内存中完全独立、互不影响。改副本的任何一层都不会动到原数据。这个需求在现实中非常普遍。表单编辑的草稿、组件之间传递复杂数据、状态管理里的快照回滚、接口数据的本地缓存都需要“完整地复制一份”而不是“共享同一块内存”。搞清楚了为什么要拷贝接下来就看看浅拷贝这个折中方案到底怎么用、什么时候用。2. 浅拷贝能用但要看懂边界浅拷贝并非一无是处。在很多场景下浅拷贝是刻意为之的高效选择因为不需要深层次的独立数据只需要把最外层属性复制出来就行。但它有三道边界必须心里有数。2.1 三种最常用的浅拷贝方式第一种展开运算符。这是ES6之后最常用的方式。const copy { ...original }; const arrCopy [...originalArr];第二种Object.assign。老牌API效果和展开运算符几乎一致。const copy Object.assign({}, original);第三种手动遍历赋值。底层原理也是理解浅拷贝本质的最佳入口。function shallowCopy(obj) { const result {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { result[key] obj[key]; } } return result; }注意for...in要配合hasOwnProperty使用否则会遍历到原型链上的属性。这三种方式其实干的是同一件事把目标对象的所有可枚举自有属性复制到新对象里。属性值如果是原始类型那就是真的复制如果是引用类型复制的是引用地址。2.2 数组浅拷贝的两种特殊姿势数组也是引用类型它的浅拷贝除了展开运算符还有更隐蔽的处理方式。const arr [1, 2, { x: 10 }]; const sliceCopy arr.slice(); const concatCopy [].concat(arr); // slice和concat对数组的浅拷贝结果等价于展开运算符 // 但是它们都只做到第一层arr[2]这个对象依然是共享引用slice和concat经常被误认为是深拷贝工具因为数组里的原始类型确实被完整复制了。但数组中一旦有对象元素它们就会原形毕露。面试里经常问“slice是深拷贝还是浅拷贝”答案是对只有原始类型的一维数组它效果上等同于深拷贝对含对象的数组它是浅拷贝。能把这个边界讲清楚的说明是真的理解了。2.3 实战中浅拷贝的适用场景浅拷贝不是“残次品”它有自己的使用场景。常见的就是React状态管理里更新不可变数据。// 更新state中的某个嵌套字段推荐做法是逐层浅拷贝 setState(prev ({ ...prev, settings: { ...prev.settings, theme: dark } }));这里每一层都用展开运算符浅拷贝合起来就实现了嵌套对象的目标替换。这不是因为浅拷贝万能而是React的不可变数据要求你不能直接修改state里的对象而是创建新对象并替换需要变更的层级。浅拷贝在这里既保留了对未修改层级的引用复用性能好又确保了修改路径上的对象引用全部更新触发正确的组件渲染。浅拷贝的边界已经很清楚了只独立第一层嵌套层共享。如果你的数据是多层嵌套结构且任何一层都可能被改那浅拷贝就不够用了得请出深拷贝。3. 深拷贝真正把数据复制一份深拷贝的话题永远绕不开那个又爱又恨的JSON方案。说它方便是真的方便说它坑多也是真的多。先把它的几大硬伤摆出来再给出真正能打的可选方案。3.1 JSON方案的硬伤五个必踩的雷const clone JSON.parse(JSON.stringify(original));这行代码是互联网上流传最广的深拷贝写法。查一下现在的GitHub仓库还能看到一堆老项目里留着它。它确实是最快的零依赖方案但它有五个硬伤每一个在真实项目里都可能变成事故。硬伤一undefined、函数、Symbol会被直接丢弃。对象里有这些属性拷贝完之后就消失了。const original { name: test, sayHi: () console.log(hi), special: undefined }; const clone JSON.parse(JSON.stringify(original)); // clone { name: test } // 函数和undefined属性直接没了硬伤二NaN和Infinity会被转成null。这对数据型应用来说是致命的。JSON.parse(JSON.stringify({ score: NaN })); // { score: null } JSON.parse(JSON.stringify({ num: Infinity })); // { num: null }硬伤三Date对象会被转成字符串类型都变了。const d new Date(); const clone JSON.parse(JSON.stringify({ time: d })); // clone.time 是字符串不再是Date实例硬伤四循环引用直接抛错。对象里如果存在自己引用自己的情况JSON.stringify会直接抛出“Converting circular structure to JSON”的异常。const obj {}; obj.self obj; JSON.parse(JSON.stringify(obj)); // 直接报错硬伤五Map、Set、RegExp、BigInt这些特殊类型会退化成空对象或直接报错。这五个硬伤只要碰上一个就是线上事故。所以我对JSON.parse(JSON.stringify())的态度很明确只在数据形状极其简单、确定不含特殊类型和循环引用的场景下使用比如深拷贝一个纯字段的接口响应数据。3.2 手写递归深拷贝一个能用的实现理解了JSON方案的局限再来看看手写递归方案。很多人背面试题时能写个七八成但真正要落地到项目里边界情况处理得不够。这里给一个工程可用的增强版。function deepClone(target, map new WeakMap()) { // 基础类型直接返回 if (target null || typeof target ! object) { return target; } // 处理循环引用 if (map.has(target)) { return map.get(target); } // 处理特殊类型 if (target instanceof Date) { return new Date(target.getTime()); } if (target instanceof RegExp) { const reg new RegExp(target.source, target.flags); reg.lastIndex target.lastIndex; return reg; } if (target instanceof Map) { const result new Map(); map.set(target, result); target.forEach((value, key) { result.set(deepClone(key, map), deepClone(value, map)); }); return result; } if (target instanceof Set) { const result new Set(); map.set(target, result); target.forEach(value { result.add(deepClone(value, map)); }); return result; } // 处理普通对象和数组 const result Array.isArray(target) ? [] : {}; map.set(target, result); Reflect.ownKeys(target).forEach(key { // 只拷贝可枚举属性和Object.assign行为保持一致 if (Object.prototype.propertyIsEnumerable.call(target, key)) { result[key] deepClone(target[key], map); } }); return result; }几个关键设计点循环引用用WeakMap解决。WeakMap的key只能是对象正好用来记录“原对象 - 拷贝对象”的映射关系。每次递归前先检查map里有没有有就直接返回之前克隆的结果这样既能处理自引用也能处理两个对象互相引用的情况。Date、RegExp、Map、Set需要单独构造。这些类型用typeof判断都是object但它们内部有特殊机制不能靠遍历属性来克隆。比如Date的时间值存在内部的[[DateValue]]槽里不是普通属性。使用Reflect.ownKeys加propertyIsEnumerable的组合来遍历属性比for...in更干净。for...in会遍历原型链上的属性Reflect.ownKeys却只拿自有属性包括不可枚举的Symbol和字符串键。3.3 structuredClone现代浏览器原生深拷贝很多人不知道浏览器和Node.js17.0已经内置了一个深拷贝API叫做structuredClone。它直接解决了大部分手写克隆的边界问题。const clone structuredClone(original);这个API能处理Date、RegExp、Map、Set、ArrayBuffer、TypedArray等绝大多数内置类型也支持循环引用性能比手写的递归实现更好。它的实现基于结构化克隆算法是浏览器内部早就用于postMessage传递数据的那个机制现在开放给了普通开发者。不过structuredClone也不是万能的。它不支持拷贝函数、Symbol、DOM节点、Error对象对应平台的Error类也不能被结构化克隆也不支持原型链上的自定义类实例克隆出来的对象类型会退化为普通对象。我在项目里的经验是前端和后端环境都支持structuredClone的情况下默认优先用它需要兼容老版本浏览器时回退到Lodash的cloneDeep。3.4 Lodash cloneDeep成熟工程方案Lodash的cloneDeep是npm上使用最广泛的深拷贝方案原因是它把所有边界情况都处理得明明白白而且经过了大量项目验证。const _ require(lodash); const clone _.cloneDeep(original);它有完善的类型处理、循环引用处理、原型链处理还支持自定义clone函数。缺点就是需要引入Lodash整个库或者用按需引入的方式import cloneDeep from lodash/cloneDeep来避免打包体积膨胀。对比三个方案我有自己的取舍标准。数据简单、团队熟悉、没有特殊类型用JSON方案求快。数据复杂、需要稳定的边界处理用structuredClone或Lodash的cloneDeep。不能引入新依赖、又是面试场景就展示手写递归方案。4. 工程场景里的选择与性能克隆效率工具都有了接下来是真正的分水岭什么时候深拷贝什么时候浅拷贝深拷贝的性能到底怎么权衡。这个选择直接决定了你的项目是健壮还是脆弱的。4.1 数据流设计中的拷贝策略在实际项目中我总结了一套非常实用的选择规则。第一表单编辑场景必须深拷贝初始数据。用户点开一个编辑弹窗表单里填的是接口拉回来的原始数据。用户改了表单内容你肯定不希望原始数据跟着变否则取消编辑时没法还原、提交时也没法做脏值校验。所以进入表单之前把源数据深拷贝一份所有表单操作在副本上进行保存时才把脏数据提交出去。第二组件间传递对象props用浅拷贝就够。父组件把一个detail对象传给子组件子组件只读展示不修改完全没有深拷贝的必要。每次深拷贝一遍又影响性能又浪费内存。第三全局状态管理里的更新用浅拷贝配合展开。Redux或Vuex的更新逻辑重点是“不可变地替换更新的路径”也就是更新哪一层就拷贝哪一层到更新路径为止兄弟节点保持引用复用。这样既保证了数据变更可追踪又避免了无谓的深拷贝开销。第四历史快照、撤销回退必须深拷贝。实现撤销功能时每次操作前要把当前状态深拷贝一份存入历史栈否则所有历史记录指向同一份数据撤销就变成了自欺欺人。4.2 克隆效率的权衡大对象深拷贝的性能坑深拷贝是有成本的。一个包含几万条记录的大数组每条记录还有好几层嵌套深拷贝一次可能要消耗几十毫秒甚至更久。如果操作频繁页面直接卡顿。我在一个数据可视化项目里踩过这个坑仪表盘配置对象巨大每次配置变更都整体深拷贝一次操作拖拽控件时明显掉帧。后来优化思路很本质异步加载时一次性深拷贝全量配置运行时修改尽量用不可变的局部更新只有必须提交配置快照时才执行深拷贝。改完之后交互流畅度立刻上来了。另外要留意的是深拷贝的耗时跟对象规模和嵌套深度成正比不能用O(1)来看待。做性能提示时我建议记住几个数字小对象几十个字段深拷贝耗时在零点几毫秒级别中等对象几千个属性在几毫秒级别超大可序列化对象几万个属性或深层数组可能到几十毫秒。如果页面交互要求60fps帧预算只有16.6ms深拷贝耗时就绝对不能忽视。JSON.parse(JSON.stringify())在纯数据对象上通常是速度最快的方案因为它在底层做了大量优化。但如果对象里有函数或特殊类型它就没法用了。手写递归方案支持的类型更多但JavaScript的递归调用有开销性能一般比JSON方案差。克隆效率和克隆完整性永远是一对矛盾。要完整的类型支持就要牺牲部分性能。要极致性能就得限制数据形状。项目里没有免费的午餐关键是明确数据不可变边界哪里需要全量复制、哪里可以共享引用必须有清晰约定。4.3 顺带说一句硬盘克隆和代码克隆不是一回事每次聊到“克隆”这个词总有朋友把硬盘克隆和编程里的拷贝弄混。搜索引擎里的热搜词也经常出现类似“傲梅克隆硬盘后00000e”这种问题。这里简单个人一句磁盘克隆是把整个分区的数据按扇区复制到另一块硬盘所谓“克隆硬盘后00000e”多半是引导记录或分区表在克隆后和硬件不匹配导致的启动错误这和编程里的深浅拷贝完全是两个领域的问题。编程里的深浅拷贝核心在于理解内存引用关系而不是物理复制。学的时候把两个概念彻底分开思维才不会混乱。5. 常见问题与排查技巧实录这部分直接把平时项目里最常见的坑和排查思路整理出来。有些是我自己踩过无数次总结出来的也有的是帮同事排查线上问题时沉淀下来的实操针对性很强。5.1 五个高频深拷贝踩坑现场踩坑一用Object.assign做深拷贝。const state { user: { profile: { name: x } } }; const copy Object.assign({}, state); copy.user.profile.name y; // state.user.profile.name 也变成了 y这是最经典的误用。Object.assign只做浅拷贝它的典型应用场景是浅层的可枚举属性复制。踩坑二以为数组的map/filter返回的就是深拷贝。const users [{ name: a, tags: [x] }]; const result users.map(u ({ ...u, tags: [...u.tags] })); // 两层嵌套这种叫浅拷贝还是深拷贝 // 对tags数组来说已经拷贝了但如果tags里再套对象还是共享map和filter配合展开运算符可以做到两层拷贝但三层以上依然暴露。判断标准不是用了什么方法而是嵌套了多少层。踩坑三ES6的深拷贝面试题只答出JSON方案。ES6深拷贝这个热搜词背后的意思是面试官想知道你会不会用现代语法写出可靠方案。只答JSON.parse(JSON.stringify())就会暴露对特殊类型边界处理的无知。正确姿势是先分析数据类型再选方案或者直接展示手写递归加WeakMap的方案。踩坑四循环引用导致页面白屏。接口返回的树形数据里节点互相引用开发时用JSON方案深拷贝运行直接抛异常页面上报错信息一堆看不懂。以后遇到“明明没写错但运行就报错”的情况先怀疑一下是不是JSON.stringify碰到了循环引用。踩坑五深拷贝之后失去了原型方法。有一种常见需求拷贝的是一个自定义类的实例拷贝后希望新对象仍然能调用类的方法。JSON方案做不到手写递归也容易丢失原型。如果确实需要保留原型链应该用下面的思路要么在克隆时显式用new target.constructor要么直接使用Lodash的cloneDeep它默认会保留原型链。5.2 面试中的深浅拷贝破题思路深浅拷贝是前端面试高频题基本每个面试官都问。我总结过一套回答结构既能在有限时间里讲透原理又能体现工程经验。第一步从内存角度讲清楚原始类型和引用类型的差异。这步展示你懂底层实现。第二步顺带讲浅拷贝的三种方式展开运算符、Object.assign、手动遍历。强调“只拷贝第一层”。第三步重点讲深拷贝的方案演进JSON方案的便利和五个硬伤、structuredClone的原生优势和限制、手写递归的边界处理细节、Lodash cloneDeep的工程成熟度。第四步落到工程选型什么场景用浅拷贝、什么场景必须深拷贝、性能如何权衡。这四步走下来面试官基本能判断出你是真的理解而不是背了个手写深拷贝模板。5.3 线上bug排查快速判断是浅拷贝还是深拷贝的问题拿到一个“数据被意外修改”的Bug我有一套固定的排查流程。第一步重现问题确定是哪两个对象共享了数据。第二步分别打印两个对象的引用是否相同。控制台里直接用比较对象或者输出对象的constructor和内存地址相关信息。第三步确认共享的层级。如果第一层就相同说明连浅拷贝都没做直接赋值了如果第一层不同但嵌套层相同说明做了浅拷贝。第四步检查共享路径上操作的是什么属性。找到代码里所有写入该属性的位置重点看是否在修改一个“传进来的对象”。第五步复盘数据流确认这条数据从创建到修改经过了哪些函数。半数情况是某个函数内部无意中修改了入参对象的嵌套属性。这种问题的修复方式不是“多拷贝几次”而是要把数据流理顺谁负责产生数据、谁负责修改数据、谁只是读取数据写代码时必须画出明确边界。5.4 一个容易忽略的点Vue和React中的不可变更新最后补一个前端框架的注意点。Vue 3的reactive和React的state都是响应式数据它们要求“数据变更必须通过框架提供的API”而不是随手改一个对象的属性。如果你用浅拷贝生成了一个新对象修改了嵌套属性然后把新对象丢进state框架很多情况下能正确触发更新。但如果你直接修改了原对象的嵌套属性框架就不会发现Bug就出现了。这也是为什么React官方文档反复强调不可变更新的原因只有每次创建新的对象引用框架才能通过引用比较快速判断数据是否变化。明白这个设计之后深浅拷贝就不再是“复制数据”的问题而是“要不要生成新引用”的问题。生成新引用框架才能触发更新复用了深层引用框架就认为数据没变。这个视角对做React项目的人尤其重要。useMemo、useCallback的依赖比较本质就是比较引用是否变化。如果每次渲染都浅拷贝出一个新对象useMemo的依赖永远“变化”缓存就失效了。如果复用旧对象useMemo又认为没变就不重新计算。弄清楚这个逻辑深浅拷贝的选型才算真正落了地。6. 实际操作中的心得体会写到现在我把深浅拷贝的底层原理、工具方案、工程选型、问题排查都过了一遍。最后再掏几个实际干活时总结的小经验。第一在代码里给拷贝函数写一句注释标明“这是深拷贝不要随意改成浅拷贝”。别笑真的有用。团队协作时其他人不看上下文就“优化”掉一个看起来多余的展开运算符是最常见的隐患来源。注释是对未来同事的友善。第二把深拷贝收敛到一个公共工具模块里。全项目不要散落各种JSON.parse(JSON.stringify())调用统一封装成cloneData工具函数内部实现可以随时替换升级而不会影响上层的调用方。工具函数里还要加类型校验和错误捕获至少保证异常时有日志可查。第三表单场景的初始值深拷贝做完之后最好写一个断言级测试。深拷贝的函数在项目里承担着大量隐蔽责任测试的价值远远大于那些一百年不变的工具函数。第四后端返回的数据如果已经是基本类型组成的纯JSON结构直接用JSON方案反而比structuredClone更快因为JSON API是浏览器深层优化的赛道。这个结论我是用performance.now()实测算过多次的。具体数值会随环境变化但量级关系基本稳定。深浅拷贝这个概念你在网上能找到几百篇教程但真正拉开水平的是对内存模型的理解、对边界情况的掌握、以及对工程选型的判断力。这几点都打通之后写代码时就不会再纠结“这里要不要拷贝一下”那个判断会变成一种条件反射。我个人目前最常用的组合是结构化克隆优先Lodash cloneDeep兜底JSON方案只用于纯数据快照。这个组合在我的项目里跑了很久几乎没有出过拷贝相关的线上问题。你也不妨从今天开始把项目里的拷贝逻辑盘一遍该封装的封装该修的修掉。等这一轮整理完你大概会和我一样感叹这么多年的坑原来全是被一个连“引用”和“值”都没搞明白的简写坑出来的。