万字长文:手撕JS深浅拷贝完全指南

0 阅读16分钟

前言

深浅拷贝是 JavaScript 中非常经典且重要的概念。

本文将从三道手撕面试题出发,由浅入深地讲解浅拷贝、简易深拷贝和完整深拷贝的实现原理与代码细节。

三道题目分别覆盖:基础浅拷贝、限定数据类型的简易深拷贝、以及支持特殊对象和循环引用的完整深拷贝。

阅读本文,掌握深浅拷贝的核心知识点和手写实现。


一、题目:FED15 浅拷贝

描述

请补全JavaScript代码,要求实现一个对象参数的浅拷贝并返回拷贝之后的新对象。 注意:

  1. 参数可能包含函数、正则、日期、ES6新对象
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    	
        <script type="text/javascript">
            const _shallowClone = target => {
                // 补全代码
                
            }
        </script>
    </body>
</html>

二、浅拷贝

对象的浅拷贝是属性与拷贝的源对象属性共享相同的引用(指向相同的底层值)的副本。

因此,当你更改源对象或副本时,也可能导致另一个对象发生更改。

与之相比,在深拷贝中,源对象和副本是完全独立的。

形式化地,如果两个对象 o1o2 是浅拷贝,那么:

  1. 它们不是同一个对象(o1 !== o2)。
  2. o1o2 的属性具有相同的名称且顺序相同。
  3. 它们的属性值相等。
  4. 它们的原型链相等。

可能导致另一个对象更改

这一点需要特别注意:并不是修改任何属性都会互相影响,只有修改被共享引用的那层属性才会。

  • 会互相影响的情况:修改 original 中一个引用类型的属性(例如数组、对象)。因为 shallowCopy 的对应属性指向同一个地址,所以 shallowCopy 能看到这个修改。
  • 不会互相影响的情况:直接给 original 的某个属性重新赋一个全新的值。这会断开 original 对该共享地址的引用,但 shallowCopy 的对应属性仍然指向原来的地址,两者不再相关。

对比深拷贝

特性浅拷贝 (Shallow Copy)深拷贝 (Deep Copy)
对象本身地址不同 (新对象)不同 (新对象)
第一层属性地址相同 (共享引用)不同 (递归创建新副本)
修改嵌套引用属性会互相影响不会互相影响
独立性部分独立 (结构独立,深层数据依赖)完全独立

Object.assign() - JavaScript | MDN

Object.assign() 静态方法将一个或者多个源对象中所有可枚举自有属性复制到目标对象,并返回修改后的目标对象。

如果目标对象与源对象具有相同的键(属性名),则目标对象中的属性将被源对象中的属性覆盖,后面的源对象的属性将类似地覆盖前面的源对象的同名属性。

const target = { a: 1, b: 2 };
const source = { b: 4, c: 5 };

const returnedTarget = Object.assign(target, source);

console.log(target);
// Expected output: Object { a: 1, b: 4, c: 5 }

console.log(returnedTarget === target);
// Expected output: true

语法

​```js Object.assign(target, ...sources)


## [RegExp - JavaScript | MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/RegExp)

**`RegExp`** 对象用于将文本与一个模式匹配。

有关正则表达式的介绍,请阅读 [JavaScript 指南](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide)中的[正则表达式章节](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Regular_expressions)。

## [Map.prototype.set() - JavaScript | MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Map/set)

[`Map`](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Map) 实例的 **`set()`** 方法会向 `Map` 对象添加或更新一个指定的键值对。

```js
const myMap = new Map();

// 将一个新元素添加到 Map 对象
myMap.set("bar", "foo");
myMap.set(1, "foobar");

// 在 Map 对象中更新某个元素的值
myMap.set("bar", "baz");

Set.prototype.add() - JavaScript | MDN

Set 实例的 add() 方法会在该集合中插入一个具有指定值的新元素,如果该 Set 对象中没有具有相同值的元素。

const mySet = new Set();

