深浅拷贝这个问题,我前后写过不下十遍,也在项目里栽过好几次跟头。最近又有人在群里问:为什么 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,最终在目标对象上得到的是值,而不是属性的完整描述符。- 扩展运算符在浅拷贝时,会使用类似于 `[[DefineOwnPrope
