先抛出我这几年的一个感受:凡是写过几轮业务代码的人,迟早会在某个深夜遇到一个诡异 bug——明明用 = 赋值了一个对象,改了下级属性,结果源头对象也变了。这种问题十有八九是“引用共享”搞的鬼,解决方案就是深拷贝。而深拷贝的实现方式里,JSON.parse(JSON.stringify()) 面试够用、常规业务凑合,但一碰到 Date、RegExp、Map、循环引用这些结构就当场翻车。真正能扛住实战的方案,是基于递归手写一套深拷贝。
这篇文章我会从浅拷贝为什么不够用讲起,把递归的思维方式、手写实现的完整演进过程、边界类型处理、循环引用规避、性能与选型建议全部展开。不管你是在准备面试,还是想彻底搞懂深拷贝的原理,都可以照着下面的思路走一遍,最后你手里会多一个能直接用的 deepClone 函数。
1. 先搞清楚:浅拷贝到底哪里不够用
1.1 一个再常见不过的赋值场景
JavaScript 里对象是“按引用”保存的。你写 const newObj = oldObj,本质上只是把内存地址复制了一遍,两块变量名字指向同一个堆内存区域。这时候你修改 newObj 下的某个字段,oldObj 同样会感知到变化,因为它们在底层是同一个东西。
我举个例子,假设你有一个配置对象:
javascript复制const defaultConfig = {
theme: {
color: '#333',
spacing: 8
},
api: {
endpoint: '/api/v1',
timeout: 5000
}
};
const userConfig = defaultConfig;
userConfig.theme.color = '#ff0000';
console.log(defaultConfig.theme.color); // '#ff0000',被改了
这种情况在 React 状态管理、Vue 响应式、后端接口返回数据处理中极其常见。你以为你只是拷贝了一份配置去做局部调整,结果把全局默认配置污染了,排查起来还非常隐蔽。因为你根本不会第一时间想到是赋值操作出了问题。
1.2 浅拷贝的三个常用手段
为了尽量避免这种引用共享,新手通常会尝试几种“表面拷贝”的写法:
写法一:对象展开运算符
javascript复制const newConfig = { ...defaultConfig };
展开运算符只拷贝了第一层的键值。对于第一层的基本类型值,它复制的是值本身;但对于嵌套对象,它复制的仍然是引用。也就是说,newConfig.theme 和 defaultConfig.theme 仍然指向同一个对象。
写法二:Object.assign()
javascript复制const newConfig = Object.assign({}, defaultConfig);
效果和展开运算符几乎一模一样。第一层是“安全”的,深层依然是引用共享。
写法三:数组的 slice() / concat()
javascript复制const newList = oldList.slice();
数组也一样,slice() 只做浅拷贝。如果数组元素是对象,newList[0] 和 oldList[0] 还是同一个引用。
所以,你真正需要的是把对象里每一个“引用类型”的字段都递归地复制一遍,直到叶子节点全部变成基本类型值为止。这就是深拷贝。
1.3 什么时候必须用深拷贝
不是所有场景都需要深拷贝,我总结了下真正非用不可的几类场景:
- 表单数据与回显数据隔离:编辑弹窗里改了表单对象,不能影响列表里的原始行数据。
- 状态管理框架中的不可变数据:比如 Redux 要求每次 state 更新都是纯函数返回新对象,浅拷贝往往导致旧 state 被连带修改,进而影响时间旅行调试。
- 缓存快照:接口返回的数据需要缓存一份做对比,比如判断“用户是否真的改了配置”,这时候深拷贝一份快照才能做可靠 diff。
- 实现撤销/重做:每一步操作前深拷贝整个状态结构,存入历史栈,回退时直接恢复。
一句话总结:只要你的数据里存在多层嵌套结构,又有“隔离修改”的需求,深拷贝就是刚需。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么深拷贝要用递归
2.1 深拷贝的问题本质是一棵树
先想清楚一个问题:深拷贝难点在哪?难点在于你不知道这个对象嵌套了多少层,也不知道每一层的结构是什么。对象里面有数组,数组里面有对象,对象里又可能有数组,这本质上是一棵多叉树。
树这种结构,处理起来最自然的方式就是递归。因为每个节点(对象或数组)的处理逻辑完全一致:遍历它的所有值,值是基本类型就直接复制,值是引用类型就继续往下走。你不需要关心树的深度,递归天然适合这种“结构未知但形态自相似”的数据。
这也是为什么“深拷贝”这个经典问题,几乎所有教材都用递归来解。它不像迭代方案需要自己维护一个栈或队列,递归的调用栈本身就是帮你做深度优先遍历的工具。
2.2 递归的两个必要条件
递归不是随便写的,它必须满足两个条件:
- 基线条件(Base Case):遇到什么情况就不再递归下去了?通常就是基本类型值、函数、以及已经处理过的对象等。
- 递归条件(Recursive Case):对于引用类型,如何缩小问题规模?通常就是“遍历当前对象的键,对每个值调用同一个 clone 函数”。
写递归深拷贝最容易犯的错就是漏掉基线条件,导致无限递归。这个问题我会在第 5 部分重点讲。
2.3 JSON方案的局限:先别急着全盘否定
很多人觉得 JSON.parse(JSON.stringify(obj)) 就是深拷贝。我平时写 demo 也会偷懒用它,但你要清楚它有几个硬伤:
- 返回
undefined、函数、Symbol的键会被直接丢弃。 Date会被转成字符串,拷贝后不再是Date实例。RegExp、Map、Set、WeakMap、WeakSet都会被序列化成空对象或普通对象,数据直接丢。- 对象循环引用时会直接抛错:
Converting circular structure to JSON。 NaN会被转成null,Infinity也会被
