这一次彻底掌握深拷贝

11,670 阅读18分钟

写在前面

在日常开发过程中,我们经常会涉及到数据的拷贝。使用时经常会困惑,到底是不是需要深拷贝,如果是深拷贝使用第三方库还是使用自己写的方法。如果使用第三方库比如lodash还好,基本上不会出错。但是如果使用的是自己写的方法,那么很容易出错,明明有些时候能够拷贝成功,为什么有些时候又不能实现拷贝成功了?这就是对深拷贝理解不深导致的。
而且在日常面试过程中,面试官经常会来一句:写一个深拷贝吧。这个时候,可能面试前临时抱佛角背下了一个深拷贝,但是自己也是模棱两可讲不清楚,从而影响面试印象。因此,本文的主要任务就是带你彻底理解Javascript的深拷贝。我不会只给你一个最终的代码,而是带你一步一步地去实现,理解为什么要这么实现。毕竟只有自己懂了的东西,才能够印象深刻,以后也不会轻易遗忘。

一. 基础知识

在写深拷贝之前,我们先来说一下一个基础知识。

1.1 数据类型

javascript有几种数据类型。这是一个很简单的问题,但是也是面试官喜欢问的致命问题,因为基本上答错一个就印象分大打折扣了。在Javascript中一共只有以下7种数据类型。

  • Number
  • String
  • Boolean
  • Null
  • Undefined
  • Symbol
  • Object

其中前面6种类型是原始数据类型,而Object是引用数据类型。我更喜欢把前面6种称之为简单数据类型,而把Object称之为复杂数据类型。因为简单数据类型没有子类型了,不可以再进行拆分了,而复杂数据类型还有子类型,比如Array,Function,RegExp,Date等对象,正是因为这些子类型的不同导致了深拷贝的各种问题。这就是为什么很多人在回答有哪些基本数据类型时会把Array和Function答进去。事实上他们只是Object的子类型,并不是基本数据类型。
数据类型的不同,会导致在内存中的存储方式的不同,如果是简单数据类型,存储在栈空间中,存储的是一个值;如果是复杂数据类型,存储在堆空间中,存储的是一个引用。正是这种存储方式的差异,导致了浅拷贝和深拷贝的区别。 数据类型

1.2 浅拷贝和深拷贝

我们先来明确一下到底什么是浅拷贝什么是深拷贝。
浅拷贝: 如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以修改新拷贝的对象会影响原对象。
这些都是官方的一些定义,他们喜欢用内存地址这种不直观的方式来进行描述,我希望能够用简单的图来描述清楚。
我理解的浅拷贝——有交叉的线的就是浅拷贝 浅拷贝 如上图所示:所谓的浅拷贝就是无论你拷贝多少个对象,这些拷贝的对象里面的属性还是指向原来对象里面的属性从图上线来看,就是两个对象之间线相交。 正是因为线的相交导致互相影响,因此只要有一个对象修改了属性,其他对象对应的属性都会进行修改。示例:

let obj = {
    id:1,
    info:{
        name:"hello",
        age:24
    }
}
// 实现浅拷贝
let obj2 = {};
for(let key in obj){
  obj2[key] = obj[key];
}
obj2.id = 3;
console.log(obj.id);   // 3

官方描述深拷贝: 将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象。
我理解的深拷贝——图上线之间不存在相交的就是深拷贝:

深拷贝 我们可以看到,深拷贝一个对象,就是创建一个与之前对象完全无关的对象,从图上线来看,就是两个对象之间线不相交。由于两个对象之间完全不相交(用句俗语来说就是咱两是两条平行线,永远也扯不上关系),既然扯不上关系,因此也就不存在谁影响谁的问题了。

let obj = {
    id:1,
    info:{
        name:"hello",
        age:24
    }
}
let obj2 = JSON.parse(JSON.stringify(obj)); // 这里实现深拷贝  暂时记住就好
obj2.id = 3;
obj2.info.name = "刘亦菲";
console.log(obj.id);   // 1
console.log(obj.info.name);   // hello

上面的代码中obj2是通过深拷贝obj1得到的,修改obj2的属性,发现Obj1的属性不会跟着修改。这是深拷贝。

二、深拷贝的实现

