☰
JavaScript深拷贝与浅拷贝:原理、应用场景与常见坑
2026/10/1 9:43:19 网站建设 项目流程

写 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.city,oldObj.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 会被转成 null,Symbol 键整个消失。

第三,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 一行就搞定了,代码干净不少。

但要注意它也不是万能的。它无法拷贝函数,遇到函数会抛 DataCloneError;Symbol 作为键也会被忽略;原型链上的自定义方法不会保留;老的浏览器和旧版 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 的半天时间。

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

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

立即咨询