欢迎大家来到”深入本质”系列,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 不影响原数据
复制代码
//引用的情况
let a=[1,2,3];
let b=a; //注意:对象->存引用
b.push(5);
console.log(a, b); //[1,2,3,5] [1,2,3,5] 改变了原对象
复制代码
到底什么是引用?
首先,有句话需要大家记住,马上我们来理解它——赋值操作一定会复制数据,但复制的是什么就不一样了,什么意思?
- 存值的数据(例如:数字),存储数据本身,所以复制也是复制数据本身
- 存引用的数据(例如:数组),存储引用,复制的也是引用,但数据本身还是那一份
传值(by value)
传值——变量直接存储数据本身
传引用(by ref)
传引用——变量存储的是数据的地址(既引用),复制也是复制这个地址,而非对象本身
总结
- 赋值操作永远都会复制变量的内容——但复制的是什么就不好说了
- 传值(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);
复制代码
方式2.通用-展开操作(…)
let a=[1,2,3];
let b=[...a]; //其实它内部,也是个循环,不用你写而已
b.push(5);
console.log(a);
console.log(b);
复制代码
方法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);
复制代码
顺便一说,stringify+parse
看起来很美,但它有很多问题,比如:并不是所有数据类型都接受、循环引用会出问题,所以谨慎使用
let a=[
1,2,3,
function (){
console.log(this.length);
}
];
//注意,函数不可以stringify
let b=JSON.parse(JSON.stringify(a));
console.log(b);
复制代码
方式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);
复制代码
方式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}
复制代码
方式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}
复制代码
复制其他对象
说了半天,这个其实才是难点,难在哪儿?”其他对象”太多了——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
复制代码
(哦豁,完蛋)
所以不用想了,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);
复制代码
但是问题是,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);
复制代码
恩。。。发生了啥?虽然成功了,我们到底做啥了
分析一下
其实所有的系统对象,都有两种最基本的创建方法:
- 空白对象:全新的创建一个对象,如:
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);
复制代码
好像,有点意思啊,再多试几个
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]
复制代码
所以,系统对象好办了,直接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();
复制代码
我们来改造它一下,主要是能接收另一个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
复制代码
这就很靠谱了,那么我们是不是可以把这种方法直接用在数组和json上?多方便啊。。。个鬼啊?
数组能用吗?
要是能用该多好啊。。。试试吧
let arr1=[1,2,3];
let arr2=new Array(arr1); //复制arr1
//都不用改,先试试行不行
console.log(arr1); //[1,2,3]
console.log(arr2); //[[1,2,3]] 嗯?什么鬼
复制代码
(浏览器那边的输出不好看,用了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]]
复制代码
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} 嗯?????????
复制代码
简单来说,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);
复制代码
测试全部通过,妥妥儿的
深克隆怎么做?
其实我们已经完成了浅克隆,那么就剩一步了,但是还是得仔细说一下,有个问题——现在的clone
怎么就不深了?因为它只复制了第一层,看个例子
//如果只有一层没事,那么多一层呢?
let arr1=[
1,2,
{a: 12, b: 5}
];
let arr2=clone(arr1);
console.log(arr1);
console.log(arr2);
复制代码
看起来挺好啊,没问题啊,真的吗?
来改点东西试试
let arr1=[
1,2,
{a: 12, b: 5}
];
let arr2=clone(arr1);
arr1[0]++; //注意:改动的是第1层
console.log(arr1);
console.log(arr2);
复制代码
好像也没问题啊,是的,因为咱们还没有改里面的东西,数组本身是复制过的,当然没事
看看里面吧:
let arr1=[
1,2,
{a: 12, b: 5}
];
let arr2=clone(arr1);
arr1[2].b=99; //注意,改的是第2层了,是数组里的json对象
console.log(arr1);
console.log(arr2);
复制代码
完蛋了
为啥啊?
因为你确实只复制了一层,里面的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);
复制代码
这个结果,还是比较稳妥的
但是等等…
你咋不早点说?
基本明白了对吧,那么我要告诉大家一个惊悚的事实(并不):
绝大部分情况下,其实我们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);
复制代码
所以结论就是:大部分时候stringify+parse
,有特殊数据时(Date、TypedArray啥的)用deepClone
,完事
总结
是时候梳理一遍Blue讲过的东西了,那么首先
- 深克隆完全复制;浅克隆性能高
- JS(以及绝大部分语言中)存在两种数据存储方式——值和引用
- 值:存储数据本身,包括所有基本类型
- 引用:存储数据的地址,包括所有对象
值
在赋值时,会复制;引用
在赋值时,不会复制数据本身,需手动复制- 复制时,我们需要分情况对待
- 基本类型:不用管
- 数组、json:循环或…
- 系统类型:利用构造器复制
new Date(old)
- 用户类型:需在constructor中提前植入复制自己的代码
- 深克隆,就是通过递归一层层的clone每个数据
- 绝大部分情况下,其实
stringify+parse
就够了,除非有特殊的类型(Date啥的)才需要折腾一通deepClone
有bug?想补充?
感谢大家观看这篇教程,有任何问题或想和Blue交流,请直接留言,发现文章有任何不妥之处,也请指出,提前感谢