通过上面的基础知识,我们已经知道了不同数据类型对拷贝的影响以及什么是深拷贝什么是浅拷贝,那么接下来就是如何去实现深拷贝。

2.1 序列化与反序列化

在上面的深拷贝的代码示例中,我使用了JSON.parse(JSON.stringify)实现了一个深拷贝。这就是日常开发中使用较为频繁的一个深拷贝方法,它可以实现一些不是那么复杂的数据类型的深拷贝。示例:

let num = 24;
let bool = true;
let obj = {
  id:1
  info:{
    name:"hello",
    age:24
  }
}

let num1 = JSON.parse(JSON.stringify(num))// num1就是num的深拷贝   虽然简单的数据类型这种拷贝没啥意义
let bool1 = JSON.parse(JSON.stringify(bool))// num1就是num的深拷贝   虽然简单的数据类型这种拷贝没啥意义
let obj2 = JSON.parse(JSON.stringify(obj))// 复杂数据类型也可以使用JSON.parse(JSON.stringify(obj))

但是这种方法存在一些缺点,由于它是依赖于JSON,因此它不支持JSON不支持的其他格式,通过JSON的官网可知,JSON只支持object,array,string,number,true,false,null这几种数据或者值,其他的比如函数,undefined,Date,RegExp等数据类型都不支持。对于它不支持的数据都会直接忽略该属性。

1. 对象中不能有函数,否则无法序列化
函数问题

2. 对象中不能有undefined,否则无法序列化

undefined问题

3. 对象中不能有RegExp正则,否则无法序列化
如果对象属性中存在正则,使用JSON.parse(JSON.stringify))克隆后会被忽略,最终变成空。 正则

4. Date类型数据会被转化为字符串类型
如果对象中存在Date类型的数据,会被转换成字符串,从而丢失Date的一些特性,比如时间格式化等方法。 日期格式

5. 对象不能是环状结构的,否则会导致报错
所谓环状结构的对象,就是对象的属性又指向了自身,window就是最常见的一个环状对象。

let obj = {name:'hello'}
obj.self = obj   // self属性又指向了obj对象,形成了一个换

这种环状结构的对象,在使用JSON.parse(JSON.stringify)深拷贝时会报错。 环状问题

小结:从上面的分析中,我们可以看到JSON.parse(JSON.stringify())虽然能够深拷贝一个对象,但是存在很大的局限性,对于复杂的对象就不适用了。因此,我们需要采用另外的方式来实现深拷贝,也就是通过递归的方式手动实现深拷贝。

2.2 递归克隆

我们在第一部分讲述了数据的基本类型,任何的数据都时由这些类型组成的,只是因为这些类型的差异比如简单类型和复杂类型(Object),复杂类型的子类型(Array,Function,Date)之间的差异导致了深拷贝的各种问题。 因此,我们只需要实现依次下面这些数据类型的拷贝,就能够很好地实现所有数据的深拷贝了。 数据类型 接下来就是带你一步一步地分别实现每种数据类型的拷贝,最终得到的就是要给完整的深拷贝。

2.2.1 拷贝简单数据类型

如果是简单的数据类型,由于保存的是值,因此只需要返回这个值就行,不存在相互影响的问题。实现如下:

function deepClone(target){
  return target
}

2.2.2 拷贝简单的对象

所谓简单的对象,是指这些对象是由上卖弄的简单数据类型组成的,不存在Array,Function,Date等子类型的数据。比如这种:

let obj1 = {
  name:"hello",
  child:{
    name:"小明"
  }
}

实现思路就是创建一个新的对象,然后把每个对象上的属性拷贝到新对象上。如果这个属性是简单类型的那么就直接返回这个属性值。如果是Object类型,那么就通过for...in遍历讲对象上的每个属性一个一个地添加到新的对象身上。因为无法区分对象的层级,因此使用递归,每次赋值时都是调用自己,反正如果时简单类型就递归一次直接返回值,如果是Object类型,那么就往下递归查找赋值。

function deepClone(target){
  if(target instanceof Object){
      let dist = {};
      for(let key in target){
        // 递归调用自己获取到每个值
          dist[key] = deepClone(target[key]);
      }
      return dist;
  }else{
      return target;
  }
}

