🔥「深入本质」彻底理解深度克隆问题

4,569 阅读13分钟

欢迎大家来到"深入本质"系列,Blue在这个系列中将带领大家一起挖掘代码背后的本质,一窥潜藏在代码背后作者的思想与意图,体会本质之下的精巧构思与设计之美

想理解事物为什么是这样,要站在创造者的角度思考问题

​ ——Blue

深克隆(deep clone)或叫深拷贝(deep copy)面试中经常出现,也是工作中频繁用到的技术,那么究竟什么是深克隆?怎么做?有哪些值得注意的问题?Blue带你一起来看看

这玩意叫法超级多——深克隆、深度克隆、深拷贝、深度拷贝、深度复制啥的,反正意思都是一样的,本文里Blue会比较集中的使用"深克隆"这一叫法

本文将包含以下内容

  • 深克隆、浅克隆有何区别
  • 为什么要深克隆、什么时候要深克隆
  • JS中数据的内存分配——by value与by ref
  • 各种数据类型的clone操作如何完成
  • 如何完成深克隆

克隆?深克隆?啥意思

所谓克隆,其实就是复制,我们经常需要把对象复制一份,以便于对其改动不影响原对象,js中常见的克隆有两种:

  • 浅克隆(shallow copy):仅复制对象本身,而不复制内容
  • 深克隆(deep copy):既复制对象本身,也复制对象的内容(以及"内容的内容")

来看个例子

let a={
  name: 'blue',
  datas: [12, 5, 8],
};

//浅克隆——仅复制对象本身
let b=shallowClone(a); //假想的函数,后面会实现它

//修改b
b.datas.push(99);

console.log(a); //{name: 'blue',datas: [12, 5, 8, 99]} 问题:原对象也变了
console.log(b); //{name: 'blue',datas: [12, 5, 8, 99]}

浅克隆仅复制对象本身,而datas依然公用一个数组对象,所以修改了b的datas,出现a也跟着变的情况

来看看另一个

let a={
  name: 'blue',
  datas: [12, 5, 8],
};

//深克隆——复制整个对象(对象+内容)
let b=deepClone(a); //假想的函数,后面会实现它

//修改b
b.datas.push(99);

console.log(a); //{name: 'blue',datas: [12, 5, 8]}  不会影响原对象
console.log(b); //{name: 'blue',datas: [12, 5, 8, 99]}

深克隆与浅克隆的区别

大家可能会想:那深克隆肯定好啊,全都搞新的,对以前的没影响,浅克隆还会把以前的对象影响了,以后我就全用深克隆了!其实不是的,程序里几乎没有绝对的好和坏,主要看适不适合,我们来比较一下

优点缺点适用场景
深克隆彻底的数据隔离,绝不影响原对象性能差,因为要复制的东西更多进行任何操作都不影响原有数据
如:表单传参(修改表单数据,不能对原数据有影响)
浅克隆性能高无法做到彻底隔离仅操作表层数据
如:数据排序、移动、交换等操作

到这里,大家应该清楚了,深克隆就是把对象完整的克隆一遍,对象自身和内容都克隆,那么怎么做呢?我们开始吧

从头开始-值与引用

几乎所有语言中,都有两种使用数据的方式,引用

  • :变量存储值本身,如:数字、布尔值等
  • 引用:变量存储的不是数据本身,只是找到数据的"地址",如:json、数组等

这个事太重要了,不只是对克隆,其他问题也很有影响,所以Blue带你仔细琢磨一下,先来看两个例子:

//存值的情况
let a=12;
let b=a; //注意:数字->存值

b++;

console.log(a, b); //12, 13	不影响原数据

1

//引用的情况
let a=[1,2,3];
let b=a; //注意:对象->存引用

b.push(5);

console.log(a, b); //[1,2,3,5] [1,2,3,5]  改变了原对象

2

到底什么是引用?

首先,有句话需要大家记住,马上我们来理解它——赋值操作一定会复制数据,但复制的是什么就不一样了,什么意思?

  • 存值的数据(例如:数字),存储数据本身,所以复制也是复制数据本身
  • 存引用的数据(例如:数组),存储引用,复制的也是引用,但数据本身还是那一份

