"能手写一个深拷贝吗?"
我立马想到的是 JSON.parse(JSON.stringify(obj))。
但如果进一步追问:"如果对象有循环引用呢?如果有 Date、RegExp 呢?" 我才意识到,深拷贝远比我想象的复杂。
这篇文章是我重新梳理深拷贝的学习笔记,从最简单的递归开始,一步步处理各种边界情况。
引子:浅拷贝带来的 Bug
先来看一个真实场景下容易踩的坑:
javascript
// 环境:浏览器 / Node.js
// 场景:用户信息编辑
const originalUser = {
name: 'Alice',
profile: {
age: 25,
city: 'Shanghai'
}
};
// 使用展开运算符"复制"对象
const editedUser = { ...originalUser };
// 修改嵌套属性
editedUser.profile.city = 'Beijing';
console.log(originalUser.profile.city); // "Beijing" - 糟糕,原对象也被修改了!
console.log(editedUser.profile.city); // "Beijing"
这个 Bug 的根源在于:展开运算符和 Object.assign 都只做浅拷贝。
它们只拷贝对象的第一层属性。
对于嵌套的对象,拷贝的只是 引用 。
引用类型 vs 基本类型的内存模型
要理解这个问题,需要先理解 JavaScript 的内存模型:
javascript
// 基本类型:直接存储值
let a = 10;
let b = a; // 复制值
b = 20;
console.log(a); // 10 - a 不受影响
// 引用类型:存储的是内存地址
let obj1 = { count: 10 };
let obj2 = obj1; // 复制的是地址
obj2.count = 20;
console.log(obj1.count); // 20 - obj1 也被修改了,因为它们指向 **同一块内存**
浅拷贝只拷贝了第一层的引用,深拷贝则需要递归地复制所有嵌套层级,创建 全新的对象 。
深拷贝的核心挑战
实现深拷贝主要面临这几个挑战:
1. 递归思维:处理嵌套结构
深拷贝的核心是递归:
- 如果遇到对象,就递归地拷贝它的每个属性;
- 如果遇到基本类型,直接复制。
2. 循环引用检测:避免爆栈
如果对象存在循环引用,朴素的递归会导致无限循环:
javascript
// 环境:浏览器 / Node.js
// 场景:循环引用的对象
const obj = { name: 'Alice' };
obj.self = obj; // 循环引用自己
// 如果直接递归拷贝,会发生什么?
// deepClone(obj) -> deepClone(obj.self) -> deepClone(obj.self.self) -> ...
// 无限递归,最终栈溢出!
3. 类型判断:不同类型需要不同处理
JavaScript 的引用类型五花八门:Object、Array、Date、RegExp、Map、Set、Function... 每种类型的拷贝方式都不同。
4. 特殊值处理:null、undefined、Symbol
这些特殊值需要特别小心处理,容易成为 Bug 的源头。
从简单到完整的实现路径
让我们从最简单的版本开始,逐步完善。
版本 1:JSON 方法的局限性
最快速的深拷贝方式是使用 JSON:
javascript
// 环境:浏览器 / Node.js
// 场景:JSON 深拷贝
const obj = {
name: 'Alice',
profile: {
age: 25,
hobbies: ['reading', 'coding']
}
};
const cloned = JSON.parse(JSON.stringify(obj));
cloned.profile.age = 30;
console.log(obj.profile.age); // 25 - 原对象未改变 ✅
console.log(cloned.profile.age); // 30
但这个方法有严重的局限性:
javascript
// 环境:浏览器 / Node.js
// 场景:JSON 方法的各种问题
const obj = {
date: new Date(),
regex: /test/g,
func: () => console.log('hello'),
undef: undefined,
symbol: Symbol('key'),
nan: NaN,
infinity: Infinity
};
obj.self = obj; // 循环引用
// ❌ 会抛出错误:TypeError: Converting circular structure to JSON
// const cloned = JSON.parse(JSON.stringify(obj));
// 即使没有循环引用,也会丢失很多信息:
const safeObj = {
date: new Date(),
regex: /test/g,
func: () => console.log('hello'),
undef: undefined,
nan: NaN
};
const cloned = JSON.parse(JSON.stringify(safeObj));
console.log(cloned);
// {
// date: "2024-03-14T10:30:00.000Z", // 变成了字符串!
// regex: {}, // 变成了空对象!
// nan: null // NaN 变成了 null!
// // func 和 undef 直接消失了!
// }
JSON 方法的问题总结:
- ❌ 无法处理循环引用(会报错)
- ❌ 函数会丢失
- ❌ undefined 会丢失
- ❌ Symbol 会丢失
- ❌ Date 变成字符串
- ❌ RegExp 变成空对象
- ❌ NaN/Infinity 变成 null
所以,JSON 方法只适用于简单的、纯数据的对象。
版本 2:基础递归实现
javascript
// 环境:浏览器 / Node.js
// 场景:基础深拷贝,处理 object 和 array
function deepClone(target) {
// 基本类型直接返回
if (typeof target !== 'object' || target === null) {
return target;
}
// 区分数组和对象
const cloneTarget = Array.isArray(target) ? [] : {};
// 递归拷贝每个属性
for (let key in target) {
if (target.hasOwnProperty(key)) { // 只拷贝自身属性,不拷贝原型链
cloneTarget[key] = deepClone(target[key]);
}
}
return cloneTarget;
}
// 测试
const obj = {
name: 'Alice',
age: 25,
hobbies: ['reading', 'coding'],
profile: {
city: 'Shanghai',
education: {
degree: 'Bachelor',
school: 'MIT'
}
}
};
const cloned = deepClone(obj);
cloned.profile.city = 'Beijing';
console.log(obj.profile.city); // "Shanghai" - 原对象未改变 ✅
console.log(cloned.profile.city); // "Beijing"
这个版本已经能处理基本的嵌套对象和数组了,但还有两个致命问题:
- 无法处理循环引用
- 无法处理特殊类型(Date、RegExp 等)
版本 3:使用 WeakMap 解决循环引用
循环引用的核心思路是:记录已经拷贝过的对象,如果再次遇到,直接返回之前的拷贝结果。
javascript
// 环境:浏览器 / Node.js
// 场景:处理循环引用
function deepClone(target, map = new WeakMap()) {
// 基本类型直接返回
if (typeof target !== 'object' || target === null) {
return target;
}
// 检查是否已经拷贝过
if (map.has(target)) {
return map.get(target); // 返回之前的拷贝结果,避免无限递归
}
// 区分数组和对象
const cloneTarget = Array.isArray(target) ? [] : {};
// 记录当前对象的拷贝结果
map.set(target, cloneTarget);
// 递归拷贝每个属性
for (let key in target) {
if (target.hasOwnProperty(key)) {
cloneTarget[key] = deepClone(target[key], map);
}
}
return cloneTarget;
}
// 测试循环引用
const obj = { name: 'Alice' };
obj.self = obj; // 指向自己
obj.nested = { parent: obj }; // 嵌套的循环引用
const cloned = deepClone(obj);
console.log(cloned.self === cloned); // true ✅
console.log(cloned.nested.parent === cloned); // true ✅
console.log(cloned !== obj); // true - 是新对象
为什么用 WeakMap 而不是 Map?
javascript
// WeakMap vs Map 的区别
// Map:强引用,即使原对象被销毁,Map 中的引用仍然存在
const map = new Map();
let obj = { data: 'large object' };
map.set(obj, 'value');
obj = null; // obj 被销毁,但 map 中的引用仍然存在,造成内存泄漏
// WeakMap:弱引用,原对象销毁后,WeakMap 中的引用会自动清除
const weakMap = new WeakMap();
let obj2 = { data: 'large object' };
weakMap.set(obj2, 'value');
obj2 = null; // obj2 被销毁,weakMap 中的引用也会被垃圾回收
在深拷贝的场景中,map 只是临时用来检测循环引用的,拷贝完成后就不需要了。使用 WeakMap 可以让垃圾回收器自动清理,避免内存泄漏。
版本 4:处理特殊类型
现在我们来处理 Date、RegExp、Map、Set 等特殊类型:
javascript
// 环境:浏览器 / Node.js
// 场景:处理各种特殊类型
function deepClone(target, map = new WeakMap()) {
// 基本类型直接返回
if (typeof target !== 'object' || target === null) {
return target;
}
// 检查循环引用
if (map.has(target)) {
return map.get(target);
}
// 获取对象的具体类型
const type = Object.prototype.toString.call(target);
let cloneTarget;
// 根据类型选择拷贝策略
switch (type) {
case '[object Array]':
cloneTarget = [];
break;
case '[object Object]':
cloneTarget = {};
break;
case '[object Date]':
return new Date(target); // Date 直接返回,不需要递归
case '[object RegExp]':
// 拷贝 RegExp 需要保留 flags
return new RegExp(target.source, target.flags);
case '[object Map]':
cloneTarget = new Map();
map.set(target, cloneTarget);
target.forEach((value, key) => {
cloneTarget.set(key, deepClone(value, map));
});
return cloneTarget;
case '[object Set]':
cloneTarget = new Set();
map.set(target, cloneTarget);
target.forEach(value => {
cloneTarget.add(deepClone(value, map));
});
return cloneTarget;
default:
// 其他类型(Function, Symbol 等)直接返回
return target;
}
// 记录当前对象
map.set(target, cloneTarget);
// 递归拷贝属性
for (let key in target) {
if (target.hasOwnProperty(key)) {
cloneTarget[key] = deepClone(target[key], map);
}
}
return cloneTarget;
}
// 测试特殊类型
const obj = {
date: new Date('2024-03-14'),
regex: /test/gi,
map: new Map([['key1', 'value1'], ['key2', { nested: true }]]),
set: new Set([1, 2, { value: 3 }]),
arr: [1, 2, 3]
};
const cloned = deepClone(obj);
console.log(cloned.date instanceof Date); // true ✅
console.log(cloned.date.getTime() === obj.date.getTime()); // true ✅
console.log(cloned.regex.source === obj.regex.source); // true ✅
console.log(cloned.regex.flags === obj.regex.flags); // true ✅
console.log(cloned.map.get('key2') !== obj.map.get('key2')); // true - 深拷贝 ✅
console.log(cloned !== obj); // true - 新对象 ✅
类型检测的关键:Object.prototype.toString
为什么要用 Object.prototype.toString.call(target) 而不是 typeof?
javascript
// 环境:浏览器 / Node.js
// 场景:类型检测对比
const arr = [1, 2, 3];
const date = new Date();
const regex = /test/;
// typeof 无法区分对象类型
console.log(typeof arr); // "object"
console.log(typeof date); // "object"
console.log(typeof regex); // "object"
// Object.prototype.toString 可以精确区分
console.log(Object.prototype.toString.call(arr)); // "[object Array]"
console.log(Object.prototype.toString.call(date)); // "[object Date]"
console.log(Object.prototype.toString.call(regex)); // "[object RegExp]"
console.log(Object.prototype.toString.call(null)); // "[object Null]"
这是判断 JavaScript 类型最可靠的方式。
边界情况与陷阱
在实际使用中,还有一些容易忽略的边界情况。
1. Function 能深拷贝吗?
函数的拷贝比较特殊。一种观点是:函数不应该被拷贝,因为函数通常依赖外部作用域,拷贝后可能失去原有的上下文。
javascript
// 环境:浏览器 / Node.js
// 场景:函数的拷贝问题
const obj = {
count: 0,
increment: function() {
this.count++; // 依赖 this 上下文
}
};
// 如果拷贝函数,this 指向会改变吗?
const cloned = deepClone(obj);
cloned.increment();
console.log(cloned.count); // 1 - this 指向 cloned,正常工作 ✅
在我们的实现中,函数直接返回原引用,这是一种常见的做法。如果真的需要拷贝函数,可以用 new Function 或 eval,但通常不推荐。
2. Symbol 作为 key 的处理
对象的 key 可以是 Symbol,而 for...in 和 Object.keys 都无法遍历 Symbol 属性:
javascript
// 环境:浏览器 / Node.js
// 场景:Symbol 属性的拷贝
function deepCloneWithSymbol(target, map = new WeakMap()) {
if (typeof target !== 'object' || target === null) {
return target;
}
if (map.has(target)) {
return map.get(target);
}
const cloneTarget = Array.isArray(target) ? [] : {};
map.set(target, cloneTarget);
// 拷贝普通属性
for (let key in target) {
if (target.hasOwnProperty(key)) {
cloneTarget[key] = deepCloneWithSymbol(target[key], map);
}
}
// 拷贝 Symbol 属性
const symbolKeys = Object.getOwnPropertySymbols(target);
for (let key of symbolKeys) {
cloneTarget[key] = deepCloneWithSymbol(target[key], map);
}
return cloneTarget;
}
// 测试
const sym = Symbol('key');
const obj = {
normal: 'value',
[sym]: 'symbol value'
};
const cloned = deepCloneWithSymbol(obj);
console.log(cloned[sym]); // "symbol value" ✅
3. 不可枚举属性的处理
默认情况下,for...in 只遍历可枚举属性。如果需要拷贝不可枚举属性,要用 Object.getOwnPropertyNames:
javascript
// 环境:浏览器 / Node.js
// 场景:不可枚举属性
const obj = {};
Object.defineProperty(obj, 'hidden', {
value: 'secret',
enumerable: false // 不可枚举
});
console.log(obj.hidden); // "secret"
// for...in 无法遍历
for (let key in obj) {
console.log(key); // 不会打印 "hidden"
}
// 使用 Object.getOwnPropertyNames
const allKeys = Object.getOwnPropertyNames(obj);
console.log(allKeys); // ["hidden"]
不过在大多数场景下,不可枚举属性通常是内部属性,不需要拷贝。
4. getter/setter 的处理
如果对象的属性定义了 getter/setter,直接拷贝会丢失这些访问器:
javascript
// 环境:浏览器 / Node.js
// 场景:getter/setter 的拷贝
const obj = {
_age: 25,
get age() {
console.log('Getting age');
return this._age;
},
set age(value) {
console.log('Setting age');
this._age = value;
}
};
// 普通拷贝会丢失 getter/setter
const cloned = deepClone(obj);
cloned.age = 30; // 不会触发 setter
console.log(cloned.age); // 不会触发 getter
// 正确的做法:使用 Object.getOwnPropertyDescriptors
const correctCloned = Object.create(
Object.getPrototypeOf(obj),
Object.getOwnPropertyDescriptors(obj)
);
这涉及到属性描述符(Property Descriptor)的概念,在深拷贝的复杂场景中需要考虑。
手写实现的关键点
面试时手写深拷贝,这些点是考察重点:
1. 递归终止条件
javascript
// ✅ 正确:基本类型和 null 都要终止递归
if (typeof target !== 'object' || target === null) {
return target;
}
// ❌ 错误:忘记检查 null
if (typeof target !== 'object') {
return target;
}
// typeof null === 'object',会继续递归,导致报错!
这是很容易忽略的细节,因为 typeof null === 'object' 是 JavaScript 的一个历史遗留问题。
2. 循环引用的检测时机
javascript
// ✅ 正确:在创建新对象前检查
if (map.has(target)) {
return map.get(target);
}
const cloneTarget = Array.isArray(target) ? [] : {};
map.set(target, cloneTarget);
// ❌ 错误:在递归拷贝后才记录
const cloneTarget = Array.isArray(target) ? [] : {};
for (let key in target) {
cloneTarget[key] = deepClone(target[key], map); // 如果这里遇到循环引用,已经晚了
}
map.set(target, cloneTarget);
必须在递归前就记录,否则遇到循环引用时,还是会无限递归。
3. WeakMap 的作用
- WeakMap 的 key 必须是对象(符合我们的需求)
- WeakMap 是弱引用,不会阻止垃圾回收
- 拷贝完成后,map 会被自动清理,避免内存泄漏
4. 数组和对象的区分
javascript
// ✅ 推荐:使用 Array.isArray
const cloneTarget = Array.isArray(target) ? [] : {};
// ⚠️ 可用但不够优雅:使用 instanceof
const cloneTarget = target instanceof Array ? [] : {};
// ❌ 不推荐:使用 constructor
const cloneTarget = target.constructor === Array ? [] : {};
Array.isArray 是最可靠的方式,即使在不同 iframe 环境下也能正常工作。
性能与工程实践
在实际项目中,深拷贝的性能也是需要考虑的。
lodash 的 cloneDeep 实现思路
lodash 的 cloneDeep 是工业级实现的参考,它的核心思路:
- 使用栈(stack)代替递归,避免爆栈
- 缓存已拷贝对象(类似我们的 WeakMap)
- 针对不同类型使用优化的拷贝策略
- 处理大量边界情况(原型链、属性描述符等)
javascript
// 环境:Node.js / 浏览器(需要引入 lodash)
// 场景:使用 lodash 的 cloneDeep
import _ from 'lodash';
const obj = {
date: new Date(),
func: () => console.log('hello'),
symbol: Symbol('key')
};
obj.self = obj;
const cloned = _.cloneDeep(obj);
console.log(cloned.self === cloned); // true ✅
什么时候不需要深拷贝(Immutable 思想)
在 React 等现代框架中,推荐使用不可变数据(Immutable Data):
javascript
// 环境:React
// 场景:不可变更新,替代深拷贝
// ❌ 不推荐:深拷贝整个对象
const newState = deepClone(state);
newState.user.name = 'Bob';
// ✅ 推荐:只拷贝需要修改的部分
const newState = {
...state,
user: {
...state.user,
name: 'Bob'
}
};
这种方式更高效,也更符合函数式编程的思想。深拷贝应该只在确实需要"完全独立的副本"时使用。
一些有关 deepClone 的追问
Q1: 深拷贝和浅拷贝的区别?
我的理解:
- 浅拷贝只复制第一层属性,嵌套对象复制的是引用
- 深拷贝递归复制所有层级,创建完全独立的副本
- 浅拷贝: 展开运算符、Object.assign
- 深拷贝: 递归实现、JSON 方法(有限制)、structuredClone
Q2: 如何检测循环引用?
使用 Map 或 WeakMap 记录已经拷贝过的对象。如果再次遇到,直接返回之前的拷贝结果,而不是继续递归。
Q3: 为什么不推荐用 JSON 方法做深拷贝?
JSON 方法的限制太多:
- 无法处理循环引用(报错)
- 函数、undefined、Symbol 会丢失
- Date 变成字符串
- RegExp 变成空对象
- NaN/Infinity 变成 null
只适用于纯数据对象。
Q4: 在 React 状态管理中的最佳实践?
不推荐深拷贝整个 state,而是:
- 使用展开运算符做浅拷贝
- 只拷贝需要修改的部分
- 保持数据不可变(Immutable)
- 考虑使用 Immer 等库简化不可变更新
小结
深拷贝看似简单,实际上涉及递归、类型判断、循环引用检测、内存管理等多个知识点。手写深拷贝的核心是:
- 递归思维: 基本类型直接返回,对象类型递归拷贝
- 循环引用检测: 使用 WeakMap 记录已拷贝对象
- 类型判断 : 用
Object.prototype.toString精确区分类型 - 特殊类型处理: Date、RegExp、Map、Set 需要特殊构造
面试时,除了能写出代码,更重要的是能解释清楚:
- 为什么要用 WeakMap?(弱引用,避免内存泄漏)
- 为什么要先记录再递归?(避免循环引用)
- 如何区分数组和对象?(Array.isArray)
- 什么情况下不需要深拷贝?(不可变数据更新)
这篇文章是我准备面试时的思考过程,可能有理解不到位的地方。实际项目中,我会使用 lodash 的 cloneDeep,而不是手写实现。但理解原理对于调试问题和优化性能仍然很重要。
参考资料
- MDN - structuredClone() - 原生深拷贝 API
- Lodash cloneDeep 源码 - 工业级实现参考
- MDN - WeakMap - 弱引用 Map
- JavaScript 数据类型和数据结构 - 理解引用类型
- Immer 文档 - 不可变数据更新库