我们使用上面的深拷贝函数,进行简单的测试。拷贝简单的对象,拷贝后对象中所有的引用类型必须是不相同的,但是所有的简单数据类型的值是相同的(但是他们其实不是同一个),比如:

let obj1 = {
  name:"hello",
  child:{
    name:"小明"
  }
}
let obj2 = deepClone(obj1);
console.log(obj2 !== obj1);                         // true
console.log(obj2.name === obj1.name);               // true
console.log(obj2.child !== obj1.child);             // true
console.log(obj2.child.name === obj1.child.name);   // true
obj2.name = "World";
console.log(obj1.name === 'hello');                 // true

2.2.3 拷贝复杂对象——数组

使用上面的方法我们能够实现拷贝简单的对象,但是对于一些包含子类型的对象,比如数组无法实现。我们看下代码:

    const a = [[11,12],[21,22]];
    const a2 = deepClone(a);
    console.log('........:',a2); //{ '0': { '0': 11, '1': 12 }, '1': { '0': 21, '1': 22 } }

我们发现拷贝后的数组,得到的是一个特殊的对象。这个对象以数组的下标作为key值,数组的每一项作为value值,这是因为for in 在遍历数组时由于找不到key值会默认以数组的下表作为key值,数组的每一项作为value值。这样的话最终克隆后得到的数据类型就跟数组不一致了(实际上这就是数组本身的特殊造成的)。最终由数组拷贝后变成了对象。
我们发现问题出在我们把所有的东西都定义成一个{}了,而数组是不能用{}来描述的,因此我们需要根据对象的类型来区分一下最终返回的数据类型。实现代码如下:

// 先不优化代码
function deepClone(target){
  if(target instanceof Object){
      let dist ;
      if(target instanceof Array){
        // 如果是数组,就创建一个[]
        dist = []
      }else{
        dist = {};
      }
      for(let key in target){
          dist[key] = deepClone(target[key]);
      }
      return dist;
  }else{
      return target;
  }
}

由于数组也可以通过for in进行遍历,因此实际上我们要修改的就是在克隆时,先判断要克隆的对象是不是数组即可。

2.2.4 拷贝复杂对象——函数

拷贝函数这个其实有点争议,因为在很多人看来函数是无法拷贝的。在我看来函数实际上不应该有深拷贝的,如果真的要有,那么也就是实现函数的功能,同时函数的对象也必须是符合深拷贝的逻辑(引用属性不等,简单类型属性相等):

  1. 函数实现的功能要相同——返回的值相同
  2. 函数身上的引用类型的属性要不相同,直接类型的属性的值要相同。 如下代码所示:
    const fn = function(){return 1};
    fn.xxx = {yyy:{zzz:1}};
    const fn2 = deepClone(fn);
    console.log(fn !== fn2);                 // 函数不相同
    console.log(fn.xxx!== fn2.xxx);          // 函数引用类型的属性不相同
    console.log(fn.xxx.yyy!== fn2.xxx.yyy);  // 函数引用类型的属性不相同
    console.log(fn.xxx.yyy.zzz === fn2.xxx.yyy.zzz);// 函数简单类型的属性值相同
    console.log(fn() === fn2());            //  函数执行后相等

那么应该如何实现一个函数的拷贝了?

  1. 首先需要返回一个新的函数
  2. 新的函数执行结果必须与原函数相同。
function deepClone(target){
  if(target instanceof Object){
      let dist ;
      if(target instanceof Array){
        dist = []
      }else if(target instanceof Function){
        dist = function(){
            // 在函数中去执行原来的函数,确保返回的值相同
            return target.call(this, ...arguments);
        }
      }else{
        dist = {};
      }
      for(let key in target){
          dist[key] = deepClone(target[key]);
      }
      return dist;
  }else{
      return target;
  }
}

2.2.5 拷贝复杂对象——正则表达式

如何拷贝一个正则了?以一个简单的正则为例:

const a = /hi\d/ig;

一个正则,其实由两部分组成,正则的模式(斜杠之间的内容)hi\d,以及参数ig。因此,只要能够拿到这两部分就可以得到一个正则表达式。从而实现克隆这个正则。通过正则的source属性就能够拿到正则模式,通过正则的flags属性就能够拿到正则的参数。

