这个话题几乎是前端面试的“钉子户”,也是日常开发里最容易埋雷的地方。我见过不少同学写代码时碰到的诡异 bug——明明只改了 A 对象里的一个字段,结果 B 对象跟着变了;或者一个配置对象被多个模块共用,某天某处往里面塞了个属性,整个页面行为全乱了。排查到最后,根源往往就一句话:当初拷贝的时候,用的是浅拷贝,你拿到的根本不是一份独立的数据。
这篇文章我想把自己对浅拷贝与深拷贝的理解完整梳理一遍,包括它们到底在内存层面做了什么、各有什么边界限制、手写一个稳妥的深拷贝需要小心哪些细节,以及实战中怎么快速判断该用哪一种。不管你是刚入门的新人,还是被这类 bug 折磨过的老手,这篇文章应该都能帮上忙。
1. 先弄清楚:你操作的是数据本体还是引用
想搞懂深浅拷贝,前提是先搞清楚 JavaScript 里数据是怎么存的。很多解释文章一上来就列方法、贴代码,但根本没有讲透底层模型,导致读者只会背结论,换个场景就判断不了了。
1.1 值类型与引用类型:一切差异的源头
JavaScript 的数据类型可以分为两大阵营。
第一类是基本类型(primitive type),包括string、number、boolean、null、undefined、symbol、bigint。这类数据的特点是:变量直接保存值本身,赋值的时候是把值复制一份交给新变量。
let a = 10; let b = a; b = 20; console.log(a); // 10 console.log(b); // 20这里b拿到的是a的副本,两者从此各过各的,谁改都不影响对方。
第二类是引用类型(reference type),包括普通对象、数组、函数、日期、正则等等。这类数据的特点是:变量里存的不是数据本身,而是数据在内存中的“地址”。赋值的时候,复制的是地址,而不是地址指向的那块数据。
const objA = { name: '张三' }; const objB = objA; objB.name = '李四'; console.log(objA.name); // '李四' console.log(objB.name); // '李四'看到没?objA和objB指向的是同一个对象,改任何一个,另一个也会跟着变。这就是为什么扯到拷贝之前,必须先建立这个认知:你在操作引用对象时,绝大多数情况下操作的是同一个东西的两把“钥匙”。
1.2 内存里的模型:栈与堆的一次比喻
为了把上面说的“地址”变得更好理解,可以把内存粗略分成两个区域:栈(stack)和堆(heap)。
栈的存取速度很快,适合保存体积较小、大小固定的数据,比如基本类型。当你写let a = 10时,栈里直接存了10这个值。
引用类型的值通常比较大,或者大小不固定,所以实际数据放在堆里。栈里存的只是一个指向堆的内存地址。当你写const objA = { name: '张三' }时,堆里有一块区域存着{ name: '张三' },栈里存的则是这块区域的地址编号。
于是赋值操作就分成了两种:
- 基本类型赋值:栈里把值复制一份,两个变量互不相干。
- 引用类型赋值:栈里把地址复制一份,两个变量指向同一个堆内存数据。
你可能会问:那拷贝一个对象,不就是把堆里的数据也复制一份吗?这正是深浅拷贝要解决的。浅拷贝只复制了栈里的地址,或者只复制了对象第一层的基本数据;深拷贝则是把堆里层层嵌套的数据都复制一遍,让新旧对象在内存层面完全独立。
1.3 判断“真拷贝”的唯一标准
很多教程里写“展开运算符实现的是浅拷贝”,读者可能不理解:明明{ ...obj }看起来像复制了一份新对象啊?
判断标准非常简单:你得到的新对象,和旧对象在内存里是不是完全独立。具体验证方式也很粗暴——修改新对象里某个值,然后看旧对象有没有被影响到。
const original = { name: '张三', address: { city: '杭州' } }; const copy = { ...original }; copy.name = '李四'; console.log(original.name); // '张三',第一层字符串没受影响 copy.address.city = '上海'; console.log(original.address.city); // '上海',嵌套对象被改了!第一层name是基本类型,展开操作符直接把值复制了一份,所以改copy.name不影响原对象。但address是引用类型,展开操作符复制的是address的地址,所以copy.address和original.address依然指向同一个嵌套对象。
这个例子基本揭示了浅拷贝的本质:它能保证最外层是新的,但不能保证嵌套层独立。
2. 浅拷贝到底拷贝了什么
理解深浅之分后,先别急着追求深拷贝。浅拷贝在不少场景下是明智且高效的选择,关键在于你要知道它在边界上止步于哪里。
2.1 常见的浅拷贝写法
日常开发里,浅拷贝主要靠几种手段完成:
第一种是展开运算符。这是 ES6 之后最常用的写法,简洁直观,对象和数组都适用。
const obj = { a: 1, b: { c: 2 } }; const clone = { ...obj }; const arr = [1, 2, { x: 3 }]; const arrClone = [...arr];第二种是Object.assign。它的行为是:把源对象的所有可枚举属性复制到目标对象上,然后返回目标对象。看起来也是“复制一份”,但它的复制逻辑同样是第一层。
const target = {}; const source = { a: 1, b: { c: 2 } }; const result = Object.assign(target, source);第三种是数组相关的原生方法。Array.prototype.slice和Array.prototype.concat都不修改原数组,而是返回一个新数组。很多老教程把它们当作“数组拷贝神器”,但它们内部依然是浅拷贝逻辑。
const arr = [1, 2, { a: 3 }]; const sliced = arr.slice(); sliced[2].a = 999; console.log(arr[2].a); // 999,嵌套对象还是共享的第四种是手写一个循环遍历。本质上就是把对象的第一层属性一个个赋值到新对象里。虽然代码是自己写的,但只要没处理嵌套对象,它依然是浅拷贝。
function shallowClone(obj) { const result = {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { result[key] = obj[key]; } } return result; }2.2 第一层独立,深层仍然共享
理解浅拷贝的核心,是记住这样一句话:浅拷贝只复制第一层的值,如果这一层的值是基本类型,那么新旧对象不复用;如果这一层的值是引用类型,那么新旧对象共享同一个嵌套数据。
用一个具体业务场景来演示。
假设你有一个用户信息对象,结构是这样的:
const user = { id: 1001, name: '小明', profile: { age: 18, tags: ['前端', '篮球'], }, };这时你为了在页面上做一个“编辑用户资料”的功能,先浅拷贝一份user作为草稿:
const draft = { ...user }; draft.name = '小红'; draft.profile.age = 20;你发现user.profile.age也变成了 20。因为draft.profile和user.profile是同一个对象。你本想改草稿,结果把真实数据也改了。如果这个时候发请求保存草稿,后端就会收到被污染的数据。
深层共享的问题在数组里尤其隐蔽。比如二维数组或对象数组,slice()之后你以为安全了,结果修改arr[0][0]会牵动原数组。这类问题在复杂表单、异构数据整理、多级配置合并中非常容易踩中。
2.3 浅拷贝并非鸡肋,这些场景它很合适
浅拷贝不是“不行”,而是“浅”。有些场景下,浅拷贝不仅是合理的,甚至是性能最优解。
第一个典型场景是 React 或 Vue 里的不可变更新。React 强调状态不可变,更新状态时要返回一个新对象。但状态往往很深,如果深拷贝整个状态树,不仅慢,而且会丢失“引用变化”的优化意义。正确的做法是只浅拷贝需要修改的那几层对象,保持其他层引用不变。比如修改state.user.profile.age,应该这样做:
setState({ ...state, user: { ...state.user, profile: { ...state.profile, age: 20, }, }, });这样 React 可以用引用比较快速判断哪一层变了,从而精准触发渲染。
第二个场景是配置合并。项目里经常会有默认配置和用户配置合并的需求,比如组件参数、请求设置。通常只需要合并最外层的几项配置,内层由具体逻辑自行决定是否覆盖。这种场景用{ ...defaults, ...custom }完全可以,因为不需要把每个嵌套对象都解耦。
第三个场景是对性能敏感的大数据。深拷贝的开销随数据规模和嵌套深度线性增长,如果数据非常大且只修改最外层,使用浅拷贝可以省下大量时间和内存。有些时候“共享引用”反而是有意的设计。
所以说,浅拷贝不是“错误”,而是一种有边界的工具。边界内它是利器,边界外它就是 bug 的温床。这也是为什么你必须清楚地知道它的边界到底在哪里。
3. 深拷贝的几种靠谱实现
当你确定需要一份完全独立的数据时,就要上深拷贝了。深拷贝的目标很明确:把原对象在堆内存里的数据完整复制一份,包括所有嵌套的对象和数组,新旧对象之间再没有任何引用关系。
3.1 JSON 方法:快,但坑也多
最简单粗暴的深拷贝方案是JSON.parse(JSON.stringify(obj))。它先把对象序列化成 JSON 字符串,再把这个字符串解析成全新的对象。两步行云流水,代码只有一行。
const deepCopy = JSON.parse(JSON.stringify(original));这个方案对付普通的数据结构,比如纯对象、数组、字符串、数字嵌套,确实方便快捷。但它有一长串限制,我在实际开发中踩过的坑包括:
undefined、函数、symbol类型的属性会被整个丢弃,不会保留在拷贝结果中。Date对象会被转换成字符串,而不是原来的Date对象。RegExp、Map、Set、WeakMap、WeakSet等特殊对象会被序列化成空对象。NaN和Infinity会被转换成null。- 存在循环引用时会直接抛出错误:
TypeError: Converting circular structure to JSON。
看一个实际例子:
const original = { name: '小明', sayHi: () => console.log('hi'), date: new Date(), map: new Map([['key', 'value']]), nan: NaN, }; const copy = JSON.parse(JSON.stringify(original)); console.log(copy); // { // name: '小明', // date: '2024-01-01T00:00:00.000Z', // map: {}, // nan: null, // // sayHi 直接消失了 // }如果你的数据里只是普通业务字段,JSON 方案能用。但如果数据里含有特殊类型、循环引用或者函数,它就会静默地改变甚至丢失数据。它是“能用但不完全可靠”的典型代表。
3.2 手写递归深拷贝,逐步完善
想拥有一个符合自己业务需求的深拷贝,手写递归是绕不开的练习。不光是面试要考,理解这个递归过程后,你对 JavaScript 的对象模型会有一层更深的认知。
先写一个基础版本,只处理普通对象和数组:
function deepClone(source) { if (source === null || typeof source !== 'object') { return source; } if (Array.isArray(source)) { return source.map((item) => deepClone(item)); } const result = {}; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { result[key] = deepClone(source[key]); } } return result; }这个版本已经能处理大多数“对象套数组、数组套对象”的结构了。但很快你就会发现新问题——循环引用。看这个结构:
const obj = {}; obj.self = obj;如果你用上面的函数去拷贝obj,会陷入无限递归,直到栈溢出。解决办法并不复杂,用一个额外的容器记录“已经拷贝过的对象”,当递归再次遇到同一个对象时,直接返回之前拷贝的结果。这个容器用WeakMap最合适,因为它的键是弱引用,不会造成内存泄漏。
function deepClone(source, map = new WeakMap()) { if (source === null || typeof source !== 'object') { return source; } if (map.has(source)) { return map.get(source); } if (Array.isArray(source)) { const result = []; map.set(source, result); source.forEach((item) => result.push(deepClone(item, map))); return result; } const result = {}; map.set(source, result); for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { result[key] = deepClone(source[key], map); } } return result; }到这里,一个能处理循环引用、嵌套数组/对象的基础深拷贝就成型了。
但还远远没到“完善”的程度。如果要支持Date、RegExp、Map、Set,以及保留原型链,还需要写很多分支。比如:
function deepClone(source, map = new WeakMap()) { if (source === null || typeof source !== 'object') { return source; } if (map.has(source)) { return map.get(source); } if (source instanceof Date) { return new Date(source.getTime()); } if (source instanceof RegExp) { return new RegExp(source.source, source.flags); } 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; } 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); source.forEach((item) => result.push(deepClone(item, map))); return result; } const result = {}; map.set(source, result); for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { result[key] = deepClone(source[key], map); } } return result; }写到这里你应该明白一件事:手写一个“完全计划周全”的深拷贝,工程量不小。平时如果时间紧,我更推荐用成熟的开源方案,后面会讲到。
3.3 成熟方案:内置 API 与 lodash 怎么选
如果项目允许引入库,lodash.cloneDeep是我用得最多的方案。它对各种内置类型、原型链、循环引用都有成熟处理,源码久经考验。不需要自己维护那些边边角角的分支逻辑。
import { cloneDeep } from 'lodash'; const copy = cloneDeep(original);如果你不想要 lodash 整个库的体积,可以单独安装lodash.clonedeep这个包,只引入一个函数,构建体积影响极小。
除了第三方库,现代浏览器和 Node.js 17+ 环境下还有一个原生 API 值得关注:structuredClone。它是真正意义上由运行环境提供支持的深拷贝,能够处理Date、RegExp、Map、Set、ArrayBuffer、甚至File、Blob等复杂类型,并且支持循环引用。
const copy = structuredClone(original);这行代码的干净程度,跟JSON.parse(JSON.stringify(obj))不相上下,但能力强了不是一个量级。我个人的建议是:如果项目运行环境支持structuredClone,可以直接用它作为默认深拷贝方案,省心省力。
不同方案的对比,整理成一张表格更直观:
| 方案 | 是否能处理嵌套对象 | 是否能处理循环引用 | 是否能保留Date/Map/Set/函数等 | 性能 | 适用场景 |
|---|---|---|---|---|---|
| JSON.parse(JSON.stringify()) | 是 | 否,直接报错 | 否,会丢失或变形 | 快 | 纯数据业务对象 |
| 手写递归 | 是 | 需借助 WeakMap | 需自行扩展分支 | 取决于实现 | 面试或定制需求 |
| lodash.cloneDeep | 是 | 是 | 大部分类型可以 | 中等偏上 | 生产环境通用 |
| structuredClone | 是 | 是 | 支持大多数内置类型 | 快 | 现代运行环境 |
4. 实战中的那些坑和排查思路
即使懂了概念,实战中深浅拷贝的坑依然层出不穷。我自己就曾经因为一个浅拷贝问题,在线上环境排查了整整一个下午。这一节把常见问题和排查思路整理出来,希望能帮你少走弯路。
4.1 “为什么我改了配置别人也变了”——共享引用类的 bug
这类 bug 的典型特征:多个变量看似独立,实际共享底层数据。常见于以下场景:
- 从 store/state 里取出的对象,未经拷贝就赋给局部变量,然后修改了该局部变量。
- 组件之间通过 props 传递对象,子组件直接修改 props 里的嵌套属性。
- 工具函数内部对传入的对象参数做了“写操作”,影响了调用方的数据。
- 多个模块共用同一个配置文件,某个模块往里塞了字段,其他模块也跟着看到。
有一次我遇到的情况是:A 模块初始化了一个全局配置对象,为了让 B 模块也能用,就const bConfig = aConfig直接赋值过去了。后来 B 模块往配置里加了一个timeout字段,A 模块读取配置时也读到了这个timeout。看起来是“全局生效”,实则是共享引用的副作用。这种共享不一定是坏事,但如果 B 只是想改自己的副本,就会导致 A 被连带修改。
排查这类问题,最快的方法是在可疑的赋值处打一个“引用快照”。用console.log不足以判断两个对象是不是同一个引用,更好的办法是给对象加个临时标记字段,或者直接比较两个变量是否完全相等:
console.log(a === b); // true 说明就是同一个引用如果a === b为true,那它们就是同一个对象,改谁都是在改那一个。
4.2 深拷贝导致性能慢,大对象卡顿
深拷贝不是“万能药”,它同样有代价。当一个对象非常大、嵌套非常深时,深拷贝的耗时和内存开销会成倍上涨。如果在一个高频调用的函数里做深拷贝,页面卡顿是必然的。
我之前处理过一个批量导入 Excel 的场景,每次要拷贝一个包含数万条记录的大数组,每条记录还有多层嵌套。一开始为了图省事,所有地方都用深拷贝,结果一次导入要卡两三秒。后来优化的思路是:
- 只在数据从“导入区”进入“正式区”时做一次深拷贝,中间过程用引用传递。
- 对于只读数据,完全不做拷贝,直接使用原引用,用 Object.freeze 冻结对象,防止意外修改。
- 尽量用浅拷贝配合不可变更新模式,只更新需要变化的层级。
这里的大原则是:能用浅拷贝解决的就不要用深拷贝,深拷贝是一种负担,而不是一种保险。尤其在列表渲染、热更新路径上,越少的拷贝意味着越高的性能。
4.3 常见问题速查表
把我在开发中遇到的高频问题整理成了一张表格,方便你快速对号入座。
| 问题现象 | 大概率原因 | 解决方案 |
|---|---|---|
| 修改拷贝后的对象,原对象也跟着变了 | 用了浅拷贝或直接赋值 | 改用深拷贝,或确认是否需要共享引用 |
JSON 序列化后undefined、函数丢失 | 使用了 JSON 方案拷贝 | 换用 structuredClone 或 lodash.cloneDeep |
| 拷贝 Date 后变成字符串 | 使用了 JSON 方案 | 换用structuredClone,或手动处理 Date 分支 |
| 循环引用的对象无法拷贝 | 使用了 JSON 方案 | 使用structuredClone或手写带 WeakMap 的深拷贝 |
| 深拷贝很慢,页面卡顿 | 拷贝了过大的对象,或者高频调用 | 考虑浅拷贝、不可变更新或数据扁平化 |
| 对象被意外冻结/修改不动 | 有人用了 Object.freeze | 检查代码中对 freeze 的调用,拷贝时也无法解冻 |
| React state 更新后组件不重新渲染 | 直接修改了原 state,没有创建新引用 | 使用展开运算符合并层级,保持不可变更新 |
4.4 我常用的三类排查技巧
第一招:打印内存中的引用关系。如果你怀疑两个变量指向同一对象,可以给它们各打一个标签属性:
objA.__debugTag = 'A'; console.log(objB.__debugTag); // 'A',说明 objB 和 objA 是同一个对象这个方法虽然粗糙,但在复杂数据流中找“别名”很有效。
第二招:用structuredClone或JSON制造一个“隔离副本”,作为对照实验。如果修改隔离副本后原对象不变,而修改某个变量后原对象变了,说明问题出在赋值/拷贝环节,而不是逻辑环节。
第三招:遇到“改了这里,那里变”的问题,直接在可疑的深层对象上打断点,观察它的调用栈,看哪个函数先修改了它。很多时候问题不是出在你正在调试的那一行,而是更早的某个操作。
5. 最后再分享两个实操细节
整篇讲下来,核心就一句话:拷贝之前,先搞清楚你是要“独立的数据”,还是“共享的引用”。下面是两个我每次写代码都会反复确认的细节。
第一个细节是:对象拷贝要看清数据来源。如果对象来自前端状态管理库(Redux、Vuex、Pinia 等),共享引用往往是刻意设计的。你贸然深拷贝一份,反而可能破坏框架的响应式追踪或更新机制。正确的做法是遵循框架的更新惯例,用浅拷贝加层级更新的方式去修改状态,而不是一股脑深拷贝。
第二个细节是:如果你站在面试场景里,不要只背结论,要能画出一条记忆链:基本类型存值、引用类型存地址、浅拷贝只复制第一层、深拷贝递归到底、循环引用用 WeakMap 兜底、JSON 方案有哪些坑、structuredClone 是什么。这条链理清楚了,面试官怎么追问你都能接住。
写代码这件事,很多坑都是 “概念差之毫厘,线上失之千里”。深浅拷贝这个点,花点时间彻底吃透,是真的能在未来某个深夜帮你省下几个小时 debug 时间的。