一.前言
每一个前端的 JavaScript 之路不一定是由《JavaScript 高级程序设计》开启的,但是每一位前端都一定被“按值传递”和“按引用传递”这两个概念坑过。现在你我应该都很清楚,在 JavaScript 中的object
类型是按引用传递的,但是在函数参数中,所有参数都是按值传递的。
我们今天要谈的东西,就起源于object
型数据的复制与再操作,简单来说,就是我们今天的主题:对象的浅拷贝和深拷贝。
二.按引用传递是什么含义?
首先,我们需要快速回想一下在 JavaScript 中基本数据类型有哪些,请看下面:
1. number
2. string
3. boolean
4. null
5. undefined
6. symbol
引用数据类型只有一种:
object
当然,由此衍生出来的变种也有很多,包括Array,function
等等,也可以看成是对象的一种。
接着我们得复习下 JavaScript 的堆栈中是怎么存储数据的。在 JS 中内存的使用和分配与其他语言也大同小异:
堆是动态分配内存,内存大小不一,也不会自动释放。栈是自动分配相对固定大小的内存空间,并由系统自动释放。
对以上6种基本数据类型来说,
当我们在 JavaScript 中声明一个简单基础数据类型并初始化它的值时:
const NAME = 'waw';
此时这个值将以键值对的形式key:'NAME', value:'waw'
保存在栈中。
但是对引用类型的数据来说却并不是如此,对它来说,虽然也是以键值对形式存储于栈中,但是value
部分存储的是指向堆中的地址,如:key:'NAME', value: 指向堆的指针
,而value
真正的值则存储在堆中。而在 JavaScript 中是不允许直接操作堆中的内容的,所以我们日常操作对象时实际上操作的是对象的引用。
到这儿为止,咱们的提前知识储备已经差不多了,从上面的内容我们可以达成一个基础的共识,那就是基础数据类型是没有浅拷贝深拷贝之说的,大家都是存储在栈中的社会主义公民,都很平等。所以为了接下来讲解的层次性,我将从数组和对象,基本数据类型和引用数据类型,两种角度出发来给大家讲讲浅拷贝和深拷贝。
三.JavaScript 的浅拷贝
3.1 从基本数据类型的数组角度来说
形如var arr = [1, '1', true, null, undefined, Symbol()]
,这一类数组内元素的集合,我们都可以称它们为基本数据类型的数组。
浅拷贝最简单的就是var a = b
了。举个例子:
var arr = [1, '1', true, null, undefined, Symbol()];
var arrCopy = arr;
arrCopy[0] = 2;
console.log(arr)
console.log(arrCopy)
结果如下:
很明显,这就是个最简单的浅拷贝。当然我们也可以自己实现一个基础的浅拷贝函数:
function shallowClone(obj){
let cloneObj = {};
for(let key in obj){
if(obj.hasOwnProperty(key)){
cloneObj[key] = obj[key];
}
}
return cloneObj;
}
3.2 从引用数据类型的数组角度来说
什么是引用数据类型的数组?举个简单例子,形如[[1, 2], {age: 1}, Number(1), String(2)]
的数组内元素的集合,都可以称为引用数据类型。当然,我们平常在业务中最常见的就是多维数组或者是对象数组。
对它来说,浅拷贝最直接的方式也是var a = b
;举个例子:
var arr = [[1, 2], {age: 1}, Number(1), String(2)];
var arrCopy = arr;
arrCopy[0] = [3, 4];
console.log(arr)
console.log(arrCopy)
结果如下:
3.3 从对象角度来说
从对象角度来说,它就不像数组那样好区分基本数据类型和引用数据类型了,因为毕竟是key=> value
键值对形式来存储的结构。
我们一般也可以通过var a=b
来进行浅拷贝。举个例子:
var arr = {name:'waw', age:1};
var arrCopy = arr;
arrCopy.name = 'gcc';
console.log(arr)
console.log(arrCopy)
结果如下:
这儿有个很有意思的事,ES6有一个Object.assgin()
方法,可以拷贝(合并)对象并返回新对象。我们试试用它来复制上面的对象看会输出什么:
var arr = {name:'waw', age:1};
var arrCopy = Object.assign({}, arr);
arrCopy.name = 'gcc';
console.log(arr)
console.log(arrCopy)
结果如下:
emmm…从结果来看,像是一个深拷贝方法,不着急,我们试试稍微复杂的结构:
var arr = {name:'waw', age:1, love:{ ball: 'football', game: 'tecent'}};
var arrCopy = Object.assign({}, arr);
arrCopy.love.game= 'alibaba';
console.log(arr);
console.log(arrCopy);
结果如下:
你还可以试试其他层次大于一级的对象结构,我们会发现Object.assign()
这个方法在大于一级的对象结构下进行的都是浅拷贝。所以,我们一般是建议在业务里如果想使用深拷贝来处理对象,避免使用Object.assign()
函数。
四.JavaScript 的深拷贝
要谈深拷贝,首先我们要有一个共识,就是何为深拷贝?简单来说,就是对某个对象会不断递归去遍历复杂对象中的每一个层级,最后输出的这样一个过程,可以称之为深拷贝。有了这个前提,我们再来看看深拷贝在数组和对象中的具体实现。
4.1 从基本数据类型的数组角度来说
对于数组的深拷贝来说,其实你可以从这个角度出发:
在 JavaScript 中所有操作数组的方法里,如果此操作是不修改原数组而是返回新数组,那么可以判定这个方法可以做到深拷贝。
接下来我用几个例子来支撑上面这个理论:
var arr = ['a', 'b', 'c'];
var arrCopySlice = arr.slice(0);
var arrCopyConcat = [].concat(arr);
var arrCopyMap = arr.map(el => el);
var arrCopyFilter = arr.filter(el => el);
var arrCopy = [...arr];
arrCopySlice[0] = 'test';
arrCopyConcat[0] = 'test';
arrCopyMap[0] = 'test';
arrCopyFilter[0] = 'test';
arrCopy[0] = 'test';
console.log('arr', arr)
console.log('arrCopySlice', arrCopySlice)
console.log('arrCopyConcat', arrCopyConcat)
console.log('arrCopyMap', arrCopyMap)
console.log('arrCopyFilter', arrCopyFilter)
console.log('arrCopy', arrCopy)
可以看到,我使用了arr.slice(), arr.concat(), arr.map(), arr.filter(), [...arr]
这五个方法,以上5个方法都是满足在不修改原数组的前提下返回新数组
这一原则。输出结果如下:
从输出可以看出,改变了arrCopy
并不会影响原数组arr
。当然,我们还可以换句话来理解:
所谓数组深拷贝,也可以理解为无论此数组内的数据有多少层级,它都可以一层层遍历去获取。
而以上5个例子,我们要注意都是基本数据类型,这意味着大家都是一层的结构,更深的层次,以上5个方法,都做不到深拷贝了,要切记这一点。
4.2 从引用数据类型的数组角度来说
这儿也有个很有意思的事,我们对数组使用Object.assgin()
方法时,也是可以拷贝数组的,但是结果与拷贝对象有所不同:
var arr = [1, '1', {a:11}];
var arrCopy = Object.assign([],arr);
arrCopy[2].a = 22;
console.log(arr)
console.log(arrCopy)
结果如下:
结果与对对象进行 Object.assgin()
操作时不同,为浅拷贝,所以对于数组来说,一级结构也不能使用 Object.assgin()
来进行深拷贝。
对于更深层的数组或是对象(二级结构或以上)来说,我们一般会采取两种思路:
- 序列化和序列化。(即
JSON.parse()
和JSON.stringify()
) - 深递归
所以这两种思路就放在对象的深拷贝里一起讲了。
4.3 从对象角度来说
主要说说两种思路。
第一种,序列化和反序列化
const obj = {a:1, b:'str', c:{name:'waw', age:20}};
let objCopy = JSON.parse(JSON.stringify(obj));
objCopy.c.age = 18;
console.log(obj)
console.log(objCopy)
输出结果如下:
就平常的业务情况来说,肯定是够用的,当然它也不是银弹,它只能处理对象和数组内容,除此之外的 reg
,date
,err
等类型对象都无法处理。
第二种. for in 深递归
function isObject(obj){
return (typeof obj === 'object' || typeof obj === 'function') && obj !== null
}
function deepClone(obj){
let isArray = Array.isArray(obj);
var cloneObj = isArray ? [] : {};
for(let key in obj){
cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
}
return cloneObj;
}
let obj = {a:1, b:2, c:{name:'waw', age:10}};
let copyObj = deepClone(obj);
copyObj.c.age = 20;
console.log('obj', obj)
console.log('copyObj', copyObj)
输出结果如下:
五.深拷贝的特殊情况
5.1 Date
和 RegExp
对象的深拷贝失效
在 MDN 的结构化克隆算法
中有这么一段话:
对应到我们现在的问题来说,意味着error
, Date
, RegExp
以及 Function
对象不能被上面的所深克隆。
举个例子:
let obj = {date:new Date(), reg:/reg/g, func:function(){}};
let copyObj = deepClone(obj);
console.log('obj', obj)
console.log('copyObj', copyObj)
我们可以看到结果:
copyObj
里的属性全是空对象{}
,深拷贝失败。
怎么解决呢?可以通过构建它们对应的构造函数来处理这个问题:
function isObject(obj){
return (typeof obj === 'object' || typeof obj === 'function') && obj !== null
}
function deepClone(obj){
let cloneObj;
let Constructor = obj.constructor;
switch(Constructor){
case RegExp:
cloneObj = new Constructor(obj);
break;
case Date:
cloneObj = new Constructor(Number(obj));
break;
default:
cloneObj = new Constructor();
}
for(let key in obj){
cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
}
return cloneObj;
}
let obj = {date:new Date(), reg:/reg/g};
let copyObj = deepClone(obj);
console.log('obj', obj)
console.log('copyObj', copyObj)
console.log(obj.date === copyObj.date)
console.log(obj.reg === copyObj.reg)
输出如下:
5.2 环对象
什么是环对象?形如下面这种结构就叫环对象:
const obj = {};
obj.private = obj;
我们用上面的那个 deepClone()
函数对此对象进行复制,则会抛出栈溢出的异常:Maximum call stack size exceeded
。
怎么处理呢?其实原理也简单,就是用一个哈希表来存储已经拷贝过的内容,然后在拷贝前对哈希表里的内容进行判断,如果对象已经存在,则直接返回此对象。
function isObject(obj){
return (typeof obj === 'object' || typeof obj === 'function') && obj !== null
}
function deepClone(obj, hash = new WeakMap()){
let cloneObj;
let Constructor = obj.constructor;
if(hash.has(obj)){
return hash.get(obj);
}
switch(Constructor){
case RegExp:
cloneObj = new Constructor(obj);
break;
case Date:
cloneObj = new Constructor(Number(obj));
break;
default:
cloneObj = new Constructor();
hash.set(obj, cloneObj);
}
for(let key in obj){
cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key], hash) : obj[key]
}
return cloneObj;
}
let obj = {date:new Date(), reg:/reg/g};
obj.private = obj;
let copyObj = deepClone(obj);
console.log('obj', obj)
console.log('copyObj', copyObj)
输出结果如下:
我们可以看到,private
中是一个 Circular
对象,即无限自循环对象。拷贝是成功的。
你也可以把上面的代码放到浏览器中执行,就可以看到 Circular
对象的具体内容:
5.3 原型链上的属性
我们知道,函数有protorype
属性,被称为原型链。对象也有原型链,其属性名为__proto__
。如果你想拷贝某个对象__proto__
上的属性,你可以使用for...in
。看个例子:
const obj = {
name:'waw',
age:11
};
const obj2 = Object.create(obj);
for(let key in obj2){
console.log(key)
}
输出结果如下:
我们可以看到,obj2
本是一个空对象,并没有属性,但是 for...in
却打印出了 name
和 age
属性,从上图也可以看出,这两个属性实际上是原型链 __proto__
上的属性。
到这里深拷贝的内容差不多就结束了,其实还有很多可以继续挖掘的地方,比如说:Symbol
类型的深拷贝, Symbol
类型作为 key
时的深拷贝,不可枚举属性的深拷贝,function
类型的深拷贝等等,由于篇幅原因就不在此篇文章中细说了,忙完手里的事我会再接着写一篇后续来补充更深入的内容。