mySet.add(1);
mySet.add(5).add("some text"); // 可以链式调用

console.log(mySet);
// Set [1, 5, "some text"]

三、解法:浅拷贝实现

题目要求

  1. 实现浅拷贝(只拷贝第一层属性)
  2. 参数可能包含:函数、正则、日期、ES6新对象(如 MapSet 等)
  3. 返回一个新对象,且 target !== result

普通浅拷贝的问题

通常我们这样做浅拷贝:

// 方法1:扩展运算符
const result = { ...target };

// 方法2:Object.assign
const result = Object.assign({}, target);

但这对特殊对象(Date、RegExp、Map、Set等)会出问题

const date = new Date();
const copy = { ...date };        // 得到 {},不是日期对象
const copy2 = Object.assign({}, date); // 也是 {}

因为扩展运算符和 Object.assign 只拷贝可枚举的自身属性,而 DateRegExp 等对象的实际数据存储在内部槽(internal slots)中,不是普通属性。

内部槽”是 JavaScript 引擎用来存储对象真实核心数据的地方。它不是普通的属性,你不能用 .属性名 的方式直接访问它,也不能通过 Object.keys() 看到它。

const date = new Date();
console.log(Object.keys(date));  // [] ← 没有可枚举的自身属性
console.log({ ...date });        // {} ← 扩展运算符拷贝了个寂寞

也就是这些对象用普通的展开语法,或者点属性调用是没有办法访问到的。

即,普通的展开语法(...)和点属性访问(.)都无法访问到内部槽中的数据。

typeof - JavaScript | MDN

注意在类型判断的时候,typeof 运算符返回一个字符串,表示操作数的类型。

所以写法注意都是要这样写的

typeof target === 'function'

正确实现思路

需要先判断类型,针对不同类型做不同处理:

const _shallowClone = target => {
    // 处理 null 和基本类型
    if (target === null || typeof target !== 'object') {
        return target;
    }
    
    // 处理函数
    if (typeof target === 'function') {
        return target;
    }
    
    // 处理 Date
    if (target instanceof Date) {
        return new Date(target);
    }
    
    // 处理 RegExp(显式传递 flags)
    if (target instanceof RegExp) {
        return new RegExp(target.source, target.flags);
    }
    
    // 处理 Map
    if (target instanceof Map) {
        const newMap = new Map();
        target.forEach((value, key) => {
            newMap.set(key, value);
        });
        return newMap;
    }
    
    // 处理 Set
    if (target instanceof Set) {
        const newSet = new Set();
        target.forEach(value => {
            newSet.add(value);
        });
        return newSet;
    }
    
    // 处理数组
    if (Array.isArray(target)) {
        return [...target];
    }
    
    // 处理普通对象(保留原型链)
    return Object.assign(Object.create(Object.getPrototypeOf(target)), target);
};

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    	
        <script type="text/javascript">
            const _shallowClone = target => {
                if(target === null || typeof target !== 'object'){
                    return target ;
                }

                if (typeof target === 'function'){
                    return target ;
                }

                if (target instanceof Date ){
                    return new Date(target);
                }

                if (target instanceof RegExp ){
                    return new RegExp(target.source , target.flag);
                }
                
                if (target instanceof Map){
                    const newMap = new Map();
                    target.forEach ((value , key ) => {
                        newMap.set(key ,value);
                    });
                    return newMap ;
                }

                if (target instanceof Set){
                    const newSet = new Set();
                    target.forEach (value => {
                        newSet.add(value);
                    });
                    return newSet ;
                }

                if(Array.isArray(target)){
                    return [...target];
                }

                return Object.assign(Object.create(Object.getPrototypeOf(target)),target);
                
            }
        </script>
    </body>
</html>

四、题目:FED16 简易深拷贝

描述