传值(by value)

传值——变量直接存储数据本身

3

传引用(by ref)

传引用——变量存储的是数据的地址(既引用),复制也是复制这个地址,而非对象本身

4

总结

  • 赋值操作永远都会复制变量的内容——但复制的是什么就不好说了
  • 传值(byVal)指的是变量存储数据本身,当赋值时,复制的是数据本身
  • 传址/传引用(byRef)指的是变量存储引用/地址,当赋值时,复制的是地址,但被引用的对象还是同一个

如何复制一个引用对象?

从上面我们看出,如果希望复制/克隆一个传引用的数据,那么直接的赋值是不行的,需要手动完成,咋做呢?来看Blue给你俩小例子,瞬间变清晰

let a=[1,2,3];
let b=a; //错误的方式,a和b都指向一个对象,达不到复制的目的

b.push(5);
console.log(a, b); //[1,2,3,5]  [1,2,3,5]

复制数组

复制一个数组方法非常多,但核心都一样——创建一个新数组,这样就拥有了两个不同的数组

方式1.最基础的(也是最本质的)

let a=[1,2,3];
let b=[]; //等价于new Array(),创建新的数组

//把a的东西拿过来
for(let i=0;i<a.length;i++){
  b[i]=a[i]; //a[i]是一些数字(传值),用赋值操作完全没问题
}

//试试修改b
b.push(5);

console.log(a);
console.log(b);

5

方式2.通用-展开操作(...)

let a=[1,2,3];
let b=[...a]; //其实它内部,也是个循环,不用你写而已

b.push(5);

console.log(a);
console.log(b);

6

方法3.通用-stringify+parse

let a=[1,2,3];

let str=JSON.stringify(a); //"[1,2,3]"
let b=JSON.parse(str); //[1,2,3] 是个新对象,因为是从字符串中创建,跟原始对象无关

//如果需要,我们也可简写:
let b=JSON.parse(JSON.stringify(a));

b.push(5);

console.log(a);
console.log(b);

7

顺便一说,stringify+parse看起来很美,但它有很多问题,比如:并不是所有数据类型都接受、循环引用会出问题,所以谨慎使用

let a=[
  1,2,3,
  function (){
    console.log(this.length);
  }
];

//注意,函数不可以stringify
let b=JSON.parse(JSON.stringify(a));

console.log(b);

8

方式4.部分数组方法

let a=[1,2,3];

let b=[].concat(a);  //创建一个新数组,然后把a的内容连接进来
let c=a.map(i=>i);   //map在映射时会创建一个新数组,然后我们把每个东西原样拿过来
let d=a.filter(_=>true);  //filter也会创建新的,并且固定返回true,也就是所有的都要
let e=a.slice(0, a.length);  //把数组a的所有(0~length-1)数据拿出来做成子数组
//还有很多........

这个能举例的邪门玩法太多了,也没必要说,因为它们都没啥实用性,还不如...方便

复制json对象

json跟数组本质差不多,也有很多通用的方法

方式1.最基础、最本质的

let a={name: 'blue', age: 18};
let b={}; //新对象,跟a没关系

for(let key in a){
  b[key]=a[key]; //把东西拿过来
}

//修改b
b.age++;

console.log(a);
console.log(b);

9

方式2.通用-展开操作

let a={name: 'blue', url: 'zhinengshe.com'};
let b={...a}; //一样的,其实也是循环

b.age=18;

console.log(a); //{name, url}
console.log(b); //{name, url, age}

10

方式3.通用-stringify+parse

数组能变成字符串,json当然也可以,所以stringify+parse其实也适用于json对象

let a={name: 'blue', url: 'zhinengshe.com'};
let b=JSON.parse(JSON.stringify(a));

b.age=18;

console.log(a); //{name, url}
console.log(b); //{name, url, age}

方式4.Object.assign方法

let a={name: 'blue', age: 18};
let b=Object.assign({}, a); //本质上,是创建一个新对象{},然后把属性全请过去(assign)