const a = /hi\d/ig;
console.log(a.source);   //   hi\d
console.log(a.flags)    // ig

因此,我们深拷贝一个正则实际上就是拿到这两部分,然后重新创建一个新的正则,从而实现跟原来的正则相同的功能即可。

function deepClone(target){
  if(target instanceof Object){
      let dist ;
      if(target instanceof Array){
        // 拷贝数组
        dist = [];
      }else if(target instanceof Function){
        // 拷贝函数
        dist = function () {
          return target.call(this, ...arguments);
        };
      }else if(target instanceof RegExp){
        // 拷贝正则表达式
       dist = new RegExp(target.source,target.flags);
      }else{
        // 拷贝普通对象
        dist = {};
      }
      for(let key in target){
          dist[key] = deepClone(target[key]);
      }
      return dist;
  }else{
      return target;
  }
}

2.2.6 拷贝复杂对象——日期

如果拷贝的是一个日期,在通过我们上面的方法拷贝后,返回的是一个字符串。这个字符串不是Date类型的, 它无法调用Date的任何方法。因此,我们需要支持日期格式的拷贝。事实上,通过上面的Array,Function,RexExp复杂对象类型的拷贝,我们可以发现,实际上这些拷贝都是通过new XXX(),相当于创建一个新的对象返回回去。因此,日期的拷贝也是一样:

dist = new Date(source);

将要拷贝的日期,作为参数然后生成一个新的Date。最终实现如下:


function deepClone(target){
  if(target instanceof Object){
      let dist ;
      if(target instanceof Array){
        // 拷贝数组
        dist = [];
      }else if(target instanceof Function){
        // 拷贝函数
        dist = function () {
          return target.call(this, ...arguments);
        };
      }else if(target instanceof RegExp){
        // 拷贝正则表达式
       dist = new RegExp(target.source,target.flags);
      }else if(target instanceof Date){
          dist = new Date(target);
      }else{
        // 拷贝普通对象
        dist = {};
      }
      for(let key in target){
          dist[key] = deepClone(target[key]);
      }
      return dist;
  }else{
      return target;
  }
}

好了,到目前为止我们的深拷贝已经支持了简答数据类型,普通对象,数组,函数,正则,日期这些最常见的数据了。虽然我们的代码中有很多if else结构,但是我觉得这是最容易让大家理解的写法。

三、进一步优化

到目前为止,我们虽然写出了一个可使用的深拷贝函数,但是这个函数仍然存在着许多可优化的地方。(这些优化的地方也是面试官容易问到的地方)。

3.1 忽略原型上的属性

我们在遍历对象的属性的时候,使用的是for infor in 会遍历包括原型上的所有可迭代的属性。 比如:

let a = Object.create({name:'hello'});
a.age = 14;

那么使用遍历时,会遍历nameage属性。而不仅仅是a自身身上的age属性。但是,事实上我们不应该去遍历原型上的属性,因为这样会导致对象属性非常深。因此,使用for in遍历时我们最好把原型上的属性和自身属性区分开来,通过hasOwnProperty筛选出自身的属性进行遍历。

    for (let key in source) {
      // 只遍历本身的属性
      if(source.hasOwnProperty(key)){
        dist[key] = deepClone(source[key]);
      }
    }

因此,优化后的代码如下:

function deepClone(target){
  if(target instanceof Object){
      let dist ;
      if(target instanceof Array){
        // 拷贝数组
        dist = [];
      }else if(target instanceof Function){
        // 拷贝函数
        dist = function () {
          return target.call(this, ...arguments);
        };
      }else if(target instanceof RegExp){
        // 拷贝正则表达式
       dist = new RegExp(target.source,target.flags);
      }else if(target instanceof Date){
          dist = new Date(target);
      }else{
        // 拷贝普通对象
        dist = {};
      }
      for(let key in target){
          // 过滤掉原型身上的属性
        if (target.hasOwnProperty(key)) {
            dist[key] = deepClone(target[key]);
        }
      }
      return dist;
  }else{
      return target;
  }
}

3.2 环状对象的爆栈问题