请补全JavaScript代码,要求实现对象参数的深拷贝并返回拷贝之后的新对象。 注意:

  1. 参数对象和参数对象的每个数据项的数据类型范围仅在数组、普通对象({})、基本数据类型中]
  2. 无需考虑循环引用问题
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    	
        <script type="text/javascript">
            const _sampleDeepClone = target => {
                // 补全代码
                
            }
        </script>
    </body>
</html>

五、深拷贝

对象的深拷贝是指其属性与其拷贝的源对象的属性不共享相同的引用(指向相同的底层值)的副本。

因此,当你更改源或副本时,可以确保不会导致其他对象也发生更改;也就是说,你不会无意中对源或副本造成意料之外的更改

这种行为与浅拷贝的行为形成对比,在浅拷贝中,对源或副本的更改可能也会导致其他对象的更改(因为两个对象共享相同的引用)。

如果两个对象 o1o2结构等价的,那么它们的观察行为是相同的。这些行为包括:

  1. o1o2 的属性具有相同的名称且顺序相同。
  2. 它们的属性的值是结构等价的。
  3. 它们的原型链是结构等价的(尽管在处理结构等价时,这些对象通常是普通对象,意味着它们都继承自 Object.prototype)。

结构等价的对象可以是同一个对象(o1 === o2)或副本o1 !== o2)。因为等价的原始值总是相等的,所以你无法对它们进行复制。

我们现在可以更正式地定义深拷贝:

  1. 它们不是同一个对象(o1 !== o2)。
  2. o1o2 的属性具有相同的名称且顺序相同。
  3. 它们的属性的值是彼此的深拷贝。
  4. 它们的原型链是结构等价的。

深拷贝可能会或可能不会复制它们的原型链(通常情况下不会)。

但是,具有结构不等价原型链的两个对象(例如,一个是数组,另一个是普通对象)永远不会是彼此的副本。

所有属性都具有原始值的对象的副本符合深拷贝和浅拷贝的定义。然而,讨论这种副本的深度并无意义,因为它没有嵌套属性,而我们通常在改变嵌套属性的上下文中讨论深拷贝。

在 JavaScript 中,标准的内置对象复制操作(展开语法Array.prototype.concat()Array.prototype.slice()Array.from()Object.assign()Object.create())不创建深拷贝(相反,它们创建浅拷贝)。

深拷贝就是创建了一个“全新”的对象,这个新对象跟原对象“长得一模一样”,但彼此独立。你改新对象,不会影响原对象;改原对象,也不会影响新对象。

Object.prototype.hasOwnProperty() - JavaScript | MDN

hasOwnProperty() 方法返回一个布尔值,表示对象自有属性(而不是继承来的属性)中是否具有指定的属性。

六、解法:简易深拷贝实现

好的,题目要求很明确:实现一个简易深拷贝。既然题目已经限定数据类型范围在数组、普通对象、基本数据类型,且无需考虑循环引用,那我们可以用一种清晰直接的方法来实现。

下面我直接给出补全的代码,并附上详细注释,帮助你理解每一行在做什么。

const _sampleDeepClone = target => {
    // 1. 处理基本数据类型 和 null
    if (target === null || typeof target !== 'object') {
        return target;  // 直接返回原值(数字、字符串、布尔、null、undefined等)
    }

    // 2. 根据 target 的类型创建空的容器(数组或对象)
    const newObj = Array.isArray(target) ? [] : {};

    // 3. 遍历原对象/数组的所有属性(包括可枚举的自身属性)
    for (let key in target) {
        // 确保只复制 target 自身的属性,不复制原型链上的属性
        if (target.hasOwnProperty(key)) {
            // 4. 递归调用深拷贝,把属性的值也进行深拷贝
            newObj[key] = _sampleDeepClone(target[key]);
        }
    }

    // 5. 返回新的对象/数组
    return newObj;
};

遍历时注意是 let key in target 为了遍历原对象/数组的所有属性(包括可枚举的自身属性)

