JavaScript深拷贝与浅拷贝:原理、应用场景与常见坑
发布时间:2026/10/1 9:43:21来源:尧图网络
写 JavaScript 这几年要说被问得最多的基础题浅拷贝和深拷贝的区别绝对能排进前三。不管你是做前端、写 Node 后端还是搞小程序只要代码里出现对象、数组之间互相赋值迟早会撞上这个问题。简单说浅拷贝只复制了最外层深拷贝要复制到底但真正要命的是——很多人在项目里用错了拷贝方式改了半天数据原对象莫名其妙被改了查了几小时才发现是浅拷贝惹的祸。这篇文章我打算把浅拷贝和深拷贝从头到尾捋一遍它们到底差在哪、每种写法的优缺点和坑、实际项目里该怎么选以及一份可以拿去直接用的深拷贝工具函数。不管你是刚入行还是写了几年这篇应该都能让你有点收获。1. 先搞懂拷贝问题到底出在哪要理解浅拷贝和深拷贝得先弄明白 JavaScript 里变量到底是怎么存数据的。很多人写了好几年代码遇到“引用类型”“堆栈”这些词还是发怵其实没那么复杂我用大白话讲一遍。1.1 变量里存的是值还是地址JavaScript 的数据类型可以分成两大类基本类型和引用类型。基本类型包括 number、string、boolean、null、undefined、symbol、bigint这类数据存在栈里变量存的就是实实在在的值。你写let a 10; let b a;b 拿到的是 10 这个值的副本之后改 b 永远不会影响 a两者彻底独立。引用类型就不一样了对象、数组、函数、Map、Set 这类数据真正的数据存在堆里变量里存的只是一个地址——你可以理解成一张写着门牌号的纸条。当你写let obj2 obj1的时候你并没有把房子复制一份只是把纸条上的门牌号抄了一遍。所以 obj2 和 obj1 指向的是同一栋房子改 obj2 的某个属性obj1 对应的属性跟着变这就是“引用赋值”。清楚了这个之后再回头看拷贝就简单了拷贝的本质是想让新对象拥有独立的数据不再受原对象变化的影响。只是不同拷贝方式独立的程度不一样于是分出了浅拷贝和深拷贝。1.2 引用赋值、浅拷贝、深拷贝三者有什么区别面试时经常有人把“引用赋值”和“浅拷贝”混为一谈其实它们是三件事。引用赋值let b a新变量只是原变量地址的一份复印件两个变量指向同一块内存。浅拷贝新对象是创建出来了但它内部的属性如果还是引用类型那这些属性仍然指向原对象的内存没有完全独立。深拷贝新对象和原对象在内存里完全没关系了所有层级的属性都是新创建的改任何一个都不会影响另一个。可以用一个生活化的例子理解有一栋两层小楼你想在隔壁建一栋一模一样的。引用赋值相当于贴了个告示“隔壁就是复制品”其实还是同一栋楼。浅拷贝是只把第一层重新盖了一遍第二层还是共用的。深拷贝是把整栋楼每一块砖都重新垒一遍两层楼完全独立。在真实项目里你得想清楚到底需要多深程度的复制。如果只改最外层属性浅拷贝可能就够用如果改的是深层嵌套数据浅拷贝就会出问题必须上深拷贝。2. 浅拷贝表面上是新对象内部仍藕断丝连浅拷贝的实现方式不少ES6 之后尤其方便但很多人在用的时候压根没意识到它的边界在哪里。我先把常用写法列一遍再带你实际验证它到底“浅”在什么地方。2.1 常用的浅拷贝写法盘点JavaScript 里常见的浅拷贝方法有下面这些// 写法一Object.assign let newObj Object.assign({}, oldObj); // 写法二展开运算符 let newObj { ...oldObj };这两种都是对象浅拷贝的经典写法。Object.assign 是 ES5 时代就有的展开运算符是 ES6 提供的语法糖用起来更简洁两者效果几乎一样唯一的区别是展开运算符只能用在字面量场景Object.assign 还能用来给已有对象追加属性。数组也有对应的浅拷贝写法let newArr oldArr.slice(); let newArr2 oldArr.concat(); let newArr3 [...oldArr];slice、concat、展开运算符都会返回一个新数组但新数组里的元素如果是引用类型那这些元素指向的仍然是原数组里的对象。也就是说数组浅拷贝只把最外层那个“装元素”的壳子换掉了里面的东西还是共用的。还有几个高频场景容易混淆Array.from(oldArr)也是浅拷贝。解构赋值let { a, b } obj相当于把 obj 的属性值取出来本质还是浅拷贝因为 a、b 如果是引用类型拿到的还是原引用。另外Object.create(Object.getPrototypeOf(obj))这类写法一般在原型链场景才用日常业务里不推荐为了拷贝去折腾什么自带原型的复制容易把自己绕晕。2.2 浅拷贝到底“浅”在哪动手验证一次别光看理论我建议你在浏览器控制台里跑一遍下面的代码感受会直观很多const oldObj { name: 张三, address: { city: 北京, district: 海淀区 } }; const newObj { ...oldObj }; newObj.name 李四; console.log(oldObj.name); // 张三第一层属性独立了 newObj.address.city 上海; console.log(oldObj.address.city); // 上海第二层还是被改了看到没有改了 newObj.address.cityoldObj.address.city 也跟着变成了“上海”。原因就是...oldObj复制address属性时只复制了地址引用并没有把{ city: 北京 }这个对象重新创建一份。记住一句话浅拷贝只复制了一层属性遇到嵌套的引用类型就无能为力了。这也是浅拷贝最核心的特性也是它最大的局限性。2.3 浅拷贝的适用场景浅拷贝是不是就一定不好当然不是。事实上在很多业务场景下浅拷贝够用而且性能比深拷贝好太多。最常见的场景就是 React 里的不可变数据更新。比如你有一个对象列表要改某一条数据的某个字段常规做法是浅拷贝出一个新数组再替换对应项保证 state 引用变化触发视图更新const [list, setList] useState([ { id: 1, name: 苹果 }, { id: 2, name: 香蕉 } ]); function updateName(id, newName) { setList(prev prev.map(item item.id id ? { ...item, name: newName } : item ) ); }这里{ ...item, name: newName }就是典型的浅拷贝用法。因为我们只是要替换最外层的 item 对象引用让 React 感知到变化不需要把 item 内部所有属性全部深拷贝一遍。如果这时候你用深拷贝反而多做了很多无用功性能还更差。再比如 Vue 3 里做响应式数据更新或者用 Redux、Pinia 管理状态时都需要保证返回的新对象引用发生变化。浅拷贝正好满足需求又不会因为过度复制导致性能浪费。3. 深拷贝要把整栋楼重新盖一遍深拷贝的思路和浅拷贝完全不同。浅拷贝只解决最外层深拷贝要求对象里所有层级的属性都重新创建一遍原对象和拷贝之后的对象在内存里彻底分家。3.1 深拷贝的核心思路递归既然嵌套对象可能有多层那最直白的思路就是递归遇到对象就遍历它遇到引用类型的属性继续往里走遇到基本类型就直接赋值。原理听起来很简单但真正写起来才发现坑不少。先从一个最简单的版本开始function deepClone(source) { if (Array.isArray(source)) { return source.map(item deepClone(item)); } if (source ! null typeof source object) { const result {}; for (let key in source) { if (source.hasOwnProperty(key)) { result[key] deepClone(source[key]); } } return result; } return source; }这个版本能处理普通的对象和数组数组的map会返回新数组对象的for...in配合hasOwnProperty只复制自身属性。功能很简单但已经能应对不少常规业务了。后面我们会在它基础上逐步加强处理 Date、RegExp、Map、Set、循环引用等特殊情况。3.2 最省事的 JSON 方案以及它埋下的雷日常开发里很多人遇到深拷贝需求第一个想到的就是 JSON 的三板斧const newObj JSON.parse(JSON.stringify(oldObj));这行代码能应付大部分普通对象而且写法确实优雅在业务代码里也经常看到。但如果你只靠它迟早会被坑。它有几个硬伤我逐个说。第一函数会被丢掉。对象的属性如果是一个函数JSON.stringify序列化时会直接忽略它拷贝出来的对象没有这个属性。第二undefined和Symbol类型的属性会被丢弃。不只是直接作为属性值的 undefined数组里的 undefined 会被转成 nullSymbol 键整个消失。第三Date 对象会变成字符串。JSON.stringify会把 Date 序列化成 ISO 格式的字符串拷贝出来就不再是 Date 类型的对象了后续你想调用 getTime 之类的方法都会报错。第四RegExp、Map、Set 这类对象会被序列化成普通对象或者空对象丢失原本的内部结构和原型方法。第五循环引用会直接报错。比如对象上有个属性指回自己JSON.stringify会抛出Converting circular structure to JSON整个程序就崩了。所以 JSON 方案只适合那些结构简单、属性全是基本类型、数组或者普通对象的场景。一旦你的数据里混入了函数、Date、Map、Set或者有循环引用的可能就得换更靠谱的方案。3.3 现代浏览器原生方案structuredClone如果你不需要兼容很老的浏览器其实有一个更好的原生方案——structuredClone。它是浏览器和 Node.js 17 之后都支持的全局函数专门用来做深拷贝const newObj structuredClone(oldObj);这个函数能正确处理大多数常见类型包括 Date、RegExp、Map、Set、ArrayBuffer 等还能处理循环引用。我实测下来很多以前需要手写递归处理的场景用 structuredClone 一行就搞定了代码干净不少。但要注意它也不是万能的。它无法拷贝函数遇到函数会抛 DataCloneErrorSymbol 作为键也会被忽略原型链上的自定义方法不会保留老的浏览器和旧版 Node 环境不支持。还有一点它拷贝出来的对象是普通对象继承的原型方法可能会丢失这点在特定场景下要注意。所以在现代项目里我的建议是能直接用 structuredClone 就用它别自己重复造轮子。除非你要兼容基本不维护的老项目或者需要处理函数、自定义原型这类 structuredClone 搞不定的东西那再考虑手写。3.4 手写一个健壮的深拷贝函数尽管有 structuredClone 了面试里依然爱考手写深拷贝而且真实项目里偶尔也需要针对特殊类型定制拷贝逻辑。我写了一个兼顾常见特殊类型和循环引用的版本直接可以拿去做工具的起点function deepClone(source, map new WeakMap()) { // 基本类型直接返回 if (source null || typeof source ! object) { return source; } // 处理循环引用如果已经拷贝过直接返回之前的拷贝 if (map.has(source)) { return map.get(source); } // 处理 Date、RegExp if (source instanceof Date) { return new Date(source.getTime()); } if (source instanceof RegExp) { const newRegExp new RegExp(source.source, source.flags); newRegExp.lastIndex source.lastIndex; return newRegExp; } // 处理 Map if (source instanceof Map) { const result new Map(); map.set(source, result); source.forEach((value, key) { result.set(deepClone(key, map), deepClone(value, map)); }); return result; } // 处理 Set if (source instanceof Set) { const result new Set(); map.set(source, result); source.forEach(value { result.add(deepClone(value, map)); }); return result; } // 处理数组 if (Array.isArray(source)) { const result []; map.set(source, result); for (let i 0; i source.length; i) { result[i] deepClone(source[i], map); } return result; } // 处理普通对象 const result {}; map.set(source, result); Reflect.ownKeys(source).forEach(key { result[key] deepClone(source[key], map); }); return result; }这个版本有几个关键点我展开说说。首先用 WeakMap 来记录“原对象 - 拷贝后对象”的对应关系好处是 WeakMap 的键是弱引用不会阻止垃圾回收而且它天然支持处理循环引用。当遇到某个对象已经被拷贝过时直接返回 map 里存的那份就能避免无限递归导致栈溢出。其次我用instanceof Date和instanceof RegExp之类的判断来保留特殊类型。Date 用getTime()创建新实例RegExp 用 source 和 flags 重建再保留 lastIndex 避免正则的 lastIndex 状态丢失。然后Map 和 Set 要单独处理Map 的 key 和 value 都要深拷贝因为 key 也有可能是引用类型。Set 则要保证每个元素都独立。数组和对象分开处理是因为数组需要用下标遍历对象用Reflect.ownKeys能拿到包括 Symbol 在内的所有自有键这比Object.keys更完整。为什么不用Object.create(Object.getPrototypeOf(source))来保留原型如果你希望拷贝后的对象还能沿用原对象的原型方法可以在最后加一步但那样也要把原型链上的数据拷贝考虑进去逻辑会更复杂。日常业务数据基本都是普通对象JSON 解析出来的、后端接口返回的都是这种所以默认返回{}问题不大。你要真遇到特殊类实例建议还是写针对性的拷贝逻辑别指望一个通用函数搞定所有东西。4. 实战选择深拷贝和浅拷贝该怎么取舍现在你知道了两者的原理和实现但落到项目里最难的不是写出来而是判断什么时候该用哪个。我结合实际业务场景说说我的选择逻辑。4.1 业务场景对照表我整理了一张对照表你可以直接按图索骥场景推荐方式原因修改 state需要触发视图更新浅拷贝只更新最外层引用即可性能更好表单数据回显与编辑深拷贝避免编辑过程污染原始初始值路由 query 参数传递对象深拷贝防止下游修改影响上游数据列表数据筛选排序浅拷贝 局部替换通常只需要新数组引用元素可复用初始化配置对象后续要合并覆盖深拷贝防止默认配置被误改篡改图表配置、组件 props 赋值深拷贝或浅拷贝均可如果外层类名等属性需独立浅拷贝即可有深层嵌套则深拷贝接口提交前组装复杂参数深拷贝避免组装过程影响原始数据举个例子表格编辑功能非常典型。页面上有很多行数据用户点编辑会弹出一个弹窗弹窗里有一份表单用户改来改去。如果你把原数据直接赋值给表单数据用户改一个字段原数据立刻跟着变如果点了取消原数据已经回不去了这个 bug 排查起来特别恼火。正确的做法是编辑打开时先深拷贝一份原数据给表单用户改的是副本最终点确定再回写取消就直接丢弃原始数据永远是干净的。再比如 React 里常见的性能优化会比较 prevProps 和 nextProps 是否变化如果你的数据层级很深父组件更新时传给子组件的对象总是新引用子组件 memo 优化就会失效导致不必要的渲染。这种时候要根据业务判断到底是数据真的变了再更新还是浅拷贝已经足够。4.2 深拷贝的性能与成本深拷贝听起来很万能但它是有代价的。一份大对象深拷贝可能需要遍历每一层属性逐层创建新对象性能开销比浅拷贝高很多。尤其是数组里套对象、对象里套数组、层级深数据量大的场景深拷贝会消耗不少内存和时间。如果你是在高频触发的函数里做深拷贝比如渲染循环、滚动事件、实时计算性能影响会很明显。这种场景下应该尽量做结构优化减少嵌套层级或者用浅拷贝 局部不可变更新的方式代替整体深拷贝。说白了拷贝的“深度”要按需来能浅就别深不能浅的时候再上深拷贝。另一个容易被忽略的成本是深拷贝会切断所有的引用关系。有些业务场景本身是依赖对象共享的比如一个配置对象被多个模块引用模块 A 改了某个值模块 B 希望能感知到。如果你用了深拷贝两边变成两份独立数据后面改起来反而更麻烦。所以在做决策之前先想清楚数据流是不是真的需要完全独立。4.3 第三方库方案lodash.cloneDeep 还值不值得用前端圈子里最著名的深拷贝工具当属 lodash 的_.cloneDeep。它经历了大量真实项目打磨对各种类型和边界情况的处理都相当完善循环引用、Date、RegExp、Map、Set、ArrayBuffer、TypedArray 都能处理比很多手写版本都靠谱。以前我遇到复杂深拷贝需求时第一反应就是import { cloneDeep } from lodash-es。带es的版本配合 tree-shaking 只打包用到的函数体积可控。不过现在有 structuredClone 之后我个人的习惯是普通业务优先 structuredClone确实需要兼容老环境或者要处理更复杂的自定义类型时才用 lodash 的 cloneDeep。如果不想引入 lodash也有其他选择比如 immer 这类不可变数据工具它用的是 Proxy 代理的方式写起来又是另外一套思路适合需要大量不可变更新的场景这里就不展开讲了。不管用哪种方案建议都封装成一个独立的工具函数用的时候别到处散写原生代码。以后要换底层实现只需要改工具函数内部外部业务逻辑一行都不用动。这是一种很实用的工程习惯。5. 常见的坑和排查技巧实录这个部分我想专门记录一下自己踩过的坑以及平时帮别人排查问题时的高频场景。你会发现很多“bug”到最后都是拷贝方式没用对。5.1 改了子对象父对象跟着变这是浅拷贝最经典的坑。症状是我明明已经用...复制了一个对象改了新对象的嵌套属性原对象也变了是不是哪里出 bug 了排查思路很简单先看改的是哪一层。如果改的是第一层属性浅拷贝已经隔离了如果改的是嵌套属性浅拷贝并没有隔离。确认之后再看这个场景到底需要浅拷贝还是深拷贝。比如列表项里的某条数据改了子字段通常你需要做的是先浅拷贝出新的列表项对象再把子属性也手动替换成新对象而不是直接改item.child.name。有一种更好记的说法如果你在一个对象里做了“两层以上的修改”数据流里的每一步都要保持不可变别只在最外层做拷贝。5.2 JSON 方案丢函数、丢 undefined、Date 变字符串有些同事接手老代码看到JSON.parse(JSON.stringify(obj))这个写法就觉得它是万能深拷贝结果遇到含函数的配置对象拷贝完配置里埋的埋点函数没了刷新一下功能就失效。遇到这种问题第一件事就是把对象类型打印出来看看比如console.log(newObj.createTime)如果之前是 Date 对象拷完变成字符串那基本都是 JSON 方案导致的。解决办法也分两个方向。如果数据结构里这些特殊类型是必要的换成 structuredClone 或者手写深拷贝如果只是临时用一下不关心函数和类型保留JSON 方案可以继续用但要明确它的边界别让它出现在关键业务数据上。5.3 循环引用导致的栈溢出当你手写递归深拷贝的时候遇到循环引用最典型的报错就是Maximum call stack size exceeded或者 JSON 方案直接报Converting circular structure to JSON。比如一个树形结构的节点parent指向父节点children指向子节点父子之间天然就是循环引用。用递归深拷贝时如果没做缓存处理你会一直递归下去直到栈溢出。解决思路就是前面深拷贝函数里的 WeakMap 方案。看到 deepClone 里有 map 参数就是干这个用的。如果你用的是 structuredClone它内部已经处理了循环引用不用你操心。5.4 数组深拷贝容易漏掉的细节数组浅拷贝和深拷贝的边界很多人会搞混。arr.slice()、arr.concat()、[...arr]都只是浅拷贝数组里面的对象改了原数组同样会变。如果你确实需要对数组做深拷贝最简单的办法是const newArr JSON.parse(JSON.stringify(oldArr));但这个同样有限制遇到函数、undefined、Symbol 一样会丢。更稳妥的做法是使用前面写的 deepClone它会自动处理数组情况。 还有一个思路是oldArr.map(item ({ ...item }))这属于“一层浅拷贝 内部手动浅拷贝”的组合只对固定层级的数据有效层级不固定时还是要靠深拷贝。5.5 structuredClone 用不了的场景我在实际项目里遇到过 structuredClone 报DataCloneError的情况一般是三个原因拷贝了函数、拷贝了 Symbol 作为值、或者拷贝了某些自定义类实例和 DOM 节点。遇到这种需求如果你不能接受损失就只能手写递归或者用 lodash 的 cloneDeep 做定制处理。另外还有个容易踩的点是老环境不支持 structuredClone。Node 14 之前没有部分 WebView 和低版本浏览器也没有。上线前最好加一行typeof structuredClone function做能力检测不支持的再走 fallback别直接一把梭。还有structuredClone 拷贝出来的对象不会保留原型链上的自定义方法。如果你把一个类实例拷过去拿到的只是普通对象类的方法全没了。这种场景下要么自己设计一个toJSON和反序列化逻辑要么不要依赖通用深拷贝手动重建实例更靠谱。最后分享点实际经验说了这么多落到项目里我个人的选择习惯是这样的普通的配置、表单、列表数据如果确定结构简单JSON 方案偶尔用一下可以但只限内部模块不允许出现在关键业务链路上稍微复杂一点的数据优先用 structuredClone一行搞定性能也不错结构化很深、类型很多还涉及循环引用的直接用封装好的工具函数或者 lodash 的 cloneDeep。写代码千万别为了炫技去手写一个看起来很牛但维护成本很高的深拷贝。还有一个小技巧拷贝之后验证两件事一个是改深层属性看原对象是否受影响另一个是打印一下关键字段的类型确认 Date、RegExp、Map 之类的类型是否保留。这两个检查各花不到一分钟却能帮你省下排查 bug 的半天时间。
网站建设高端定制企业官网