手写深拷贝:从 JSON 到递归,彻底解决循环引用问题

前言

在前端开发中,对象拷贝是一个高频话题。面试时经常被问到:

"请手写一个深拷贝,并处理循环引用的情况"

今天我们从浅拷贝的问题出发,逐步实现一个完整的深拷贝函数,彻底搞懂这个问题。


一、浅拷贝 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;
  • 这是引用式拷贝,不是真正的拷贝
  • datausers 指向同一个堆内存地址
  • 修改 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 深拷贝的需求

深拷贝需要:

  1. 完全独立的副本:修改新对象不影响原对象
  2. 递归拷贝:嵌套对象也要拷贝
  3. 处理特殊类型:Date、RegExp、Map、Set 等
  4. 处理循环引用:对象引用自己,不能无限递归

二、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));
  • datausers 指向不同的堆内存地址
  • 修改 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;
}
  • 函数通常直接返回引用
  • 如果需要完全拷贝,可以使用 evalnew 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:如何处理函数?

  • 通常直接返回函数引用
  • 如果需要完全拷贝,可以用 evalnew 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;
}

八、总结

核心要点

  1. 浅拷贝只拷贝第一层,嵌套对象仍然是引用
  2. 深拷贝需要递归拷贝所有层级,创建完全独立的副本
  3. JSON 方案简单但有缺陷,无法处理函数、undefined、Symbol、循环引用
  4. 递归实现可以处理所有情况,但需要注意循环引用
  5. WeakMap用于记录已拷贝的对象,避免无限递归
  6. 特殊类型(Date、RegExp、Map、Set)需要单独处理

实现步骤

javascript 复制代码
1. 处理基本类型 → 直接返回
2. 处理循环引用 → 使用 WeakMap 记录
3. 处理特殊类型 → Date、RegExp、Map、Set
4. 创建新对象 → 数组或对象
5. 递归拷贝 → 遍历所有属性
6. 处理 Symbol → 单独遍历 Symbol 键

最佳实践

  • 简单场景:使用 JSON 方案
  • 通用场景:使用递归实现
  • 生产环境:使用 lodash.cloneDeep

相关推荐
灏仟亿前端技术团队1 小时前
DOMagic:把 AI 修改绑定到页面节点的前端开发态插件
前端
Lugas1 小时前
浏览器为什么要如此严格地限制跨域请求?
前端·黑客·浏览器
Cobyte1 小时前
17.响应式系统比对:链表如何实现响应式系统的高效更新
前端·javascript·vue.js
2601_963771371 小时前
Speeding Up Creative Agency Portfolios: A Performance Guide
前端·数据库·php
禅思院1 小时前
AI对话前端从入门到崩溃:一个长对话引发的五层优化战争【二】
前端·前端框架·ai编程
IT_陈寒2 小时前
React useEffect里面写异步,我的状态怎么老丢?
前端·人工智能·后端
AI视觉网奇2 小时前
BambuStudio 自定义加载页面
服务器·前端·javascript
Liquad Li2 小时前
Salesforce高级开发面试题
java·网络·面试·crm·salesforce
橙子家2 小时前
Extension storage、Interest groups 和 Shared storage 简介-浏览器缓存之【隐私沙盒与扩展专用存储】
前端