☰
深拷贝与浅拷贝:JavaScript内存模型、实现方式与实战避坑指南
2026/10/1 15:05:43 网站建设 项目流程

这个话题几乎是前端面试的“钉子户”,也是日常开发里最容易埋雷的地方。我见过不少同学写代码时碰到的诡异 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 时间的。

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

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

立即咨询