先抛出我这几年的一个感受:凡是写过几轮业务代码的人,迟早会在某个深夜遇到一个诡异 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也会被转成null。- 嵌套过深时性能不佳。
所以它叫“JSON 安全的子集”的序列化,不叫通用深拷贝。业务里对“纯数据对象”用一用没问题,但一旦对象里包含非 JSON 类型,你就只能自己写或引库。
3. 手写一个能扛住实战的递归深拷贝
这一部分我按版本演进讲,每版解决一个具体缺陷,这样你不仅拿到最终代码,还能理解每一步为什么这么写。
3.1 第一版:先把普通对象和数组跑通
先写一个最小可用的版本:
javascript复制function deepClone(target) {
if (target === null || typeof target !== 'object') {
return target;
}
const result = Array.isArray(target) ? [] : {};
for (const key in target) {
result[key] = deepClone(target[key]);
}
return result;
}
这个版本能跑通普通对象和数组的嵌套拷贝。思路就是:遇到基本类型直接返回,遇到对象就新建一个容器(数组或普通对象),然后递归复制每个键。
但这里有两个明显的洞:
for...in会把原型链上的可枚举属性也遍历进来,这通常不是我们要的。- 完全没有考虑
Date、RegExp、Map等特殊对象。
所以这只是起步,还差得远。
3.2 类型判断:别用 typeof 搞定一切
你可能会说,typeof null 是 'object',typeof [] 也是 'object',typeof new Date() 还是 'object',那我怎么区分它们?
我的习惯是先用 Object.prototype.toString.call(target) 拿到统一格式的类型标签,比如 [object Date]、[object RegExp]、[object Map]、[object Set]、[object Array]。这种方式比 instanceof 更可靠,因为跨 iframe、跨 realm 的场景下 instanceof 会失效,而 Object.prototype.toString 返回的是内部属性 [[Symbol.toStringTag]] 的字符串,稳定性好很多。
javascript复制function getType(target) {
return Object.prototype.toString.call(target).slice(8, -1);
}
这个方法会返回 'Object'、'Array'、'Date'、'RegExp'、'Map'、'Set' 等字符串。有了它,你就能按照不同类型走不同的深拷贝逻辑。
3.3 第二版:补充 Date、RegExp、Map、Set、函数
不同内置类型的拷贝策略不一样:
- Date:
new Date(target.getTime())复制时间戳。 - RegExp:
new RegExp(target.source, target.flags)复制正则源码和修饰符。 - Map:遍历键值对,分别对 key 和 value 做深拷贝,然后重新
set进新 Map。 - Set:遍历每个值,对 value 做深拷贝后重新
add进新 Set。 - 函数:一般不需要深拷贝,直接返回同一个引用即可。函数属于“行为”而非“数据”,拷贝它没有实际意义。
对应代码如下:
javascript复制function deepClone(target, hash = new WeakMap()) {
if (target === null || typeof target !== 'object') {
return target;
}
// 函数直接返回引用
if (typeof target === 'function') {
return target;
}
const type = getType(target);
const isArray = type === 'Array';
// 单独处理非普通对象的类型
if (type === 'Date') {
return new Date(target.getTime());
}
if (type === 'RegExp') {
return new RegExp(target.source, target.flags);
}
if (type === 'Map') {
const result = new Map();
target.forEach((value, key) => {
result.set(deepClone(key, hash), deepClone(value, hash));
});
return result;
}
if (type === 'Set') {
const result = new Set();
target.forEach(value => {
result.add(deepClone(value, hash));
});
return result;
}
// 处理循环引用
if (hash.has(target)) {
return hash.get(target);
}
// 数组或者是普通对象
const result = isArray ? [] : {};
hash.set(target, result);
// 复制自身可枚举属性(不包含原型链)
for (const key in target) {
if (Object.prototype.hasOwnProperty.call(target, key)) {
result[key] = deepClone(target[key], hash);
}
}
return result;
}
这里我已经引入了 WeakMap 作为缓存来解决循环引用,具体原理下一节讲,先接着把类型补齐。
3.4 第三版:解决循环引用(WeakMap 是关键)
循环引用指的是对象的某个属性直接或间接地指向了自己:
javascript复制const obj = {};
obj.self = obj;
如果用最初的递归版本,deepClone(obj) 会无限递归下去,直到爆栈。解决方案很简单:用一个 WeakMap 记录“原对象 → 新对象”的映射,每次准备克隆一个对象前,先查一下这个对象是不是已经克隆过了。如果克隆过,直接返回之前克隆的结果。
注意这里一定要在 new 出新的容器后、继续遍历属性之前就写入 WeakMap,不能等整棵树克隆完再写。因为树里很可能有兄弟节点引用同一个先辈节点,你要保证它在后续访问时能立刻查到自己对应的拷贝。代码里我放在遍历前:
javascript复制hash.set(target, result);
这样当递归遇到 obj.self 时,hash.has(target) 为 true,直接返回之前创建好的 result,循环引用就被切断了。
WeakMap 和 Map 的主要区别在于:WeakMap 的键是弱引用,不会阻止垃圾回收。在一些长期运行的应用里,用 Map 存拷贝缓存容易造成内存泄漏,因为源对象再也不用了,Map 却还拽着它不放。虽然拷贝函数执行完缓存理论上会被清理,但用 WeakMap 是更稳妥的专业习惯。
3.5 第四版:把 Symbol 键和非枚举属性也照顾到
for...in 只能遍历可枚举属性,而且会忽略 Symbol 键。很多隐藏的坑就出在这里,比如用 Symbol 做内部标识的对象,直接用 for...in 拷贝会把标识丢掉。
解决方法是分两步取键:
Object.keys(target):取自身可枚举字符串键。Object.getOwnPropertySymbols(target):取自身所有 Symbol 键。
然后在遍历时合并这两个数组,对每个键递归拷贝。我写一个辅助函数:
javascript复制function copyOwnProperties(target, result, hash) {
const keys = [
...Object.keys(target),
...Object.getOwnPropertySymbols(target)
];
for (const key of keys) {
result[key] = deepClone(target[key], hash);
}
}
如果你还想把那些“不可枚举的属性”也一并拷走,就需要用 Object.getOwnPropertyDescriptors(target) 拿到所有属性描述符,再配合 Object.defineProperties(result, descriptors) 创建新属性。这样连 enumerable: false 的属性也不会丢。不过普通业务场景不需要这么极致,面试的时候提一句“我知道用 Object.getOwnPropertyDescriptors 可以做到”就够了。
3.6 第五版:保留原型链
普通 {} 创建的新对象,原型指向 Object.prototype,而源对象如果是一个类的实例,比如:
javascript复制class User {
constructor(name) {
this.name = name;
}
greet() {
return `Hello, ${this.name}`;
}
}
const user = new User('张三');
const cloneUser = deepClone(user);
如果直接用 {} 接收拷贝结果,cloneUser.greet 就不存在,因为原型链丢失了。
解决办法是创建新对象时保留源对象的原型:
javascript复制const result = isArray ? [] : Object.create(Object.getPrototypeOf(target));
Object.create(Object.getPrototypeOf(target)) 会创建一个以源对象原型为原型的新对象,这样实例方法就能被继承了。这一步非常关键,很多人手写深拷贝时忽略,导致用起来的时候才发现对象“变老实了”,方法全没了。
3.7 最终版:合并所有能力
把前面几版的能力合并,就是一套比较完整的递归深拷贝实现:
javascript复制function getType(target) {
return Object.prototype.toString.call(target).slice(8, -1);
}
function deepClone(target, hash = new WeakMap()) {
if (target === null || typeof target !== 'object') {
return target;
}
if (typeof target === 'function') {
return target;
}
const type = getType(target);
if (type === 'Date') {
return new Date(target.getTime());
}
if (type === 'RegExp') {
return new RegExp(target.source, target.flags);
}
if (type === 'Map') {
const result = new Map();
target.forEach((value, key) => {
result.set(deepClone(key, hash), deepClone(value, hash));
});
return result;
}
if (type === 'Set') {
const result = new Set();
target.forEach(value => {
result.add(deepClone(value, hash));
});
return result;
}
if (hash.has(target)) {
return hash.get(target);
}
const isArray = type === 'Array';
const result = isArray
? []
: Object.create(Object.getPrototypeOf(target));
hash.set(target, result);
const keys = [
...Object.keys(target),
...Object.getOwnPropertySymbols(target)
];
for (const key of keys) {
result[key] = deepClone(target[key], hash);
}
return result;
}
const clone = deepClone(source);
到这一步,你已经拥有一个能处理常见数据类型的深拷贝工具函数。性能上虽然比不过 lodash.cloneDeep 这种专门优化过的库,但逻辑清晰、可定制性强,读代码的人一眼就能看懂每一层在干嘛。
4. 实操过程与核心环节实现
4.1 准备测试数据,别拿空对象糊弄
手写函数之后,最重要的事情不是急着上线,而是先准备一组覆盖各种类型的数据,把所有分支跑一遍。我常用的测试数据长这样:
javascript复制const source = {
name: 'project-alpha',
version: '1.0.0',
author: {
name: '张三',
tags: ['前端', 'Node.js'],
address: {
city: '杭州',
zip: '310000'
}
},
createdAt: new Date(),
pattern: /ab+c/gi,
configMap: new Map([
['theme', { color: '#333' }],
['cache', true]
]),
tagSet: new Set(['a', 'b', { nested: true }]),
symbolKey: Symbol('symbol-key'),
[Symbol('desc')]: 'symbol value',
circular: null
};
source.circular = source;
这份数据里包含普通嵌套对象、数组、Date、RegExp、Map、Set、Symbol 键、循环引用,几乎把能踩的坑都覆盖了。
4.2 验证拷贝结果:改一处,原对象不能动
测试逻辑分为两步。第一步,克隆;第二步,修改克隆后的深层字段和特殊类型值,断言原对象完全不受影响。
javascript复制const cloned = deepClone(source);
cloned.author.address.city = '深圳';
cloned.configMap.get('theme').color = '#000';
cloned.tagSet.forEach(item => {
if (item && typeof item === 'object') {
item.nested = false;
}
});
console.log(source.author.address.city); // '杭州'
console.log(source.configMap.get('theme').color); // '#333'
console.log([...source.tagSet][2].nested); // true
如果所有断言都通过,说明普通嵌套对象、Map 里的对象值、Set 里的对象值都没有发生引用共享。
4.3 验证循环引用和类型保留
javascript复制console.log(cloned.circular === cloned); // true,循环引用正确指向自身
console.log(cloned.createdAt instanceof Date); // true
console.log(cloned.createdAt === source.createdAt); // false,日期被重建
console.log(cloned.pattern instanceof RegExp); // true
能输出预期结果,说明循环引用没有被递归成栈溢出,特殊类型也都正确还原了。
4.4 关于性能:递归深拷贝的瓶颈在哪
递归深拷贝的时间复杂度是 O(n),n 是节点总数,这个无法避免。但你可以注意几个常数优化:
- 避免频繁使用
Object.prototype.toString.call这种耗 CPU 的调用。虽然现代引擎优化得不错,但在超级大对象上,每次进入递归都做一次类型标签解析是有开销的。性能敏感场景可以用target.constructor加instanceof组合判断,更快但边界情况略多。 - 尽量少在递归中创建多余闭包和临时数组,比如
[...Object.keys(target), ...Object.getOwnPropertySymbols(target)]这个数组在深度很大的情况下会有大量临时分配。如果真的要对超大对象做拷贝,建议先Object.keys拿到数组再遍历,Symbol 键单独处理,避免频繁展开。
这里想提醒一句:不要为了“炫技”把所有边缘特性全上。如果你的业务数据就是后端返回的 JSON,第一版就够用了;如果你的业务里有类实例、有循环引用,才需要考虑完整版。方案永远服务于场景。
5. 常见问题与排查技巧实录
5.1 问题一:Maximum call stack size exceeded
这是最常见的报错,原因通常有三种:
- 对象存在循环引用,而你的递归函数没有做已访问对象缓存。
- 对象嵌套层级确实太深(比如几万层),递归调用栈超过了引擎上限。
- 基线条件缺失或写错,导致递归永不终止。
排查顺序:先查有没有循环引用,再看数据类型是不是漏了,最后确认嵌套深度是否在合理范围内。如果业务中确实可能出现极深嵌套,递归方案就力不从心了,可以考虑用栈 + 队列改成迭代式深拷贝,本质上仍是深度优先遍历,只是不再占调用栈。
5.2 问题二:Date 拷贝完变成字符串
出现这个现象,十有八九是用了 JSON 方案,或者手写函数里没有对 Date 做单独处理。用 new Date(target) 直接重建也可以,但我更推荐 new Date(target.getTime()),语义更明确,而且能避免某些环境的字符串解析歧义。
5.3 问题三:拷贝后“相等”判断失败,原来是原型链丢了
如果你发现 clone instanceof User 是 false,说明你的深拷贝函数创建新对象时用 {} [] 了。解决办法就是前面说的 Object.create(Object.getPrototypeOf(target))。
这个方法对“纯数据对象”也有一个额外好处:如果源对象是通过 Object.create(null) 创建的无原型对象,拷贝后也能保持无原型,不会意外获得 toString 等方法。
5.4 问题四:修改克隆对象,原对象还是变了
这种问题通常出在特殊类型上,尤其是 Map 和 Set。注意在实现时,Map 的 key 和 value 都要递归克隆,Set 的 value 也要递归克隆。只克隆容器本身、不克隆容器内的对象值,就会出现“改了克隆体里的嵌套对象,原对象跟着变”的情况。
还有一个隐蔽点:weakMap 缓存里存的是同一个引用。如果原对象有两个属性都引用了同一个子对象,克隆后这两个属性应该指向同一个“克隆后的子对象”,而不是各自复制一份。这一点用 WeakMap 缓存后自然就满足了,不需要额外处理。但如果哪次重构把 hash 获取写错了位置,就会导致同一对象被克隆了两份,进而让“共享引用”的语义丢失。
5.5 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
报错 Converting circular structure to JSON |
对象循环引用,且用了 JSON 方案 | 改用递归方案并在递归中加入 WeakMap 缓存 |
| 修改深拷贝结果原对象跟着变 | Map/Set/嵌套对象内部未递归克隆 | 对 Map 的 key/value、Set 的每个值都做递归 deepClone |
| Date 变成字符串 | 用了 JSON 序列化 | 对 Date 单独处理,new Date(target.getTime()) |
| 类实例拷贝后方法丢失 | 没保留原型链 | 创建结果时用 Object.create(Object.getPrototypeOf(target)) |
| Symbol 键丢失 | 只用 Object.keys 或 for...in |
额外用 Object.getOwnPropertySymbols 取 Symbol 键 |
| 大对象拷贝慢 | 递归中大量临时对象分配 | 减少类型标签调用,按业务场景裁剪功能 |
6. 现实中怎么选:手写 vs lodash vs structuredClone
6.1 structuredClone:官方原生方案
structuredClone 是浏览器和 Node.js 后来提供的原生深拷贝 API:
javascript复制const clone = structuredClone(source);
它能处理绝大多数内置类型,包括 Date、RegExp、Map、Set、ArrayBuffer,也支持循环引用。但有两类东西它会抛错:函数和 DOM 节点。另外它属于“结构化克隆算法”,和普通深拷贝的语义存在差异,比如 Error 对象的行为在不同引擎里不完全一致。
如果你的运行环境是 Node 17+ 或现代浏览器,业务数据又以纯数据为主,structuredClone 是最省事、性能也最好的选择。手写函数不是用来和它正面竞争的,而是帮你理解原理,以及应对那些结构化克隆不支持的场景。
6.2 lodash.cloneDeep 内部是怎么做的
lodash.cloneDeep 内部维护了一个 Stack 对象来做循环引用检测,然后用 baseClone 递归处理各种类型。它比我们手写的版本多了大量边界处理,比如:
- 对
Buffer、TypedArray、DataView等更底层的二进制数据有专门支持。 - 对
arguments对象、Error、Promise等也有处理策略。 - 对数组的“稀疏空洞”也做了保留,而不是简单变成
undefined。
所以如果你的项目里已经引了 lodash,直接用 .cloneDeep 是最稳的。纯手写主要是面试、学习、和“不想为一个函数引一整个库”的场景。
6.3 我的选型建议
拿我自己的项目习惯来说:
- 写 demo、抓数据做本地测试:用
JSON.parse(JSON.stringify(data))快速处理,前提是确认数据里没有特殊类型。 - 业务复杂对象,团队已经用 lodash:直接用
_.cloneDeep。 - 环境支持
structuredClone,数据不包含函数/DOM:优先用原生的,性能最好。 - 面试或学习场景:必须能手写递归版本,并讲清楚每一步的取舍。
- 需要深度定制拷贝行为(比如只拷白名单字段、字段改名、跳过某些复杂类型):手写递归并按需调整。
手写递归深拷贝的意义不在于取代现成工具,而是让你在遇到“工具处理不了”的角落时,有底气和能力去改出一个符合自己场景的版本。
最后再分享一个我实际踩过的坑:早期我写深拷贝时,只在普通对象分支里处理了循环引用,Map 分支却忘了传 hash,结果遇到 map.set(map, 'self') 照样爆栈。后来我把所有需要递归克隆的分支都强制带上 hash,才彻底堵住这个漏洞。所以你在扩展深拷贝函数时,每加一种类型,都要在它内部递归调用的地方把 hash 透传下去。这是细节中的细节,但恰恰是生产环境最容易翻车的位置。