特性for...infor...of
遍历内容属性名(键)元素值
适用对象对象、数组(但不推荐用于数组)数组、字符串、Map、Set、arguments 等
是否遍历原型链是(会遍历继承的属性)否(只遍历可迭代对象自身的元素)
是否保证顺序不保证(依赖引擎实现)保证(按可迭代协议的顺序)

为什么这样写就能实现深拷贝?

用一个例子来测试:

const original = {
    name: "小明",
    age: 25,
    address: {
        city: "北京",
        zip: 100000
    },
    hobbies: ["篮球", "编程"]
};

const cloned = _sampleDeepClone(original);

// 修改克隆对象的嵌套属性
cloned.address.city = "上海";
cloned.hobbies.push("阅读");

console.log(original.address.city); // "北京" —— 原对象不受影响
console.log(original.hobbies);      // ["篮球", "编程"] —— 原对象不受影响

执行过程:

  1. target 是对象 → 进入处理逻辑
  2. 创建空对象 newObj = {}
  3. 遍历 name → 基本类型 → 直接复制
  4. 遍历 address → 又是一个对象 → 递归调用自身,再次执行深拷贝逻辑
  5. address 的递归调用中,创建新对象,复制 cityzip
  6. 遍历 hobbies → 是数组 → Array.isArray 检测为真 → 创建空数组 [] → 递归复制每个元素
  7. 最终返回完全独立的新对象

代码关键点说明

代码作用
typeof target !== 'object'判断是否是基本数据类型(包括函数,但题目范围没有函数)
target === null单独处理 null(因为 typeof null === 'object' 是历史遗留问题)
Array.isArray(target)区分数组和普通对象,保证复制后类型一致
for...in + hasOwnProperty只复制对象自身的属性,不复制原型链上的
递归调用处理嵌套结构,确保每一层都是新对象/新数组

对比:这道题为什么不能用 JSON.parse(JSON.stringify())

虽然 JSON 方法在某些场景下可以实现深拷贝,但它有缺点:

  • 无法处理 undefined、函数、Symbol
  • 性能较差
  • 本题要求手写实现,考察递归思想

而上面手写的递归方法更通用,且完全满足本题的条件。

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    	
        <script type="text/javascript">
            const _sampleDeepClone = target => {
                if (target === null || typeof target !== 'object'){
                    return target ;
                }                

                const newObj = Array.isArray(target) ? [] : {} ;

                for (let key in target){
                    if (target.hasOwnProperty(key)){
                        newObj[key] = _sampleDeepClone(target[key]);
                    }
                }
                return newObj ;
            }
        </script>
    </body>
</html>

这道题的核心就是递归 + 类型判断


七、题目:FED17 深拷贝

描述

请补全JavaScript代码,要求实现对象参数的深拷贝并返回拷贝之后的新对象。 注意:

  1. 需要考虑函数、正则、日期、ES6新对象
  2. 需要考虑循环引用问题
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    	
        <script type="text/javascript">
            const _completeDeepClone = (target, map = new Map()) => {
                // 补全代码
                
            }
        </script>
    </body>
</html>

八、完整深拷贝

Object.prototype.constructor - JavaScript | MDN

Object 实例的 constructor 数据属性返回一个引用,指向创建该实例对象的构造函数。注意,此属性的值是对函数本身的引用,而不是一个包含函数名称的字符串。

除了 null 原型对象之外,任何对象都会在其 [[Prototype]] 上有一个 constructor 属性。使用字面量创建的对象也会有一个指向该对象构造函数类型的 constructor 属性,例如,数组字面量创建的 Array 对象和对象字面量创建的普通对象。

const o1 = {};
o1.constructor === Object; // true

const o2 = new Object();
o2.constructor === Object; // true

const a1 = [];
a1.constructor === Array; // true

const a2 = new Array();
a2.constructor === Array; // true

const n = 3;
n.constructor === Number; // true

Object.getOwnPropertySymbols() - JavaScript | MDN

const object1 = {};
const a = Symbol("a");
const b = Symbol.for("b");