我们在之前使用JSON.parse(JSON.stringify())拷贝对象时,就遇到过如果出现环状对象,会导致报错问题。那么使用我们自己的深拷贝函数同样会遇到问题。这是由于我们在deepClone函数中使用了递归,按理来说每一个递归应该有一个终止条件的,但是由于对象树结构一般会有终点,因此会自动在终点结束递归。但是如果一个对象有属性指向自身,那么就会形成一个环,比如:

let a = {name:"小明"};
a.self = a;   // a的self属性指向a

这样的话,在进行递归调用的过程中会无限循环,最终爆栈。因此,我们需要添加递归终止条件。所谓的递归终止条件,就是判断一个对象是否已经被克隆过了,如果被克隆过了那么就直接使用克隆后的对象,不再进行递归。因此,我们需要一个东西来保存可能重复的属性以及它的克隆地址。最好的方式就是map。 缓存 这里大家可能有点难以理解,因此我们用更加直观的图形方式来介绍: 上图中我们依次拷贝属性a,属性b和属性c对应的拷贝后的属性为a1,b1和c1。其中属性c又指向了属性a,因此拷贝时我们又得拷贝一次属性a,这样的话就不断地形成循环,最终递归导致爆栈。因此,对于a这种已经拷贝过的属性,我们可以使用一个东西把它和它对应的拷贝对象地址保存起来,如果遇到c这种又指向a的,只需要把保存的对象地址赋值给c即可。这种需要两个值,而且一一对应最常见的数据结构就是object或者map。当然使用数组也行。这里我们使用map来进行保存。

let cache = new Map();
function deepClone(target){
  if(cache.get(target)){
      return cache.get(target)
  }
  if(target instanceof Object){
      let dist ;
      if(target instanceof Array){
        // 拷贝数组
        dist = [];
      }else if(target instanceof Function){
        // 拷贝函数
        dist = function () {
          return target.call(this, ...arguments);
        };
      }else if(target instanceof RegExp){
        // 拷贝正则表达式
       dist = new RegExp(target.source,target.flags);
      }else if(target instanceof Date){
          dist = new Date(target);
      }else{
        // 拷贝普通对象
        dist = {};
      }
      // 将属性和拷贝后的值作为一个map
      cache.set(target, dist);
      for(let key in target){
          // 过滤掉原型身上的属性
        if (target.hasOwnProperty(key)) {
            dist[key] = deepClone(target[key]);
        }
      }
      return dist;
  }else{
      return target;
  }
}

3.3 共用缓存导致的互相影响问题

在上面的deepClone函数中,我们通过新增了一个缓存cache来保存已经克隆过的对象和它对应的克隆地址。但是这种方式会带来一个新的问题:由于每次克隆创建一个对象都会使用这个cache,这样的话会导致克隆一个新的对象受到上一个克隆对象的影响。示例:

  let a = {
    name:"hello",
  }     
  let a1 = deepClone(a);
  console.log(map);  //{ name: 'hello' } => { name: 'hello' }
  let b = {
    age:24
  }
  let b1 = deepClone(b);
  console.log(map);  //   { name: 'hello' } => { name: 'hello' },{ age: 24 } => { age: 24 } }

我们发现在深拷贝对象b的时候,map中已经有值了{ name: 'hello' }。而事实上这些值不是b身上已经拷贝过的属性。也就是说b的拷贝受到了a的拷贝的影响,这会导致问题。因此,我们不能让所有的深拷贝共用同一个缓存,而是让每一个深拷贝使用自己的属性。 解决办法是:在调用函数时,每次都创建一个新的map(默认参数),然后如果需要递归,就把这个map往下传。

function deepClone(target,cache = new Map()){
  if(cache.get(target)){
      return cache.get(target)
  }
  if(target instanceof Object){
      let dist ;
      if(target instanceof Array){
        // 拷贝数组
        dist = [];
      }else if(target instanceof Function){
        // 拷贝函数
        dist = function () {
          return target.call(this, ...arguments);
        };
      }else if(target instanceof RegExp){
        // 拷贝正则表达式
       dist = new RegExp(target.source,target.flags);
      }else if(target instanceof Date){
          dist = new Date(target);
      }else{
        // 拷贝普通对象
        dist = {};
      }
      // 将属性和拷贝后的值作为一个map
      cache.set(target, dist);
      for(let key in target){
          // 过滤掉原型身上的属性
        if (target.hasOwnProperty(key)) {
            dist[key] = deepClone(target[key], cache);
        }
      }
      return dist;
  }else{
      return target;
  }
}

