深浅拷贝这个问题,我前后写过不下十遍,也在项目里栽过好几次跟头。最近又有人在群里问:为什么 const obj2 = obj1 之后再改 obj2.name,obj1.name 也跟着变了?答案就一句话:你做的不是拷贝,是赋值。但真要把深浅拷贝讲明白,得从 JS 的内存模型、数据类型、递归原理一路聊到循环引用、特殊对象处理、框架里的表现,这个题目其实一点都不小。
这篇就基于我自己的踩坑记录,把深浅拷贝的来龙去脉、实现方案、边界情况一次性说清楚。适合被这个知识点困扰过的初中级前端,也适合准备面试、想在项目里做数据隔离的人参考。看完之后,你不仅能写出可靠的手写深拷贝,还能在合适的场景里做出正确选型。
1. 为什么对象赋值会"互相牵连":堆栈模型与引用传递
1.1 基本类型和引用类型的存储差异
要理解深浅拷贝,首先得接受一个事实:JS 里的数据在内存中不是统一存放的。基本类型(number、string、boolean、null、undefined、symbol、bigint)通常存在栈内存里,变量名直接对应一个具体的值;而引用类型(object、array、function、date、regexp、map、set 等)的数据本体放在堆内存里,栈上存的是一个指向堆内存的地址,也就是常说的引用。
我用一个生活中的例子帮助自己理解:栈上的变量像"门牌号记录表",堆里的对象才是真正的"房间"。你 const obj2 = obj1,不是复制了一间房,而是又写了一张指向同一间房的门牌号。所以后续通过 obj2 去改房间里的陈设,obj1 看同一个房间,自然能看到改动。房间还是那个房间,只是多了一张纸条。
javascript复制const obj1 = { name: "张三" };
const obj2 = obj1;
obj2.name = "李四";
console.log(obj1.name); // "李四"
这个结果我早期很不理解,后来画了一次内存图就通了。obj1 和 obj2 两个变量名,栈里存的是同一个十六进制地址,比如 0x0012AB,这个地址指向堆里唯一的一个对象。你通过哪个名字去操作,都是操作同一个对象本体。
1.2 赋值、浅拷贝、深拷贝的本质区别
明确了引用传递之后,三种操作的区别就很清晰了:
- 赋值:把栈上的引用复制一份给新变量,新旧变量指向同一个堆对象。改任何一方,另一方都受影响。
- 浅拷贝:创建一个新对象,然后遍历源对象的第一层属性,把属性值复制过来。如果属性值是基本类型,相当于把值复制了一份,新对象和源对象在这一层是独立的;如果属性值本身是引用类型,那么新对象里存的仍然是同一个引用,内层对象仍然共享。
- 深拷贝:递归地把对象所有层级的属性都复制为全新的值,新对象和源对象在任何层级都不共享引用,完全隔离。
也就是说,深浅拷贝的差异判断,永远要问一句:修改新对象的第二层、第三层属性时,源对象会不会跟着变?会变,就是浅拷贝;不会变,才算深拷贝。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 深浅拷贝的分水岭:先明确"复制了多少层"
2.1 一级属性与嵌套对象的差异
很多人只用"改嵌套对象有没有影响"来区分深浅拷贝,这个方向是对的,但不够精确。精确的说法是:浅拷贝只保证第一层属性的独立性,深拷贝则保证每一层都独立。
举例说明,一个对象:
javascript复制const source = {
name: "张三",
address: {
city: "北京",
district: "海淀"
}
};
如果做浅拷贝,得到 copied:
javascript复制const copied = { ...source };
copied.name = "李四"; // source.name 不受影响
copied.address.city = "上海"; // source.address.city 变成 "上海"
name 是基本类型,浅拷贝时值被复制,所以新对象改名不影响源对象;address 是引用类型,浅拷贝时只是复制了指向 address 对象的引用,所以通过 copied.address 修改 city,源对象的 address 也变了。
2.2 通过实际用例验证深浅
为了让自己一眼判断当前使用的方案是浅拷贝还是深拷贝,我习惯写一段验证代码:
javascript复制function checkCopyMethod(copyFn) {
const source = {
name: "张三",
address: { city: "北京" }
};
const copied = copyFn(source);
copied.address.city = "上海";
return source.address.city === "上海"
? "浅拷贝:内层对象依然共享"
: "深拷贝:内层对象已隔离";
}
console.log(checkCopyMethod(src => ({ ...src }))); // 浅拷贝:内层对象依然共享
console.log(checkCopyMethod(src => JSON.parse(JSON.stringify(src)))); // 深拷贝:内层对象已隔离
这个思路也可以反过来用:先确认自己写的方法在第一层是独立的,再改第二层看是否隔离。只要两步都验证过,深浅拷贝的判断就非常笃定了。
2.3 一个简单的判断标准
我把判断标准压缩成一句话:拷贝完之后,直接修改新对象第二层的属性,如果源对象跟着变,就是浅拷贝;如果完全隔离,就是深拷贝。 第一层属性是否独立,只是基本要求,连第一层都不独立的,那是赋值,连浅拷贝都不算。
3. 浅拷贝的常见实现:从一行代码到手写
3.1 展开运算符与 Object.assign
现代开发中最常见的浅拷贝方式是扩展运算符(spread):
javascript复制const copy = { ...source };
另一种常见方式是 Object.assign:
javascript复制const copy = Object.assign({}, source);
两者都只复制第一层属性,差别在于:
Object.assign是通过赋值操作把属性值写进目标对象,如果源对象属性有getter/setter,Object.assign会触发 setter,最终在目标对象上得到的是值,而不是属性的完整描述符。- 扩展运算符在浅拷贝时,会使用类似于
[[DefineOwnProperty]]的方式定义属性,对某些属性描述符的把控更接近规范行为,日常使用基本感知不到差异,但遇到特殊属性时需要留意。 - 两者都会拷贝
Symbol类型的可枚举属性,但都不会拷贝原型链上的属性,也不会拷贝对象的原型对象。
在 React 中常见的 setState 写法其实就是应用了浅拷贝:
javascript复制setState(prev => ({
...prev,
config: {
...prev.config,
theme: "dark"
}
}));
这里每一层都手动展开,只改了需要改的一层。如果不展开 prev.config 而直接覆盖,那么 prev.config 的其他字段就丢了;如果只更新 prev 不更新内层,又会留下引用共享的隐患。这就是为什么"不可变更新"要逐层展开。
3.2 数组的浅拷贝
数组也是对象,也存在引用共享。常见数组浅拷贝方法有:
javascript复制const arr = [1, 2, { value: 3 }];
const copy1 = arr.slice();
const copy2 = arr.concat();
const copy3 = [...arr];
const copy4 = arr.map(item => item);
const copy5 = arr.filter(() => true);
这些方法都只复制了一层。如果数组里是对象,修改 copy1[2].value,原数组里的对象同样会变。我给数组做拷贝时,如果确认数组元素全是基本类型,用 [...arr] 就够了;如果有嵌套对象,必须走深拷贝逻辑。
值得提醒的是,Array.from 也比很多人以为的要浅:
javascript复制const copy6 = Array.from(arr); // 同样是浅拷贝
3.3 手写浅拷贝的基准版本
理解原理最直接的方式是手写。一个基础版本如下:
javascript复制function shallowClone(target) {
// 基本类型直接返回
if (target === null || typeof target !== "object") {
return target;
}
// 根据构造函数创建新对象/数组
const result = Array.isArray(target) ? [] : {};
// 只复制自身可枚举属性(包括 Symbol 属性)
for (const key in target) {
if (Object.prototype.hasOwnProperty.call(target, key)) {
result[key] = target[key];
}
}
return result;
}
注意 for...in 会把原型上的可枚举属性也遍历出来,所以必须用 hasOwnProperty 过滤,否则新对象上会出现莫名其妙的共享属性。这一点在面试手撕代码时也经常被考察,千万不能漏。
3.4 浅拷贝里容易被忽略的细节
除了 for...in 过滤,还有几个点容易踩坑:
- 属性描述符:浅拷贝默认只复制值,不会保留源对象的
getter/setter和writable/enumerable等属性描述符配置。如果确实需要保留,得用Object.getOwnPropertyDescriptor和Object.defineProperty。 - 原型链:浅拷贝得到的新对象,原型是
Object.prototype或Array.prototype,而不是源对象的原型。要实现原型的保留,需要Object.create(Object.getPrototypeOf(target))。 - 非可枚举属性:普通遍历拿不到非可枚举属性,需要
Object.getOwnPropertyNames加Object.getOwnPropertySymbols配合遍历。 Object.create(null)的对象:这种对象没有原型,常规{}拷贝后反而带上Object.prototype,处理时需要额外判断。
4. 深拷贝:三种主流方案与各自的"死穴"
4.1 JSON 方案
一行代码实现深拷贝,这是很多人的第一反应:
javascript复制const deepCopy = JSON.parse(JSON.stringify(source));
这方案思路很直观:先把对象序列化成 JSON 字符串,再解析回一个新的对象。由于 JSON 字符串是纯粹的数据文本,解析出来的对象和源对象彻底没关系了,嵌套层级再深也不会共享引用。
它最方便的地方在于代码量极小、逻辑一目了然,应对后端接口返回的纯数据对象时非常可靠。我早期在项目里大量使用它来拷贝表单数据。
4.2 JSON 七宗罪:它到底丢掉了什么
但 JSON.parse(JSON.stringify()) 不是万能的,它对以下数据类型都会出问题:
| 源对象中的类型 | JSON 方式的行为 | 典型后果 |
|---|---|---|
undefined |
属性直接被丢弃 | 对象字段消失 |
function |
属性被忽略,函数序列化时会被移除 | 方法丢失 |
symbol |
属性被忽略,数组中的 Symbol 变成 null |
值丢失 |
bigint |
直接报错 TypeError |
程序异常 |
Date |
转成 ISO 字符串 | 不再是 Date 对象 |
RegExp |
转成空对象 {} |
正则应配失效 |
Map / Set |
转成空对象 {} |
数据全丢 |
NaN / Infinity |
变成 null |
数值精度异常 |
| 循环引用 | 抛出 TypeError |
程序异常 |
| 原型链 | 不保留,只保留普通对象/数组结构 | 自定义类实例退化为普通对象 |
我举个实际场景:从服务端拿到的配置里含有一个 Date 类型的到期时间,用 JSON 方式拷贝之后,date 变字符串,后面调 getTime() 直接报 is not a function。这种问题在运行时才暴露,排查成本比想象中高。
4.3 原生 structuredClone:浏览器自带的新方案
现代浏览器和 Node 17+ 提供了一个原生深拷贝 API:
javascript复制const clone = structuredClone(source);
它支持循环引用,支持 Date、Map、Set、ArrayBuffer、Blob、ImageData 等结构化类型,整体能力比 JSON 方案强很多。但它也不是没有限制:
- 不支持函数、Symbol 作为值拷贝,函数会被直接忽略或报错;
- 不支持拷贝 DOM 节点,无法用于克隆元素本身;
- 原型链同样不被保留,自定义 Class 实例会退化为普通对象;
- 老版本浏览器和旧 Node 环境没有这个 API,使用前要确认运行环境。
如果项目环境允许,structuredClone 是深拷贝的首选方案之一,比自己手写可靠,也比引入第三方库轻量。但遇到函数或特殊对象,仍然需要兜底方案。
4.4 手写递归深拷贝的基本框架
手写深拷贝的核心是递归,框架如下:
javascript复制function deepClone(target) {
if (target === null || typeof target !== "object") {
return target;
}
const result = Array.isArray(target) ? [] : {};
for (const key in target) {
if (Object.prototype.hasOwnProperty.call(target, key)) {
result[key] = deepClone(target[key]);
}
}
return result;
}
这个版本能解决嵌套对象的问题,但遇到 Date、RegExp、Map、Set、循环引用时又会露馅。真正能干活的版本,必须在这套递归框架上做类型识别和缓存处理。
5. 能打的手写深拷贝:循环引用、特殊对象与类型保持
5.1 为什么直接递归会爆栈
上面的基础版本遇到循环引用就完了:
javascript复制const obj = { name: "张三" };
obj.self = obj;
const clone = deepClone(obj); // RangeError: Maximum call stack size exceeded
原因很简单:obj 的 self 属性指向自身,递归函数进入 deepClone(obj.self) 之后,发现 self 是一个对象,又递归 deepClone(obj.self.self),无限套娃,直到栈溢出。
解决思路也不复杂:增加一个"登记表",记录哪些对象已经拷贝过了。当我们再次遇到同一个引用时,直接把之前拷贝出来的结果返回,而不是重新生成。这既解决了循环引用,也能避免同一对象被拷贝出多份,保持引用结构的一致性。
5.2 WeakMap 缓存:为什么用它
登记表用普通 Map 够不够?够用,但推荐用 WeakMap。
WeakMap 的 key 是弱引用,不会阻止垃圾回收。当源对象被业务代码释放后,WeakMap 里的键值对也能被回收,不会造成内存泄漏。而用 Map 时,源对象会被 Map 的 key 强引用,哪怕业务侧已经不再需要这个对象,它仍然被登记表占住,内存无法释放。
实际使用中,就在每次深拷贝开始前创建一个 WeakMap,然后传入递归函数:
javascript复制function deepClone(target, cache = new WeakMap()) {
// 基本类型直接返回
if (target === null || typeof target !== "object") {
return target;
}
// 遇到循环引用或重复引用,直接返回已克隆对象
if (cache.has(target)) {
return cache.get(target);
}
// 创建一个新容器
const result = Array.isArray(target) ? [] : {};
cache.set(target, result);
// 遍历拷贝
for (const key in target) {
if (Object.prototype.hasOwnProperty.call(target, key)) {
result[key] = deepClone(target[key], cache);
}
}
return result;
}
注意:一定要在遍历属性之前就把 result 放进 cache,否则循环引用路径上,属性还没开始递归就触发了缓存查询,会一直查不到,逻辑又回到无限递归。
5.3 特殊类型的防御式处理
为了处理 Date、RegExp、Map、Set 等特殊对象,我加了一个类型分支。怎么判断?Object.prototype.toString.call(target) 拿到的标签是最可靠的方式。
javascript复制function getType(target) {
return Object.prototype.toString.call(target);
}
function deepClone(target, cache = new WeakMap()) {
if (target === null || typeof target !== "object") {
return target;
}
if (cache.has(target)) {
return cache.get(target);
}
const type = getType(target);
if (type === "[object Date]") {
const result = new Date(target.getTime());
cache.set(target, result);
return result;
}
if (type === "[object RegExp]") {
const result = new RegExp(target.source, target.flags);
cache.set(target, result);
return result;
}
if (type === "[object Map]") {
const result = new Map();
cache.set(target, result);
target.forEach((value, key) => {
result.set(deepClone(key, cache), deepClone(value, cache));
});
return result;
}
if (type === "[object Set]") {
const result = new Set();
cache.set(target, result);
target.forEach(value => {
result.add(deepClone(value, cache));
});
return result;
}
const result = Array.isArray(target) ? [] : {};
cache.set(target, result);
// 遍历普通对象属性,包括 Symbol 属性
for (const key in target) {
if (Object.prototype.hasOwnProperty.call(target, key)) {
result[key] = deepClone(target[key], cache);
}
}
// 拷贝 Symbol 属性
const symbols = Object.getOwnPropertySymbols(target);
for (const sym of symbols) {
result[sym] = deepClone(target[sym], cache);
}
return result;
}
这里有几个细节:
Date要调用getTime()得到时间戳,再通过new Date(timestamp)还原,否则直接把target传给构造函数,得到的是一个字符串,不准确。RegExp要拆成source和flags两个部分,flags是正则的修饰符(g、i、m、u、y、s)。Map的 key 也参与深拷贝,这一点容易被忽略。因为 key 可能有对象,如果只拷贝 value,副本的 key 与源对象的 key 仍共享引用。Symbol属性在普通for...in里拿不到,需要额外用Object.getOwnPropertySymbols遍历。这也是手写深拷贝最容易遗漏的点。
5.4 保持原型链
上面的版本拷贝出来的普通对象,原型是 Object.prototype,如果源对象是某个类的实例,这个方法会让它变成普通对象。
要保留原型,可以在创建新容器时使用:
javascript复制const result = Object.create(Object.getPrototypeOf(target));
然后配合 Object.getOwnPropertyDescriptors 来实现属性描述符级别的拷贝:
javascript复制Object.defineProperties(
result,
Object.getOwnPropertyDescriptors(target)
);
但这里要非常小心:如果直接对 result 用 Object.getOwnPropertyDescriptors,那么 getter 函数实际上会被重新定义为 getter,这符合"保留描述符"的预期;但如果你不需要 getter 被二次执行,则可能产生副作用。一般情况下,我会把"保留原型"和"保留完整描述符"作为一个可选项,而不是默认行为,因为大部分业务对象不需要这么精细。
5.5 完整版手写深拷贝代码
整合上面的思路,给出一份我实际项目中维护过的精简版:
javascript复制function getType(target) {
return Object.prototype.toString.call(target);
}
function deepClone(target, cache = new WeakMap()) {
if (target === null || typeof target !== "object") {
return target;
}
if (cache.has(target)) {
return cache.get(target);
}
const type = getType(target);
if (type === "[object Date]") {
const result = new Date(target.getTime());
cache.set(target, result);
return result;
}
if (type === "[object RegExp]") {
const result = new RegExp(target.source, target.flags);
cache.set(target, result);
return result;
}
if (type === "[object Map]") {
const result = new Map();
cache.set(target, result);
target.forEach((value, key) => {
result.set(deepClone(key, cache), deepClone(value, cache));
});
return result;
}
if (type === "[object Set]") {
const result = new Set();
cache.set(target, result);
target.forEach(value => {
result.add(deepClone(value, cache));
});
return result;
}
const result = Object.create(Object.getPrototypeOf(target));
cache.set(target, result);
const keys = [
...Object.keys(target),
...Object.getOwnPropertySymbols(target)
];
for (const key of keys) {
result[key] = deepClone(target[key], cache);
}
return result;
}
这个版本在绝大多数业务场景下够用了。它支持循环引用、Date、RegExp、Map、Set、数组、普通对象、Symbol 属性,并且保留了原型。唯一的取舍是:它是通过赋值的方式拷贝属性,不会保留属性的完整描述符,但通常不影响业务。
5.6 大数据量下的性能考量
深拷贝是递归操作,对象层级越深、数据量越大,耗时越高。我在实际项目里测试过一个 5MB 左右的复杂配置对象,手写递归深拷贝耗时约 40ms 到 100ms,具体取决于嵌套深度和类型数量。如果是在高频渲染链路里执行,这开销不可忽视。
遇到大数据量,有几个优化方向:
- 优先用
structuredClone,如果环境支持,它是由引擎原生实现的,比 JS 递归快得多。 - 避免不必要的重复拷贝,例如只拷贝需要变动的数据块,而不是整棵对象树。
- 对于纯 JSON 数据且不包含特殊类型,
JSON.parse(JSON.stringify())在大部分浏览器里的表现都不差,且代码简洁。 - 如果对象结构固定、字段名确定,手写针对性的拷贝函数往往比通用深拷贝更快。
6. 项目里的选型建议与常见误区
6.1 什么时候用 JSON,什么时候用 structuredClone,什么时候手写
我在项目里的选型逻辑可以总结成一个简单的判断链:
- 对象里只有普通对象、数组、字符串、数字、布尔值、null,且没有循环引用:用
JSON.parse(JSON.stringify())。这是最省事、最不容易出错的方案。 - 对象里有
Date、Map、Set、RegExp,或者存在循环引用,且运行环境支持:优先用structuredClone。 - 对象里有函数、Symbol 作为值、自定义 Class 实例,或者需要精确控制原型链和属性描述符:自己写深拷贝,或使用
lodash.cloneDeep。
lodash.cloneDeep 是目前社区里最成熟的手写深拷贝方案,它处理了非常多的边界情况,包括 Buffer、TypedArray、ArrayBuffer、Error 等。如果项目已经引入了 lodash,直接用它是合理的选择;如果只是为了深拷贝去引入整个 lodash,那就要权衡包体积了。
6.2 框架状态管理里的隐藏坑
在 React 或者 Vue 中,深浅拷贝的坑经常以"状态没更新"或"状态被意外连带修改"的形式出现。
React 推荐不可变数据更新。如果你在 setState 里这样写:
javascript复制const [user, setUser] = useState({ profile: { name: "张三" } });
function updateName() {
const next = { ...user };
next.profile.name = "李四";
setUser(next);
}
那么 user.profile 和 next.profile 是同一个引用,React 对比新旧状态时,发现 profile 引用没变,可能不会重新渲染,界面就不会更新。这种 bug 的典型表现是:数据确实改了,但视图没变。
解决方案是更新时把每一层涉及修改的属性都展开:
javascript复制function updateName() {
setUser(prev => ({
...prev,
profile: {
...prev.profile,
name: "李四"
}
}));
}
说到底,框架推荐不可变数据,就是要彻底切断引用共享,而不是只改一层。浅拷贝在这里是不够用的,需要路径上的每一层都展开。这也是很多人觉得 React 更新数据"麻烦"的原因,但一旦理解了引用共享,就明白这套约束是为了避免"幽灵修改"。
6.3 我踩过的几个真实场景
第一件是编辑器场景。我用一个对象维护多个面板的折叠状态,切面板时对配置对象做了一次赋值备份,结果修改备份里的面板状态,主配置的折叠状态也被动过一次。后来把赋值改成深拷贝,问题立刻消失。
第二件是表单回填。页面上编辑一条带嵌套对象的数据,我先 JSON.parse(JSON.stringify()) 把数据拷贝一份,改完保存时再把对象传回接口,结果里头的 Date 字段全变成字符串,导致后端解析异常。这次教训让我记住了:JSON 方案必须确认数据里没有特殊类型,否则不能盲目使用。
第三件是配置合并。项目里要合并默认配置和用户配置,用户配置缺失的字段从默认配置里取。最初用 Object.assign 做合并,结果用户配置里的一个子对象会和默认配置共享引用,深层的默认值被用户修改一起带偏。查了很久才发现,是因为合并只做了一层,嵌套对象需要递归处理或深合并。
这三件事让我彻底养成一个习惯:任何拷贝操作之前,先问自己——我要拷贝的数据里有哪些类型?嵌套多深?有没有循环引用?
6.4 手写深拷贝的三个边界问题
即使写到了上面的完整版,手写深拷贝仍然有边界问题需要考虑:
Promise、Error、ArrayBuffer、TypedArray、DataView、Blob、File等特殊类型需要逐一处理,否则拷贝结果会丢失内部结构。- 包含不可枚举且携带关键业务逻辑的属性时,简单遍历拿不到这些属性,需要
Object.getOwnPropertyDescriptors配合Object.defineProperties。 - 如果自定义 Class 的实例内部维护了非对象类型的私有字段,比如
WeakMap、闭包变量、私有属性(#field),深拷贝也无法复制它们的内部状态。
所以,如果你问我到底要不要自己写深拷贝,我的答案通常分两层:如果是为了理解原理、过面试、应对简单场景,手写一份完全值得;如果是生产环境要处理复杂的业务对象,优先用 lodash.cloneDeep 或 structuredClone,然后针对业务特有问题做补充处理。自己从零维护一个全类型深拷贝的代价,往往比想象中大得多。
最后再分享一个小技巧
调试深浅拷贝问题时,别急着看代码逻辑,先在控制台打印一行:
javascript复制console.log(copied === source); // false
console.log(copied.address === source.address); // true 就是浅拷贝
用 === 直接比较引用,比肉眼观察数据变化快得多。哪个层级还共享引用,一目了然。
我自己的经验是:深浅拷贝真正难的不是那几百行实现代码,而是建立"对象引用共享"的直觉。只要在写任何赋值、展开、拷贝之前,先在脑子里过一遍"这一层是复制值还是复制引用",大部分问题都能在动手之前避开。