b.age++;

console.log(a); //{age: 18}
console.log(b); //{age: 19}

12

复制其他对象

说了半天,这个其实才是难点,难在哪儿?"其他对象"太多了——Date、RegExp、ArrayBuffer、....,而且还要算上用户自己定义的,几乎没有办法全部处理完,咱们试几个找找感觉

实例.复制Date对象

//先来偷个懒,试试date是不是引用的(废话...),万一不是呢(呵呵)
let date1=new Date('2020-1-1');
let date2=date1;

date2.setDate(30);

console.log(date1); //1 30 2020 这不废话嘛,所有的对象都是引用的啊
console.log(date2); //1 30 2020

13

(哦豁,完蛋)

所以不用想了,Date也是引用的,所以必须创建新的Date实例,才能完成复制,怎么做?

let date1=new Date('2020-1-1');
let date2=new Date(date1.getTime()); //最简单的,我们可以用date1的时间(是个数字,非引用)创建2

date2.setDate(30);

console.log(date1);
console.log(date2);

14

但是问题是,getTime是Date所特有的操作,有没有更通用一些的?还真有,Blue带你看看:

let a=new Date('2020-1-1'); //1.首先,我们有一个对象a,我们想复制它
let b=new Date(a); //2.嗯?啥玩意。。。

b.setDate(30); //3.改动一下,看看a怎么样

console.log(a);
console.log(b);

16

恩。。。发生了啥?虽然成功了,我们到底做啥了

分析一下

其实所有的系统对象,都有两种最基本的创建方法:

  • 空白对象:全新的创建一个对象,如:new Date()
  • 从已有实例创建:以一个实例的值来复制出另一个实例,如:new Date(date1)

类似的例子还有很多:

let map1=new Map();
map1.set('a', 12); //随便加点东西
map1.set('b', 5);

let map2=new Map(map1); //复制map1
map2.set('c', 99);


console.log(map1);
console.log(map2);

17

好像,有点意思啊,再多试几个

let arr1=new Uint8Array([0,0,0,0,0,0,0,0,0]);
arr1.fill(25, 2, 5); //2~5(不含5本身)的位置,填入25

console.log(arr1); //[0, 0, 25, 25, 25, 0, 0, 0, 0]


let arr2=new Uint8Array(arr1);
arr2.fill(11, 1, 6);

console.log(arr1); //[0, 0, 25, 25, 25, 0, 0, 0, 0]
console.log(arr2); //[0, 11, 11, 11, 11, 11, 0, 0, 0]

18

所以,系统对象好办了,直接new就好,但是。。。

自定义类怎么办?

其实很简单,只要你的类也遵循这一方法就好,来个例子

//随便自定义的一个类
class Person{
  constructor(user, age){
    this.user=user;
    this.age=age;
  }
  
  sayHi(){
    console.log(`My name is ${this.user}, ${this.age} years old.`);
  }
}

//随便用一下试试
const p1=new Person('blue', 18);

p1.sayHi();

19

我们来改造它一下,主要是能接收另一个Person实例作为参数就好:

class Person{
  constructor(user, age){
    //这里是重点
    if(arguments.length==1 && arguments[0] instanceof Person){
      //从已有实例中取值
      this.user=arguments[0].user;
      this.age=arguments[0].age;
    }else{
      //以前那套,没变
      this.user=user;
    	this.age=age;
    }
  }
  
  sayHi(){
    console.log(`My name is ${this.user}, ${this.age} years old.`);
  }
}



//试试它好不好使
let p1=new Person('blue', 18);
let p2=new Person(p1);

p2.age+=5;

p1.sayHi(); //...18 years old
p2.sayHi(); //...23 years old

20

这就很靠谱了,那么我们是不是可以把这种方法直接用在数组和json上?多方便啊。。。个鬼啊😂

数组能用吗?

要是能用该多好啊。。。试试吧

let arr1=[1,2,3];
let arr2=new Array(arr1); //复制arr1

