引言
在 JS 里复制一个对象,看起来简单,做起来处处是坑。你以为是复制了一份独立的数据,结果改了新对象,原对象也跟着变。或者你小心翼翼地复制了一层,结果嵌套里面的对象还是共享的。问题就出在浅拷贝和深拷贝的区别上。这篇文章只讲这一个问题,把复制的逻辑讲清楚。
一、问题的根源:引用类型
要理解拷贝,先要理解引用类型。
原始类型的变量,存的是值本身。复制一个数字变量,得到的是独立的两份值,改一个不影响另一个。
引用类型的变量,存的是地址。复制一个对象变量,复制的只是地址,两个变量指向同一个物体。改其中一个,另一个看到的也是改后的结果。
所以,复制对象不能简单赋值。必须创建新对象,把原对象的内容搬过去。但搬到什么程度,就是浅拷贝和深拷贝的分界线。
二、浅拷贝:只复制第一层
浅拷贝,顾名思义,只复制对象的第一层。
它创建一个新对象,把原对象第一层的属性值复制过来。如果属性值是原始类型,复制的是值,互不影响。如果属性值是引用类型,复制的是地址,新对象和原对象仍然共享那个嵌套对象。
所以,浅拷贝之后,改新对象的第一层原始属性,原对象不受影响;但改新对象里嵌套的对象,原对象也会变。
浅拷贝的实现方式有多种:展开运算符、对象合并方法、数组切片方法等,本质上都是只处理第一层。
三、深拷贝:递归复制所有层
深拷贝,是要得到一个完全独立的新对象,无论嵌套多少层,都不共享。
它的思路是递归:遇到原始类型直接复制值,遇到引用类型就新建一个同类型容器,再递归复制里面的内容。一层层往下,直到所有层级都复制完。
深拷贝之后,改新对象的任何地方,原对象都不会受影响。两个对象彻底独立。
四、深拷贝的难点
深拷贝听起来简单,实际实现要考虑很多情况。
第一,循环引用。对象 A 引用了 B,B 又引用了 A。递归复制时会无限循环,导致栈溢出。必须记录已经复制过的对象,遇到重复的直接复用,才能打断循环。
第二,特殊类型。日期、正则、映射、集合这些类型,不能简单当作普通对象处理,需要分别判断并特殊处理,否则复制出来的东西会丢失原有特性。
第三,函数。函数也是对象,但通常不复制函数体,而是直接共享引用。深拷贝一般只处理数据,不处理行为。
第四,原型链。简单递归会丢失原对象的原型,复制出来的对象可能不再是原来的类型。需要额外处理原型关系。
第五,性能。深拷贝要遍历所有层级,对象越大越慢。对大对象做深拷贝,可能造成明显卡顿。
五、常用的深拷贝方式及其局限
手动递归是最可控的方式,可以根据需求处理各种特殊情况,但代码量大,容易漏掉边界情况。
序列化再反序列化是一种取巧方式:把对象转成字符串,再转回对象。简单,但有明显局限:会丢失函数、日期会变成字符串、映射和集合会丢失、循环引用直接报错。只适合纯数据且结构简单的场景。
结构化克隆是较新的浏览器能力,能处理更多类型,也支持循环引用,但并非所有环境都支持,且不能复制函数。
第三方库提供了成熟的深拷贝实现,考虑了大量边界情况,但引入依赖需要权衡体积。
没有一种方式完美适用于所有场景,选择取决于数据类型和运行环境。
六、什么时候用浅拷贝,什么时候用深拷贝
浅拷贝够用的场景:对象只有一层,或者嵌套部分本来就不需要独立;你明确知道哪些部分共享、哪些部分独立;性能敏感,不想付出深拷贝的代价。
必须深拷贝的场景:需要完全独立的数据副本,修改副本不能影响原数据;对象结构复杂且嵌套深;要把数据传给不可信的外部逻辑,防止被意外修改。
很多时候,浅拷贝加手动处理特定嵌套字段,比无脑深拷贝更高效。不是越深越好,而是按需复制。
七、不可变思维
现代前端开发中,越来越流行不可变的思路:不修改原对象,每次变化都产生新对象。
这种思路下,浅拷贝用得非常频繁。因为只要保证每次修改都创建新对象,不直接改原对象,就能实现状态的可追踪。对于嵌套结构,可以只复制变化路径上的节点,其余部分共享,既保证独立性又节省性能。
理解浅拷贝和深拷贝,是掌握这种思维的基础。
八、总结
浅拷贝只复制第一层,嵌套的引用仍然共享;深拷贝递归复制所有层,得到完全独立的对象。深拷贝要处理循环引用、特殊类型、性能等问题,没有一种方式放之四海而皆准。实际开发中,要根据数据结构和需求选择复制深度,而不是一律深拷贝。理解这个区别,你就能避免"改了副本原对象也变"的坑,也能在性能敏感时做出合理取舍。