前言
在前端开发中,对象拷贝是一个高频话题。面试时经常被问到:
"请手写一个深拷贝,并处理循环引用的情况"
今天我们从浅拷贝的问题出发,逐步实现一个完整的深拷贝函数,彻底搞懂这个问题。
一、浅拷贝 vs 深拷贝:为什么要深拷贝?
1.1 引用式拷贝的问题
js
// 1.js
const users = [
{ id: 1, name: "莫某", hometown: "南昌" },
{ id: 2, name: "张三", hometown: "南昌" },
{ id: 3, name: "李四", hometown: "南昌" }
]
// 引用式拷贝:data 和 users 指向同一个堆内存地址
const data = users;
// 修改 data,users 也会被修改
data[0].hobbies = ["篮球", "看烟花"];
console.log(data, users);
// 两个数组都包含了 hobbies 属性!
逐行解析:
js
const users = [...]
users是一个数组,存储在堆内存中- 栈内存中只保存数组的引用地址
js
const data = users;
- 这是引用式拷贝,不是真正的拷贝
data和users指向同一个堆内存地址- 修改
data就是修改users
js
data[0].hobbies = ["篮球", "看烟花"];
- 修改
data[0],实际上修改的是堆内存中的对象 users[0]也会被修改,因为它们指向同一个对象
内存模型:
bash
栈内存 堆内存
┌─────────┐ ┌─────────────────┐
│ users │────>│ [{id:1,...}, │
└─────────┘ │ {id:2,...}, │
┌─────────┐ │ {id:3,...}] │
│ data │────>│ (同一个地址) │
└─────────┘ └─────────────────┘
1.2 浅拷贝的实现
浅拷贝只拷贝第一层,嵌套对象仍然是引用:
js
// 浅拷贝实现
function shallowClone(obj) {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
const clone = Array.isArray(obj) ? [] : {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = obj[key]; // 只拷贝第一层
}
}
return clone;
}
// 测试
const original = {
name: '莫某',
address: { city: '南昌' }
};
const copy = shallowClone(original);
copy.name = '张三'; // 不影响 original
copy.address.city = '北京'; // original.address.city 也变成 '北京'!
console.log(original.address.city); // '北京'
浅拷贝的局限:
- 只拷贝第一层属性
- 嵌套对象仍然是引用,修改会影响原对象
- 无法处理 Date、RegExp 等特殊对象
1.3 深拷贝的需求
深拷贝需要:
- 完全独立的副本:修改新对象不影响原对象
- 递归拷贝:嵌套对象也要拷贝
- 处理特殊类型:Date、RegExp、Map、Set 等
- 处理循环引用:对象引用自己,不能无限递归
二、JSON 方案:简单但有缺陷
2.1 JSON.parse + JSON.stringify
js
// 2.js
var users = [
{ id: 1, name: "莫某", hometown: "南昌" },
{ id: 2, name: "张三", hometown: "南昌" },
{ id: 3, name: "李四", hometown: "南昌" }
];
// JSON 序列化 + 反序列化
var data = JSON.parse(JSON.stringify(users));
data[0].hobbies = ["篮球", "看烟花"];
console.log(data, users);
// data 有 hobbies,users 没有!
逐行解析:
js
JSON.stringify(users)
- 将
users序列化成 JSON 字符串 - 这是一个全新的字符串,与原对象无关
js
JSON.parse(...)
- 将 JSON 字符串解析成新的 JavaScript 对象
- 在堆内存中申请新的空间
js
var data = JSON.parse(JSON.stringify(users));
data和users指向不同的堆内存地址- 修改
data不会影响users
2.2 JSON 方案的缺陷
缺陷 1:无法处理函数
js
const obj = {
name: '莫某',
sayHello: function() {
console.log('Hello');
}
};
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy.sayHello); // undefined,函数丢失了!
缺陷 2:无法处理 undefined
js
const obj = {
name: '莫某',
age: undefined
};
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy.age); // undefined,但属性不存在!
console.log('age' in copy); // false
缺陷 3:无法处理 Symbol
js
const obj = {
name: '莫某',
[Symbol('id')]: 123
};
const copy = JSON.parse(JSON.stringify(obj));
console.log(Object.getOwnPropertySymbols(copy)); // [],Symbol 丢失!
缺陷 4:无法处理循环引用
js
const obj = { name: '莫某' };
obj.self = obj; // 循环引用
const copy = JSON.parse(JSON.stringify(obj));
// TypeError: Converting circular structure to JSON
缺陷 5:Date 变成字符串
js
const obj = {
name: '莫某',
birthday: new Date('1990-01-01')
};
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy.birthday); // "1990-01-01T00:00:00.000Z"(字符串)
console.log(copy.birthday instanceof Date); // false
缺陷 6:RegExp 变成空对象
js
const obj = {
name: '莫某',
pattern: /hello/gi
};
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy.pattern); // {},正则表达式丢失!
JSON 方案对比:
| 数据类型 | JSON 方案 | 深拷贝期望 |
|---|---|---|
| 基本类型 | ✅ 正确 | ✅ 正确 |
| 嵌套对象 | ✅ 正确 | ✅ 正确 |
| 函数 | ❌ 丢失 | ✅ 保留或忽略 |
| undefined | ❌ 丢失 | ✅ 保留 |
| Symbol | ❌ 丢失 | ✅ 保留 |
| 循环引用 | ❌ 报错 | ✅ 正确处理 |
| Date | ❌ 变字符串 | ✅ 保持 Date |
| RegExp | ❌ 变空对象 | ✅ 保持 RegExp |
三、递归实现深拷贝
3.1 基础版本
js
function deepClone(obj) {
// 1. 处理基本类型
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 2. 创建空对象或数组
const clone = Array.isArray(obj) ? [] : {};
// 3. 遍历所有属性
for (let key in obj) {
// 只处理自身属性
if (obj.hasOwnProperty(key)) {
// 递归拷贝
clone[key] = deepClone(obj[key]);
}
}
return clone;
}
// 测试
const original = {
name: '莫某',
address: { city: '南昌', zip: '330000' },
hobbies: ['篮球', '看烟花']
};
const copy = deepClone(original);
copy.address.city = '北京';
copy.hobbies.push('编程');
console.log(original.address.city); // '南昌'
console.log(original.hobbies); // ['篮球', '看烟花']
逐行解析:
js
if (typeof obj !== 'object' || obj === null) {
return obj;
}
- 基本类型(number/string/boolean)直接返回
null也直接返回- 这是递归的终止条件
js
const clone = Array.isArray(obj) ? [] : {};
- 判断是数组还是对象
- 创建对应类型的空容器
js
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key]);
}
}
for...in遍历所有可枚举属性hasOwnProperty过滤掉原型链上的属性- 递归调用
deepClone,处理嵌套对象
3.2 处理特殊类型
js
function deepClone(obj) {
// 1. 处理基本类型
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 2. 处理 Date
if (obj instanceof Date) {
return new Date(obj);
}
// 3. 处理 RegExp
if (obj instanceof RegExp) {
return new RegExp(obj.source, obj.flags);
}
// 4. 处理数组和对象
const clone = Array.isArray(obj) ? [] : {};
// 5. 处理 Symbol 键
const symbolKeys = Object.getOwnPropertySymbols(obj);
for (let key of symbolKeys) {
clone[key] = deepClone(obj[key]);
}
// 6. 处理普通属性
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key]);
}
}
return clone;
}
// 测试
const original = {
name: '洪强',
birthday: new Date('1990-01-01'),
pattern: /hello/gi,
[Symbol('id')]: 123
};
const copy = deepClone(original);
console.log(copy.birthday instanceof Date); // true
console.log(copy.pattern instanceof RegExp); // true
console.log(Object.getOwnPropertySymbols(copy)); // [Symbol(id)]
逐行解析:
js
if (obj instanceof Date) {
return new Date(obj);
}
- 检测是否是 Date 对象
- 创建新的 Date 实例,保持相同的值
js
if (obj instanceof RegExp) {
return new RegExp(obj.source, obj.flags);
}
- 检测是否是 RegExp 对象
obj.source是正则表达式的模式obj.flags是标志(g/i/m 等)
js
const symbolKeys = Object.getOwnPropertySymbols(obj);
for (let key of symbolKeys) {
clone[key] = deepClone(obj[key]);
}
Object.getOwnPropertySymbols获取所有 Symbol 键- Symbol 键不会被
for...in遍历到,需要单独处理
3.3 处理循环引用
问题:循环引用导致无限递归
js
const obj = { name: '莫某' };
obj.self = obj; // 对象引用自己
deepClone(obj);
// RangeError: Maximum call stack size exceeded
解决方案:使用 WeakMap 记录已拷贝的对象
js
function deepClone(obj, hash = new WeakMap()) {
// 1. 处理基本类型
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 2. 检查是否已经拷贝过
if (hash.has(obj)) {
return hash.get(obj); // 返回已拷贝的引用
}
// 3. 处理特殊类型
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
// 4. 创建新对象
const clone = Array.isArray(obj) ? [] : {};
// 5. 记录到 hash 中
hash.set(obj, clone);
// 6. 处理 Symbol 键
const symbolKeys = Object.getOwnPropertySymbols(obj);
for (let key of symbolKeys) {
clone[key] = deepClone(obj[key], hash);
}
// 7. 处理普通属性
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key], hash);
}
}
return clone;
}
// 测试循环引用
const obj = { name: '莫某' };
obj.self = obj;
const copy = deepClone(obj);
console.log(copy.self === copy); // true,循环引用被正确处理
console.log(copy.self.name); // '洪强'
逐行解析:
js
function deepClone(obj, hash = new WeakMap()) {
- 添加第二个参数
hash,默认值是新的 WeakMap hash用于记录已经拷贝过的对象
js
if (hash.has(obj)) {
return hash.get(obj);
}
- 检查
obj是否已经拷贝过 - 如果拷贝过,直接返回之前拷贝的结果
- 避免无限递归
js
const clone = Array.isArray(obj) ? [] : {};
hash.set(obj, clone);
- 先创建空对象
- 立即 记录到
hash中 - 这样在递归处理属性时,如果遇到循环引用,可以正确返回
js
clone[key] = deepClone(obj[key], hash);
- 递归调用时,传递
hash参数 - 确保整个递归过程共享同一个
hash
WeakMap vs Map:
| 特性 | WeakMap | Map |
|---|---|---|
| 键的类型 | 只能是对象 | 任意类型 |
| 垃圾回收 | 弱引用,不阻止回收 | 强引用,阻止回收 |
| 遍历 | 不可遍历 | 可遍历 |
| 适用场景 | 缓存、元数据 | 通用键值对 |
为什么用 WeakMap?
- 键是弱引用,不会阻止垃圾回收
- 如果原对象被销毁,WeakMap 中的记录也会自动消失
- 避免内存泄漏
四、完整实现
js
function deepClone(obj, hash = new WeakMap()) {
// 1. 处理基本类型和 null
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 2. 处理循环引用
if (hash.has(obj)) {
return hash.get(obj);
}
// 3. 处理 Date
if (obj instanceof Date) {
return new Date(obj);
}
// 4. 处理 RegExp
if (obj instanceof RegExp) {
return new RegExp(obj.source, obj.flags);
}
// 5. 处理 Map
if (obj instanceof Map) {
const clone = new Map();
hash.set(obj, clone);
obj.forEach((value, key) => {
clone.set(deepClone(key, hash), deepClone(value, hash));
});
return clone;
}
// 6. 处理 Set
if (obj instanceof Set) {
const clone = new Set();
hash.set(obj, clone);
obj.forEach(value => {
clone.add(deepClone(value, hash));
});
return clone;
}
// 7. 处理函数(可选:直接返回或拷贝)
if (typeof obj === 'function') {
return obj; // 或者使用 eval('(' + obj.toString() + ')')
}
// 8. 创建数组或对象
const clone = Array.isArray(obj) ? [] : {};
// 9. 记录到 hash
hash.set(obj, clone);
// 10. 处理 Symbol 键
const symbolKeys = Object.getOwnPropertySymbols(obj);
for (let key of symbolKeys) {
clone[key] = deepClone(obj[key], hash);
}
// 11. 处理普通属性
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key], hash);
}
}
return clone;
}
逐行解析:
js
if (obj instanceof Map) {
const clone = new Map();
hash.set(obj, clone);
obj.forEach((value, key) => {
clone.set(deepClone(key, hash), deepClone(value, hash));
});
return clone;
}
- 处理 Map 类型
- Map 的键和值都需要递归拷贝
- 先记录到
hash,再处理内容,防止循环引用
js
if (obj instanceof Set) {
const clone = new Set();
hash.set(obj, clone);
obj.forEach(value => {
clone.add(deepClone(value, hash));
});
return clone;
}
- 处理 Set 类型
- Set 的值需要递归拷贝
js
if (typeof obj === 'function') {
return obj;
}
- 函数通常直接返回引用
- 如果需要完全拷贝,可以使用
eval或new Function - 但这样会有性能和安全问题
五、性能优化
5.1 避免重复判断
js
const toString = Object.prototype.toString;
function getType(obj) {
return toString.call(obj);
}
function deepClone(obj, hash = new WeakMap()) {
if (typeof obj !== 'object' || obj === null) return obj;
if (hash.has(obj)) return hash.get(obj);
const type = getType(obj);
if (type === '[object Date]') return new Date(obj);
if (type === '[object RegExp]') return new RegExp(obj.source, obj.flags);
if (type === '[object Map]') { /* ... */ }
if (type === '[object Set]') { /* ... */ }
// ...
}
优化点:
- 使用
Object.prototype.toString获取准确类型 - 避免多次
instanceof判断 - 性能更好,代码更清晰
5.2 处理原型链
js
function deepClone(obj, hash = new WeakMap()) {
if (typeof obj !== 'object' || obj === null) return obj;
if (hash.has(obj)) return hash.get(obj);
// 获取原型
const proto = Object.getPrototypeOf(obj);
// 创建新对象,保持原型链
const clone = Object.create(proto);
hash.set(obj, clone);
// 获取所有属性(包括不可枚举的)
const descriptors = Object.getOwnPropertyDescriptors(obj);
for (let key in descriptors) {
const descriptor = descriptors[key];
if (typeof descriptor.value === 'object' && descriptor.value !== null) {
descriptor.value = deepClone(descriptor.value, hash);
}
Object.defineProperty(clone, key, descriptor);
}
return clone;
}
优化点:
- 使用
Object.create(proto)保持原型链 - 使用
Object.getOwnPropertyDescriptors获取所有属性描述符 - 使用
Object.defineProperty保持属性的可枚举性、可配置性等
六、方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| JSON 序列化 | 简单、快速 | 无法处理函数、undefined、Symbol、循环引用 | 纯数据对象 |
| 递归实现 | 完整、灵活 | 性能较差、代码复杂 | 通用场景 |
| lodash.cloneDeep | 稳定、经过测试 | 需要引入第三方库 | 生产环境 |
性能对比:
js
const obj = {
name: '莫某',
address: { city: '南昌', zip: '330000' },
hobbies: ['篮球', '看烟花']
};
// JSON 方案:~0.05ms
console.time('JSON');
JSON.parse(JSON.stringify(obj));
console.timeEnd('JSON');
// 递归方案:~0.1ms
console.time('递归');
deepClone(obj);
console.timeEnd('递归');
七、面试要点
7.1 面试官常问的问题
Q1:为什么要用 WeakMap?
- WeakMap 的键是弱引用,不会阻止垃圾回收
- 如果原对象被销毁,WeakMap 中的记录也会自动消失
- 避免内存泄漏
Q2:如何处理函数?
- 通常直接返回函数引用
- 如果需要完全拷贝,可以用
eval或new Function - 但这样会有性能和安全问题
Q3:如何处理原型链?
- 使用
Object.getPrototypeOf获取原型 - 使用
Object.create创建保持原型链的新对象 - 使用
Object.getOwnPropertyDescriptors获取所有属性
Q4:如何优化性能?
- 避免重复判断类型
- 使用缓存(WeakMap)避免重复拷贝
- 对于大对象,可以考虑分块拷贝
7.2 完整面试答案
js
function deepClone(obj, hash = new WeakMap()) {
// 1. 基本类型
if (typeof obj !== 'object' || obj === null) return obj;
// 2. 循环引用
if (hash.has(obj)) return hash.get(obj);
// 3. 特殊类型
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
// 4. 创建新对象
const clone = Array.isArray(obj) ? [] : {};
hash.set(obj, clone);
// 5. 递归拷贝
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key], hash);
}
}
// 6. Symbol 键
const symbolKeys = Object.getOwnPropertySymbols(obj);
for (let key of symbolKeys) {
clone[key] = deepClone(obj[key], hash);
}
return clone;
}
八、总结
核心要点
- 浅拷贝只拷贝第一层,嵌套对象仍然是引用
- 深拷贝需要递归拷贝所有层级,创建完全独立的副本
- JSON 方案简单但有缺陷,无法处理函数、undefined、Symbol、循环引用
- 递归实现可以处理所有情况,但需要注意循环引用
- WeakMap用于记录已拷贝的对象,避免无限递归
- 特殊类型(Date、RegExp、Map、Set)需要单独处理
实现步骤
javascript
1. 处理基本类型 → 直接返回
2. 处理循环引用 → 使用 WeakMap 记录
3. 处理特殊类型 → Date、RegExp、Map、Set
4. 创建新对象 → 数组或对象
5. 递归拷贝 → 遍历所有属性
6. 处理 Symbol → 单独遍历 Symbol 键
最佳实践
- 简单场景:使用 JSON 方案
- 通用场景:使用递归实现
- 生产环境:使用 lodash.cloneDeep