object1[a] = "localSymbol";
object1[b] = "globalSymbol";

const objectSymbols = Object.getOwnPropertySymbols(object1);

console.log(objectSymbols.length);
// Expected output: 2

Symbol - JavaScript | MDN

symbol 是一种原始数据类型Symbol() 函数会返回 symbol 类型的值,该类型具有静态属性和静态方法。它的静态属性会暴露几个内建的成员对象;它的静态方法会暴露全局的 symbol 注册,且类似于内建对象类,但作为构造函数来说它并不完整,因为它不支持语法:"new Symbol()"。

每个从 Symbol() 返回的 symbol 值都是唯一的。一个 symbol 值能作为对象属性的标识符;这是该数据类型仅有的目的。更进一步的解析见——glossary entry for Symbol

Symbol 是 ES6 引入的一种全新的原始数据类型,它的核心特点是:每个 Symbol 值都是独一无二的

Symbol 用来创建“绝对不重名”的属性名,防止属性名冲突。

看一个实际场景:

// 你写了一个用户管理库
const user = {
    name: "小明",
    age: 18
};

// 别人用你的库时,想添加一个自定义属性
user.name = "小红";  // ❌ 把原来的 name 覆盖了!

问题:普通字符串属性名容易冲突。

用 Symbol 解决:

const user = {
    name: "小明",
    age: 18
};

// 别人添加属性时,用 Symbol
const customKey = Symbol("custom");
user[customKey] = "一些自定义数据";

// 原来的 name 完好无损
console.log(user.name);  // "小明"

// Symbol 属性不会冲突
console.log(user[customKey]);  // "一些自定义数据"

九、解法:完整深拷贝实现

与上一题(简易深拷贝)的核心区别

特性上一题(简易深拷贝)本题(完整深拷贝)
数据类型仅数组、普通对象、基本类型函数、正则、日期、Map、Set 等
循环引用不考虑需要考虑(关键难点)
原型链不要求需要保持原型链

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    	
        <script type="text/javascript">
            const _completeDeepClone = (target, map = new Map()) => {
                if (target === null || typeof target !== 'object'){
                    return target ;
                }

                if (map.has(target)){
                    return map.get(target);
                }

                const constructor = target.constructor ;

                if (constructor === Date ){
                    return new Date(target);
                }

                if (constructor === RegExp){
                    return new RegExp(target);
                }

                if (constructor === Map ){
                    const newMap = new Map ();
                    map.set (target , newMap);
                    target.forEach((value , key ) => {
                        newMap.set(
                            _completeDeepClone(key , map),
                            _completeDeepClone(value , map)
                        );
                    });
                    return newMap ;
                }

                if (constructor === Set ){
                    const newSet = new Set();
                    map.set(target , newSet);
                    target.forEach(value => {
                        newSet.add(_completeDeepClone(value , map));
                    });
                    return newSet ;
                }

                const newObj = Array.isArray(target) ? [] : {} ;
                map.set(target , newObj);

                const keys = [...Object.keys(target),...Object.getOwnPropertySymbols(target)];
                for (let key of keys){
                    newObj[key] = _completeDeepClone(target[key] , map );
                }

                return newObj ;
            }
        </script>
    </body>
</html>

解决思路

1. 循环引用问题

当对象有相互引用时,会导致无限递归:

const obj = {};
obj.self = obj;  // 自己引用自己

// 普通递归会死循环 ❌

解决方案:用 Map 缓存已经拷贝过的对象。每次拷贝前先检查,如果拷贝过就直接返回。

2. 各种类型的处理策略

类型处理方法
基本类型 / null直接返回
日期 Datenew Date(target)
正则 RegExpnew RegExp(target)
数组 Array遍历递归拷贝每个元素
普通对象 Object遍历递归拷贝每个属性
Map遍历 map,递归拷贝每个键值对
Set遍历 set,递归拷贝每个值
函数 Function直接返回(函数一般不深拷贝,复用即可)

完整代码

