1. 为什么深拷贝这件事,最后都会绕到递归上
深拷贝这个需求,前端开发里几乎人人都遇到过。不管是状态管理里要隔离数据、提交表单前深拷贝一份配置,还是做组件库时避免副作用,你都会面临同一个问题:怎么把一个对象完整地复制一份,让它和原来的对象彻底没关系。
网上搜“深拷贝”能出来一堆文章,但大部分要么直接甩一行 JSON.parse(JSON.stringify(obj)),要么推荐 lodash.cloneDeep。真正愿意把深拷贝背后的递归逻辑讲清楚的很少。我刚工作那会儿也是这样,遇到拷贝直接JSON一把梭,直到有一次日期被转成了字符串、undefined 直接消失、循环引用直接报错,我才开始认真研究递归和深拷贝之间的底层关系。
先说结论:深拷贝本质上是在遍历一棵“由引用关系组成的树”,而递归是遍历树形结构最自然、最优雅的方式。你不需要背代码,把递归的逻辑想明白,深拷贝的每个分支都能自己推出来。
这篇文章适合谁看?正在学 JavaScript 基础、被深浅拷贝和递归绕晕的前端新手,以及“会用 cloneDeep 但一直没搞懂到底发生了什么”的进阶开发者。我会从最底层的数据结构讲起,把递归的思路拆开,最后给出一套能直接放进项目的生产级深拷贝实现。
1.1 浅拷贝和深拷贝的本质区别
很多人对深浅拷贝的理解停留在“一层”和“多层”这个说法上,其实这是不对的。浅拷贝不是“只拷贝一层”,而是“只处理当前层,遇到引用类型就原封不动地共享地址”。
看一个最简单的例子:
javascript复制const original = {
name: 'Tom',
address: {
city: 'Shanghai',
zip: 200000
}
};
// 浅拷贝
const shadowCopy = { ...original };
shadowCopy.name = 'Jerry'; // 不影响 original
shadowCopy.address.city = 'Beijing'; // original.address.city 也变成了 Beijing
为什么第二层会互相影响?因为 ... 展开运算符在做拷贝时,只会把 original 的属性逐个复制到新对象里。对于基本类型(string、number、boolean),复制的是值,所以改 name 不影响原对象;但对于 address 这种引用类型,复制的是“指向内存地址的引用”,新对象和原对象的 address 属性指向同一块内存区域。所以不管你从新对象还是原对象去改 address.city,改的都是同一块内存。
这就是浅拷贝的核心:它只走了一层,更深层的引用关系没有断开。
深拷贝要做的事情,就是沿着这棵引用树继续往下走,走到每一个叶子节点都是基本类型,然后一层层把“引用”替换成“真正新建的对象”。这个“沿着引用关系往下走”的过程,天然就是递归的形态。
1.2 递归天生就是为树形结构设计的
递归的经典理解方式有两个关键词:递推和回归。你定义一个函数,在函数体里调用自己,每次调用时输入更小、更简单的子问题,直到子问题简单到可以直接返回结果,然后再一层层把结果组合回去。
把对象想象成一棵树:根节点是那个 obj,它的每个属性值是树的分支节点。如果一个属性值还是一个对象,就又是一个子树。基本类型的属性是叶子节点,没有子分支了。
递归深拷贝的逻辑其实就两句:
- 当前节点的值不是对象吗?对,那先造一个空对象/数组,然后遍历它的每个属性,对每个属性值“重复同样的拷贝逻辑”。
- 当前节点的值是基本类型吗?对,那直接返回这个值。
这个“对每个属性值重复同样的拷贝逻辑”,就是函数调用它自己。所以当你写下第一版递归深拷贝时,会发现代码几乎是在“翻译”那句话。
javascript复制function deepClone(source) {
// 叶子节点:基本类型,直接返回
if (source === null || typeof source !== 'object') {
return source;
}
// 分支节点:递归处理每个属性
const target = Array.isArray(source) ? [] : {};
for (let key in source) {
target[key] = deepClone(source[key]);
}
return target;
}
这段代码虽然短,但已经触及了递归深拷贝的骨架:先判断当前层的情况,再决定是直接返回还是深入递归。 后面所有复杂版本,都是在“分支节点”的处理上增加更多细分的类型判断,但骨架不变。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 亲手写一版基础版深拷贝,先跑通核心逻辑
在堆复杂类型之前,我建议你先亲手写一版尽可能简单的深拷贝,跑通基本场景,再一步步加边界处理。直接看完整版代码很容易被一堆 instanceof 分支吓到,忘了核心递归逻辑其实就二十行。
2.1 怎么处理对象和数组的分支
上面那段基础代码,有人可能会觉得 Array.isArray(source) 这个判断很多余:反正数组也是对象,直接 {} 不也一样吗?
不一样。数组和对象在 JavaScript 里的表现虽然相似,但在赋值属性时,数组有特殊的 length 属性和索引语义。如果拷贝时传入一个数组,你用 {} 来承接,拷贝出来的结果虽然属性都有,但它失去了数组的方法和 length 的自动维护能力,比如 push 就没了。
javascript复制const arr = [1, 2, 3];
const copy = deepClone(arr);
// 如果是 {} 承接,copy 是 {0: 1, 1: 2, 2: 3},没有 push、map、filter
所以在递归分支里,第一步要先判断这个节点是数组还是普通对象,用不同的容器来承接。这一步是所有递归深拷贝的共性:容器类型决定递归结果的结构。
2.2 为什么 for...in 会带出原型链上的属性
基础版本的 for (let key in source) 有一个隐藏问题:for...in 遍历的是“可枚举属性”,包括继承自原型链的属性。虽然常见的普通对象自身属性都能被遍历到,但如果你要拷贝的对象是用构造函数创建的、带着原型上的方法或属性,for...in 会把它们一并拷贝进去。
javascript复制function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function() { console.log('hi'); };
const p = ne