3.4 对象过长导致的爆栈问题

我们知道我们深拷贝中使用了递归,而递归是有递归栈的,递归栈的深度是有限的,一旦对象的递归深度超过了递归栈的深度,那么就可能出现爆栈。 比如,下面的对象a的对象深度有20000个属性。这样的话基本上递归到5000时就出现爆栈了,导致报错。

      let a = {
        child:null 
      }
      let b = a;
      for(let i = 0;i < 20000;i++){
        b.child = {
          child:null
        }
        b = b.child;
      }
      console.log(a);

这种由于对象过深导致的爆栈问题,暂时没有什么解决办法,而且也很少会有这么深的对象。

四、测试

好了,到目前为止,我们基本上实现了一个功能较为完整的深拷贝。最终的实现函数如下:

function deepClone(target,cache = new Map()){
  if(cache.get(target)){
      return cache.get(target)
  }
  if(target instanceof Object){
      let dist ;
      if(target instanceof Array){
        // 拷贝数组
        dist = [];
      }else if(target instanceof Function){
        // 拷贝函数
        dist = function () {
          return target.call(this, ...arguments);
        };
      }else if(target instanceof RegExp){
        // 拷贝正则表达式
       dist = new RegExp(target.source,target.flags);
      }else if(target instanceof Date){
          dist = new Date(target);
      }else{
        // 拷贝普通对象
        dist = {};
      }
      // 将属性和拷贝后的值作为一个map
      cache.set(target, dist);
      for(let key in target){
          // 过滤掉原型身上的属性
        if (target.hasOwnProperty(key)) {
            dist[key] = deepClone(target[key], cache);
        }
      }
      return dist;
  }else{
      return target;
  }
}

接下来我们就写一个复杂的对象,使用这个对象进行深拷贝,测试我们的函数性能。

          const a = {
            i: Infinity,
            s: "",
            bool: false,
            n: null,
            u: undefined,
            sym: Symbol(),
            obj: {
              i: Infinity,
              s: "",
              bool: false,
              n: null,
              u: undefined,
              sym: Symbol(),
            },
            array: [
              {
                nan: NaN,
                i: Infinity,
                s: "",
                bool: false,
                n: null,
                u: undefined,
                sym: Symbol(),
              },
              123,
            ],
            fn: function () {
              return "fn";
            },
            date: new Date(),
            re: /hi\d/gi,
          };
          let a2 = deepClone(a);
          console.log(a2 !== a);
          console.log(a2.i === a.i);
          console.log(a2.s === a.s);
          console.log(a2.bool === a.bool);
          console.log(a2.n === a.n);
          console.log(a2.u === a.u);
          console.log(a2.sym === a.sym);
          console.log(a2.obj !== a.obj);
          console.log(a2.array !== a.array);
          console.log(a2.array[0] !== a.array[0]);
          console.log(a2.array[0].i === a.array[0].i);
          console.log(a2.array[0].s === a.array[0].s);
          console.log(a2.array[0].bool === a.array[0].bool);
          console.log(a2.array[0].n === a.array[0].n);
          console.log(a2.array[0].u === a.array[0].u);
          console.log(a2.array[0].sym === a.array[0].sym);
          console.log(a2.array[1] === a.array[1]);
          console.log(a2.fn !== a.fn);
          console.log(a2.date !== a.date);
          console.log(a2.re !== a.re);

我们发现最终所有的值都为true,事实上这是我写的单元测试,只不过这里使用console.log打印出来了。大家如果想要看完整的测试过程,可以查看我的github

五、总结

本文内容主要包括:

  • Javascript基本数据类型
  • 浅拷贝和深拷贝的区别
  • JSON.parse(JSON.stringify)实现一个深拷贝,以及这种方法的缺点
  • 如何由浅及深一步一步地使用递归克隆实现一个深拷贝

通过这篇文章,你基本上能够掌握绝大部分深拷贝的相关知识,足以应付所有的面试。更加重要的是,通过本文这种方式掌握后印象深刻,基本上不会遗忘。
最后:本文的代码在deepClone,欢迎大家star。
完结撒花。