//都不用改,先试试行不行
console.log(arr1); //[1,2,3]
console.log(arr2); //[[1,2,3]] 嗯?什么鬼

21

(浏览器那边的输出不好看,用了Node的)

为什么?

这其实跟Array的参数有关

console.log(new Array(1,2,3)); //[1,2,3]
console.log(new Array('abc')); //['abc']
console.log(new Array([12, 5])); //[[12, 5]]

22

Array的参数,其实是它的初始数据(一个整数的除外)

json对象能用吗?

也不行,直接看例子吧

let obj1={a: 12, b: 5};
let obj2=new Object(obj1);

console.log(obj1); //{a: 12, b: 5}
console.log(obj2); //{a: 12, b: 5}  老师,你说错了!!!这不是可以吗!!!!!


//改个试试
obj2.b++;

console.log(obj1); //{a: 12, b: 6}
console.log(obj2); //{a: 12, b: 6}  嗯?????????

23

简单来说,new Object(obj)并不创建新对象,只是返回原有的对象,意思就是说:

let obj2=new Object(obj1);
//完全等价于
let obj2=obj1;

这都什么鬼啊。。。

小结

所以,我们可以分为五种情况来做

  • 基本类型(数字、字符串、布尔值),不需要处理
  • json/object,可以用...
  • 数组,可以用...
  • 其他系统对象,如:Date,可以new xxx(old)
  • 用户自定义对象,如:Person,需要constructor配合处理

至此,我们可以写出一个简单clone方法,但目前还是浅克隆(深克隆马上就说)

function clone(src){ //注意:浅克隆版本
  if(typeof src!='object'){
    //基本类型,不用克隆
    return src;
  }else{
    //对象类型,有三种:json、数组、系统类型及用户类型
    if(src instanceof Array){ //1-数组
      return [...src];
    }else if(src.constructor===Object){ //2-Object,也就是json
      return {...src};
    }else{ //3-系统对象或用户自定义对象(需constructor支持)
      return new src.constructor(src); //用它自身的构造器,创建一个以它为蓝本的副本,好玩
    }
  }
}

就这么个东西,我们来测试一下

//1-数组
let arr1=[1,2,3];
let arr2=clone(arr1);

arr2.push(55);

console.log(arr1);
console.log(arr2);

//2-json
let json1={a: 12, name: 'blue'};
let json2=clone(json1);

json2.age=18;

console.log(json1);
console.log(json2);


//3-系统类
let date1=new Date('1990-12-31');
let date2=clone(date1);

date2.setFullYear(2020);

console.log(date1);
console.log(date2);

24

测试全部通过,妥妥儿的

深克隆怎么做?

其实我们已经完成了浅克隆,那么就剩一步了,但是还是得仔细说一下,有个问题——现在的clone怎么就不深了?因为它只复制了第一层,看个例子

//如果只有一层没事,那么多一层呢?
let arr1=[
  1,2,
  {a: 12, b: 5}
];
let arr2=clone(arr1);

console.log(arr1);
console.log(arr2);

25

看起来挺好啊,没问题啊,真的吗?

来改点东西试试

let arr1=[
  1,2,
  {a: 12, b: 5}
];
let arr2=clone(arr1);

arr1[0]++; //注意:改动的是第1层

console.log(arr1);
console.log(arr2);

26

好像也没问题啊,是的,因为咱们还没有改里面的东西,数组本身是复制过的,当然没事

看看里面吧:

let arr1=[
  1,2,
  {a: 12, b: 5}
];
let arr2=clone(arr1);

arr1[2].b=99; //注意,改的是第2层了,是数组里的json对象

console.log(arr1);
console.log(arr2);

27

完蛋了

为啥啊?

因为你确实只复制了一层,里面的json还是同一个对象,我们来分析一下

let arr1=[
  1,2,
  {a: 12, b: 5}
];


let arr2=clone(arr1);
//其实等价于...,因为我们的clone就是这么实现的
let arr2=[...arr1];
//而...其实跟for没什么区别,都是一个个拿过去,换句话说,等价于
let arr2=[];
arr2[0]=arr1[0]; //1,没事,基本类型嘛
arr2[1]=arr1[1]; //2,也没事
arr2[2]=arr1[2]; //{a: 12, b: 5},完蛋,json赋值根本不会复制一份