const _completeDeepClone = (target, map = new Map()) => {
    // 1. 处理基本类型 和 null
    if (target === null || typeof target !== 'object') {
        return target;
    }

    // 2. 处理循环引用:如果已经拷贝过,直接返回
    if (map.has(target)) {
        return map.get(target);
    }

    // 3. 处理特殊对象类型
    const constructor = target.constructor;
    
    // 日期
    if (constructor === Date) {
        return new Date(target);
    }
    
    // 正则
    if (constructor === RegExp) {
        return new RegExp(target);
    }
    
    // Map
    if (constructor === Map) {
        const newMap = new Map();
        map.set(target, newMap);
        target.forEach((value, key) => {
            newMap.set(
                _completeDeepClone(key, map),
                _completeDeepClone(value, map)
            );
        });
        return newMap;
    }
    
    // Set
    if (constructor === Set) {
        const newSet = new Set();
        map.set(target, newSet);
        target.forEach(value => {
            newSet.add(_completeDeepClone(value, map));
        });
        return newSet;
    }
    
    // 4. 处理数组和普通对象
    const newObj = Array.isArray(target) ? [] : {};
    map.set(target, newObj);
    
    // 遍历所有属性(包括 Symbol 属性)
    const keys = [...Object.keys(target), ...Object.getOwnPropertySymbols(target)];
    for (let key of keys) {
        newObj[key] = _completeDeepClone(target[key], map);
    }
    
    return newObj;
};

测试代码(验证循环引用)

// 测试循环引用
const obj = {
    name: "test",
    date: new Date(),
    regex: /abc/g,
    map: new Map([['key', 'value']]),
    set: new Set([1, 2, 3])
};
obj.self = obj;  // 循环引用

const cloned = _completeDeepClone(obj);

console.log(cloned !== obj);           // true
console.log(cloned.self === cloned);   // true(循环引用被正确保持)
console.log(cloned.date !== obj.date); // true(日期被正确拷贝)
console.log(cloned.regex !== obj.regex); // true(正则被正确拷贝)

关键点详解

1. map 参数的作用

const _completeDeepClone = (target, map = new Map()) => {
    // 第一次调用时 map 是空的
    // 递归调用时传入同一个 map,用来记录哪些对象已经拷贝过
}

2. 循环引用处理流程

// 步骤1: 检查是否已经拷贝过
if (map.has(target)) {
    return map.get(target);  // 直接返回已拷贝的版本,避免无限递归
}

// 步骤2: 创建新对象后立即存入 map
map.set(target, newObj);
// 这样后续遇到相同引用时就能直接返回

3. 为什么要处理 Symbol 属性?

const keys = [...Object.keys(target), ...Object.getOwnPropertySymbols(target)];
  • Object.keys() 获取字符串属性名
  • Object.getOwnPropertySymbols() 获取 Symbol 类型的属性名
  • 两者合并,确保所有属性都被拷贝

4. 为什么不拷贝函数?

if (constructor === Function) {
    return target;  // 直接返回原函数
}

函数内部可能依赖外部作用域,深拷贝没有意义,通常复用原函数即可。

注意

  1. Map 和 Set 的拷贝顺序:遍历时要注意先存 map,再递归内部元素
  2. Symbol 属性:普通 for...in 遍历不到,需要专门处理
  3. 正则的 flagsnew RegExp(target) 会自动保留标志位(g、i、m 等)

十、总结

通过三道题目,我们完整地学习了深浅拷贝的各个层次:

  1. 浅拷贝:只复制第一层,使用 Object.assign 或扩展运算符,但需要特殊处理 Date、RegExp、Map、Set 等内置对象。
  2. 简易深拷贝:递归复制所有层级,适用于数组和普通对象,不考虑循环引用。
  3. 完整深拷贝:在简易深拷贝基础上,增加对 Date、RegExp、Map、Set 的支持,并使用 Map 解决循环引用问题,同时处理 Symbol 属性。