那咋整啊?

简单来说,我们要的是不光复制表层的对象,内部如果是对象,我们也要复制(clone)一下,但是问题是,我们不知道这个对象到底有多少层啊,怎么写?用递归

好了,来看看成品吧

function deepClone(src){
  if(typeof src!='object'){
    //基本类型,不用克隆
    return src;
  }else{
    if(src instanceof Array){
      //return [...src]; 因为我们要每一个都clone一次,所以抛弃...,自己搞循环
      let result=[];
      for(let i=0;i<src.length;i++){
        //result[i]=src[i];  如果这么写,内层还是没复制的,错误
        result[i]=deepClone(src[i]); //内层也clone一次,完事了
      }
      return result;
    }else if(src.constructor===Object){
      //return {...src};  跟数组类似,我们要自己动手复制,抛弃...
      let result={};
      for(let key in src){
        result[key]=deepClone(src[key]); //内层也clone一次
      }
      return result;
    }else{ //3-系统对象或用户自定义对象(需constructor支持)
      return new src.constructor(src); //用它自身的构造器,创建一个以它为蓝本的副本,好玩
    }
  }
}

思路非常简单,重复一下:

  • 碰到基本类型,直接返回,因为它是byValue的,不需要处理
  • 如果碰到json/数组,把它的内容也复制一份,构建新的数组(当然,它内部有可能还有其他数组、json,递归嘛)
  • 如果碰到系统对象和用户定义对象,让它自己搞定自己(自定义对象需要constructor的配合)

挺折腾啊,我们试试效果吧

//反正要玩,多弄几层哈
let obj1={
  name: 'blue',
  items: [
    {type: 'website', value: 'zhinengshe.com'},
    {type: 'favorites', value: 2},
  ],
};

let obj2=deepClone(obj1);

//改点东西试试
obj2.items[1].value=99;

console.log(obj1);
console.log(obj2);

28

这个结果,还是比较稳妥的

但是等等...

你咋不早点说😂

基本明白了对吧,那么我要告诉大家一个惊悚的事实(并不):

绝大部分情况下,其实我们stringify+parse就够了

stringify+parse我们说过,它有适用范围:函数不行、Date不行、这也不行、那也不行,但是你有没有想过,我们绝大部分时候,其实都没有这些东西,只是纯数据而已(数字、字符串、布尔值、数组、json啥的),所以其实这俩能解决大部分问题

看个例子吧

let obj1={
  name: 'blue',
  items: [
    {type: 'website', value: 'zhinengshe.com'},
    {type: 'favorites', value: 2},
  ],
};

let obj2=JSON.parse(JSON.stringify(obj1)); //就这货

//改点东西试试
obj2.items[1].value=99;

console.log(obj1);
console.log(obj2);

29

所以结论就是:大部分时候stringify+parse,有特殊数据时(Date、TypedArray啥的)用deepClone,完事

总结

是时候梳理一遍Blue讲过的东西了,那么首先

15-三连

  • 深克隆完全复制;浅克隆性能高
  • JS(以及绝大部分语言中)存在两种数据存储方式——值和引用
    • 值:存储数据本身,包括所有基本类型
    • 引用:存储数据的地址,包括所有对象
  • 在赋值时,会复制;引用在赋值时,不会复制数据本身,需手动复制
  • 复制时,我们需要分情况对待
    • 基本类型:不用管
    • 数组、json:循环或...
    • 系统类型:利用构造器复制new Date(old)
    • 用户类型:需在constructor中提前植入复制自己的代码
  • 深克隆,就是通过递归一层层的clone每个数据
  • 绝大部分情况下,其实stringify+parse就够了,除非有特殊的类型(Date啥的)才需要折腾一通deepClone

有bug?想补充?

感谢大家观看这篇教程,有任何问题或想和Blue交流,请直接留言,发现文章有任何不妥之处,也请指出,提前感谢