在前端开发过程中常常会听到对象的深拷贝与浅拷贝,对于初学者来说,可能是傻傻的分不清楚,本人将详细介绍javascript中对象的深拷贝与浅拷贝。
浅拷贝:当拷贝完一个对象的时候,其中一个对象的数据发生了变化,另一个对象的数据也会发生变化,因为浅拷贝拷贝的是索引
深拷贝:当拷贝完一个对象的时候,其中一个对象的数据发生了变化,另外一个对象的数据不会发生变化,因为深拷贝拷贝的是数值,拷贝后的引用数据类型,是真实的开辟了一块新的内存空间
一、javascript中的数据类型
基础数据类型
字符类型
数字类型
布尔类型
引用数据类型(地址的引用)
对象
数组
1、自己实现浅拷贝
let obj1 = {
name: '张三',
gender: '男',
bookList: ['张三', '李四']
};
let obj2 = {};
for (let key in obj1) {
obj2[key] = obj1[key];
}
obj1.name = '李四';
obj1.bookList.push('王五');
console.log(obj1);
console.log(obj2);
2、关于浅拷贝的问题
对象的浅拷贝如果是针对于基础数据类型来说,可以拷贝,修改后不会出现问题。
js中引用数据类型(数组、对象)仅仅是地址的拷贝,如果修改了之前的数据,拷贝后的数据也会修改。
Object.assign(target, [source1, source2, ...])
- 如果目标对象与源对象有同名属性,则后面的属性会覆盖前面的属性
- 如果只有一个参数,则直接返回该参数。即Object.assign(obj) === obj
- 如果第一个参数不是对象,而是基本数据类型(Null、Undefined除外),则会调用对应的基本包装类型
- 如果第一个参数是Null和Undefined,则会报错;如果Null和Undefined不是位于第一个参数,则会略过该参数的复制
要实现对象的浅复制,可以使用Object.assign方法
let target = {a: 123};
let source1 = {b: 456};
let source2 = {c: 789};
let obj = Object.assign(target, source1, source2);
console.log(obj);
不过对于深复制来说,Object.assign方法无法实现
let target = {a: 123};
let source1 = {b: 456};
let source2 = {c: 789, d: {e: 'lovely'}};
let obj = Object.assign(target, source1, source2);
source2.d.e = 'peaceful';
console.log(obj);// {a: 123, b: 456, c: 789, d: {e: 'peaceful'}}
从上面代码中可以看出,source2对象中e属性的改变,仍然会影响到obj对象
三、深拷贝
深拷贝相对于浅拷贝来说,对于拷贝后的引用数据类型,是真实的开辟了一块新的内存空间,修改了之前的旧数据,拷贝后的数据也不会变化。
1、实现一个深拷贝的方法
let obj1 = {
name: '张三',
age: 20,
bookList: ['三国演义', '西游记'],
goodFriends: {
name: '李四',
age: 19,
bookList: ['java']
},
birthday: new Date()
}
/**
* 对象的深拷贝
* @param toObj 要拷贝到的对象
* @param fromObj 从哪个对象中拷贝
*/
function deepCopyObject(toObj, fromObj) {
// 这个地方判断只要是对象就可以,不存在数组、对象、时间之分
const _isObject = (obj) => Object.is(typeof obj, 'object');
for (let key in fromObj) {
let fromObjValue = fromObj[key];
// 如果不是对象就直接拷贝到新的对象上
if (!_isObject(fromObjValue)) {
toObj[key] = fromObjValue;
} else {
// 创建一个新对象
let temObj = new fromObjValue.constructor;
// 递归调用
deepCopyObject(temObj, fromObjValue);
toObj[key] = temObj;
}
}};
let newObj = {};
deepCopyObject(newObj, obj1);
obj1.bookList.push('红楼梦');
console.log(newObj);
console.log(obj1);
四、使用JSON.parse()与JSON.stringify()对对象进行拷贝
var clone = function (obj) {
return JSON.parse(JSON.stringify(obj));
}
五、在实际项目开发过程中使用lodashjs
- 1、深拷贝使用cloneDeep方法
Object.assign()
ES6中拷贝对象的方法,接受的第一个参数是拷贝的目标,剩下的参数是拷贝的源对象(可以是多个)
语法:Object.assign(target, ...sources)
var target = {};
var source = {a:1};
Object.assign(target ,source);
console.log(target); //{a:1}
source.a = 2;
console.log(source); //{a:2}
console.log(target); //{a:1}
Object.assign是一个浅拷贝,它只是在根属性(对象的第一层级)创建了一个新的对象,但是对于属性的值是仍是对象的话依然是浅拷贝,
Object.assign还有一些注意的点是:
- 不会拷贝对象继承的属性
- 不可枚举的属性
- 不会拷贝属性的数据属性/访问器属性
- 可以拷贝Symbol类型
可以理解为Object.assign就是使用简单的=来赋值,遍历从右往左遍历源对象(sources)的所有属性用 = 赋值到目标对象(target)上
var obj1 = {
a:{
b:1
},
sym:Symbol(1)
};
Object.defineProperty(obj1,'innumerable',{
value:'不可枚举属性',
enumerable:false
});
var obj2 = {};
Object.assign(obj2,obj1)
obj1.a.b = 2;
console.log('obj1',obj1);
console.log('obj2',obj2);
image
可以看到Symbol类型可以正确拷贝,但是不可枚举的属性被忽略了并且改变了obj1.a.b的值,obj2.a.b的值也会跟着改变,说明依旧存在访问的是堆内存中同一个对象的问题
扩展运算符
利用扩展运算符可以在构造字面量对象时,进行克隆或者属性拷贝
语法:var cloneObj = { ...obj };
var obj = {a:1,b:{c:1}}
var obj2 = {...obj};
obj.a=2;
console.log(obj); //{a:2,b:{c:1}}
console.log(obj2); //{a:1,b:{c:1}}
obj.b.c = 2;
console.log(obj); //{a:2,b:{c:2}}
console.log(obj2); //{a:1,b:{c:2}}
扩展运算符Object.assign()有同样的缺陷,对于值是对象的属性无法完全拷贝成2个不同对象,但是如果属性都是基本类型的值的话,使用扩展运算符更加方便
Array.prototype.slice()
slice() 方法返回一个新的数组对象,这一对象是一个由begin和end(不包括end)决定的原数组的浅拷贝。原始数组不会被改变。
语法: arr.slice(begin, end);
在ES6以前,没有剩余运算符,Array.from的时候可以用 Array.prototype.slice将arguments类数组转为真正的数组,它返回一个浅拷贝后的的新数组
Array.prototype.slice.call({0: "aaa", length: 1}) //["aaa"]
let arr = [1,2,3,4]
console.log(arr.slice() = = = arr); //false
深拷贝
浅拷贝只在根属性上在堆内存中创建了一个新的的对象,复制了基本类型的值,但是复杂数据类型也就是对象则是拷贝相同的地址,而深拷贝则是对于复杂数据类型在堆内存中开辟了一块内存地址用于存放复制的对象并且把原有的对象复制过来,这2个对象是相互独立的,也就是2个不同的地址
将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象
一个简单的深拷贝
var obj1 = {
a: {
b: 1
},
c: 1
};
var obj2 = {};
obj2.a = {}
obj2.c = obj1.c
obj2.a.b = obj1.a.b;
console.log(obj1); //{a:{b:1},c:1};
console.log(obj2); //{a:{b:1},c:1};
obj1.a.b = 2;
console.log(obj1); //{a:{b:2},c:1};
console.log(obj2); //{a:{b:1},c:1};
在上面的代码中,我们新建了一个obj2对象,同时根据obj1对象的a属性是一个引用类型,我们给obj2.a的值也新建一个新对象(即在内存中新开辟了一块内存地址),然后把obj1.a.b属性的值数字1复制给obj2.a.b,因为数字1是基本类型的值,所以改变obj1.a.b的值后,obj2.a不会收到影响,因为他们的引用是完全2个独立的对象,这就完成了一个简单的深拷贝
JSON.stringify()
JSON.stringify()是目前前端开发过程中最常用的深拷贝方式,原理是把一个对象序列化成为一个JSON字符串,将对象的内容转换成字符串的形式再保存在磁盘上,再用JSON.parse()反序列化将JSON字符串变成一个新的对象
var obj1 = {
a:1,
b:[1,2,3]
}
var str = JSON.stringify(obj1)
var obj2 = JSON.parse(str)
console.log(obj2); //{a:1,b:[1,2,3]}
obj1.a=2
obj1.b.push(4);
console.log(obj1); //{a:2,b:[1,2,3,4]}
console.log(obj2); //{a:1,b:[1,2,3]}
通过JSON.stringify实现深拷贝有几点要注意
- 拷贝的对象的值中如果有函数,undefined,symbol则经过JSON.stringify()序列化后的JSON字符串中这个键值对会消失
- 无法拷贝不可枚举的属性,无法拷贝对象的原型链
- 无法拷贝对象的循环应用(即obj[key] = obj)
- 拷贝Date引用类型会变成字符串
- 拷贝RegExp引用类型会变成空对象
- 对象中含有NaN、Infinity和-Infinity,则序列化的结果会变成null
function Obj() {
this.func = function () {
alert(1)
};
this.obj = {a:1};
this.arr = [1,2,3];
this.und = undefined;
this.reg = /123/;
this.date = new Date(0);
this.NaN = NaN
this.infinity = Infinity
this.sym = Symbol(1)
}
var obj1 = new Obj();
Object.defineProperty(obj1,'innumerable',{
enumerable:false,
value:'innumerable'
})
console.log('obj1',obj1);
var str = JSON.stringify(obj1);
var obj2 = JSON.parse(str);
console.log('obj2',obj2);
打印出来的结果如下
image
可以看到除了Object对象和数组其他基本都和原来的不一样,obj1的constructor是Obj(),而obj2的constructor指向了Object(),而对于循环引用则是直接报错了
虽说通过JSON.stringify()方法深拷贝对象也有很多无法实现的功能,但是对于日常的开发需求(对象和数组),使用这种方法是最简单和快捷的
当然,使用这种方式实现深复制有一个缺点就是必须给JSON.parse方法传入的字符串必须是合法的JSON,否则会抛出错误
使用第三方库实现对象的深拷贝
1.lodash
2.jQuery
以上2个第三方的库都很好的封装的深拷贝的方法,有兴趣的同学可以去深入研究一下
jQuery.extend || jQuery.fn.extend
jQuery.extend对象,对使用jQuery超过一定时间的朋友来说并不默认。这个$.extend方法可以用来扩展jQuery的全局对象,而$.fn.extend方法可以用来扩展实例对象。fn实际上是prototype对象的别名,所以,扩展实例对象的方法实际上就是在jQuery原型对象上添加一些方法。
$.extend方法不仅可以用来写jQuery插件,同样的,它可以用来实现对象的深浅复制。(使用$.extend与$.fn.extend实现深浅复制都可以,唯一的差别就是this的指向性不同)
在具体分析源代码之前,我在源码中看到的$.extend方法的一些特点
- 当不接受任何参数时,直接返回一个空对象
- 当只有一个参数时(这个参数可以任何数据类型(Null、Undefined、Boolean、String、Number、Object)),会返回this对象,这里会分为两种情况。如果用$.extend,会返回jQuery对象;如果用$.fn.extend,会返回jQuery的原型对象。
- 当接收两个参数时,并且第一个参数是Boolean值时,也会返回一个空对象。如果第一个参数不是Boolean值,那么会将源对象复制到目标对象
- 当接收三个参数以上时,可以分为两种情况。如果第一个参数是Boolean值表示深浅复制,那么目标对象会移动到第二个参数,源对象会移动到第三个参数。(目标对象、源对象和Object.assign方法中的相同)。如果第一个参数不是Boolean值,那么用法与Object.assign方法常规的复制相同。
- 在循环源对象的过程中,任何数据类型为Null、Undefined或者源对象是一个空对象时,在复制的过程中都会被忽略。
- 如果源对象和目标对象具有同名的属性,则源对象的属性会覆盖掉目标对象中的属性。如果同名属性是一个对象的话,则会在deep=true等其他条件下向目标对象的该同名对象添加属性
下面贴出jQuery-2.1.4中jQuery.extend实现方式的源代码
jQuery.extend = jQuery.fn.extend = function() {
var options, name, src, copy, copyIsArray, clone,
target = arguments[0] || {},
// 使用||运算符,排除隐式强制类型转换为false的数据类型
// 如'', 0, undefined, null, false等
// 如果target为以上的值,则设置target = {}
i = 1,
length = arguments.length,
deep = false;
// 当typeof target === 'boolean'时
// 则将deep设置为target的值
// 然后将target移动到第二个参数,
if (typeof target === "boolean") {
deep = target;
// 使用||运算符,排除隐式强制类型转换为false的数据类型
// 如'', 0, undefined, null, false等
// 如果target为以上的值,则设置target = {}
target = arguments[i] || {};
i++;
}
// 如果target不是一个对象或数组或函数,
// 则设置target = {}
// 这里与Object.assign的处理方法不同,
// assign方法会将Boolean、String、Number方法转换为对应的基本包装类型
// 然后再返回,
// 而extend方法直接将typeof不为object或function的数据类型
// 全部转换为一个空对象
if (typeof target !== "object" && !jQuery.isFunction(target)) {
target = {};
}
// 如果arguments.length === 1 或
// typeof arguments[0] === 'boolean', 且存在arguments[1],
// 这时候目标对象会指向this
// this的指向哪个对象需要看是使用$.fn.extend还是$.extend
if (i === length) {
target = this;
// i-- 表示不进入for循环
i--;
}
// 循环arguments类数组对象,从源对象开始
for (; i < length; i++) {
// 针对下面if判断
// 有一点需要注意的是
// 这里有一个隐式强制类型转换 undefined == null 为 true
// 而undefined === null 为 false
// 所以如果源对象中数据类型为Undefined或Null
// 那么就会跳过本次循环,接着循环下一个源对象
if ((options = arguments[i]) != null) {
// 遍历所有[[emuerable]] === true的源对象
// 包括Object, Array, String
// 如果遇到源对象的数据类型为Boolean, Number
// for in循环会被跳过,不执行for in循环
for (name in options) {
// src用于判断target对象是否存在name属性
src = target[name];
// 需要复制的属性
// 当前源对象的name属性
copy = options[name];
// 这种情况暂时未遇到..
// 按照我的理解,
// 即使copy是同target是一样的对象
// 两个对象也不可能相等的..
if (target === copy) {
continue;
}
// if判断主要用途:
// 如果是深复制且copy是一个对象或数组
// 则需要递归jQuery.extend(),
// 直到copy成为一个基本数据类型为止
if (deep && copy && (jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)))) {
// 深复制
if (copyIsArray) {
// 如果是copy是一个数组
// 将copyIsArray重置为默认值
copyIsArray = false;
// 如果目标对象存在name属性且是一个数组
// 则使用目标对象的name属性,否则重新创建一个数组,用于复制
clone = src && jQuery.isArray(src) ? src : [];
} else {
// 如果目标对象存在name属性且是一个对象
// 则使用目标对象的name属性,否则重新创建一个对象,用于复制
clone = src && jQuery.isPlainObject(src) ? src : {};
}
// 因为深复制,所以递归调用jQuery.extend方法
// 返回值为target对象,即clone对象
// copy是一个源对象
target[name] = jQuery.extend(deep, clone, copy);
} else if (copy !== undefined) {
// 浅复制
// 如果copy不是一个对象或数组
// 那么执行elseif分支
// 在elseif判断中如果copy是一个对象或数组,
// 但是都为空的话,排除这种情况
// 因为获取空对象的属性会返回undefined
target[name] = copy;
}
}
}
}
// 当源对象全部循环完毕之后,返回目标对象
return target;
};
因此,可以针对分析过后的源码,给出一些例子
let obj1 = $.extend();console.log(obj1); // 返回一个空对象 {}
let obj2 = $.extend(undefined);
console.log(obj2); //返回jQuery对象,Object.assign传入undefined会报错
let obj3 = $.extend('123');
console.log(obj3); // 返回jQuery对象,Object.assign传入'123'会返回字符串的String对象
let target = {
a: 123,
b: 234
};
let source1 = {
b: 456,
d: ['keith', 'peaceful', 'lovely']
};
let source2 = {c: 789};
let source3 = {};
let obj4 = $.extend(target, source1, source2);// let obj4 = $.extend(false, target, source1, source2);
console.log(obj4); // {a: 123, b: 456, d: Array(3), c: 789}// 默认情况下,复制方式都是浅复制// 如果只需要浅复制,不传入deep参数也可以// 浅复制时,obj4对象中的d属性只是指向数组对象的指针
let obj5 = $.extend(target, undefined, source2);
let obj6 = $.extend(target, source3, source2);
console.log(obj5, obj6);// {a: 123, b: 234, c: 789}, {a: 123, b: 234, c: 789}// 会略过空对象或Undefined、Null值
let obj7 = $.extend(true, target, source1, source2);
console.log(obj7); // {a: 123, b: 456, d: Array(3), c: 789}// 这里target对象有b属性,源对象source1也有b属性// 此时源对象的b属性会覆盖目标对象的b属性// 这里deep=true,属于深复制// 当name=d时,会递归调用$.extend, 直到它的属性对应的属性值全部为基本数据类型// 源对象的改变不会影响到obj7对象
JavaScript 复制对象
因此,可以根据$.extend方法,写出一个通用的实现对象深浅复制的函数,copyObject函数唯一的不同就是当i === arguments.length属性时,copyObject函数直接返回了target对象
function copyObject () {
let i = 1,target = arguments[0] || {},deep = false,length = arguments.length,name, options, src, copy,copyIsArray, clone;
// 如果第一个参数的数据类型是Boolean类型
// target往后取第二个参数
if (typeof target === 'boolean') {
deep = target;
// 使用||运算符,排除隐式强制类型转换为false的数据类型
// 如'', 0, undefined, null, false等
// 如果target为以上的值,则设置target = {}
target = arguments[1] || {};
i++;
}
// 如果target不是一个对象或数组或函数
if (typeof target !== 'object' && !(typeof target === 'function')) {
target = {};
}
// 如果arguments.length === 1 或
// typeof arguments[0] === 'boolean',
// 且存在arguments[1],则直接返回target对象
if (i === length) {
return target;
}
// 循环每个源对象
for (; i < length; i++) {
// 如果传入的源对象是null或undefined
// 则循环下一个源对象
if (typeof (options = arguments[i]) != null) {
// 遍历所有[[emuerable]] === true的源对象
// 包括Object, Array, String
// 如果遇到源对象的数据类型为Boolean, Number
// for in循环会被跳过,不执行for in循环
for (name in options) {
// src用于判断target对象是否存在name属性
src = target[name];
// copy用于复制
copy = options[name];
// 判断copy是否是数组
copyIsArray = Array.isArray(copy);
if (deep && copy && (typeof copy === 'object' || copyIsArray)) {
if (copyIsArray) {
copyIsArray = false;
// 如果目标对象存在name属性且是一个数组
// 则使用目标对象的name属性,否则重新创建一个数组,用于复制
clone = src && Array.isArray(src) ? src : [];
} else {
// 如果目标对象存在name属性且是一个对象
// 则使用目标对象的name属性,否则重新创建一个对象,用于复制
clone = src && typeof src === 'object' ? src : {};
}
// 深复制,所以递归调用copyObject函数
// 返回值为target对象,即clone对象
// copy是一个源对象
target[name] = copyObject(deep, clone, copy);
} else if (copy !== undefined){
// 浅复制,直接复制到target对象上
target[name] = copy;
}
}
}
}
// 返回目标对象
return target;
}
慎用lodash的cloneDeep函数
lodash的cloneDeep函数能够很方便的拷贝对象,但是一旦拷贝一些很复杂的对象就有可能报错。比如用cloneDeep克隆一个vue实例,就有可能包key.charAt is not a Function的错。(正确的拷贝方法是Vue.extend())。一般来说复杂的对象都会内置拷贝方法,使用内置的方法拷贝会比lodash的cloneDeep要安全得多。遇到比较简单的对象我们们应该直接调用clone方法而不是cloneDeep(或者JSON.parse(JSON.stringify(obj))),这样既保险也可以减少性能损耗。
浅拷贝和深拷贝
本质上的原因是对象引用的是地址,直接赋值会吧引用地址也复制给新值。
浅复制只会将对象的各个属性进行依次复制,会把引用地址也复制。
深拷贝是会递归源数据,把新值得引用地址给换掉。
lodash的cloneDeep
入口
<!-- 这两个flog是因为baseClone会被很多方法给调用,这两个适用于区分一些操作 --><!-- 1是是否深度复制,4是是否复制 Symbols类型-->
const CLONE_DEEP_FLAG = 1
const CLONE_SYMBOLS_FLAG = 4
function cloneDeep(value) {
return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG)
}
核心逻辑
function baseClone(value, bitmask, customizer, key, object, stack) {
let result
const isDeep = bitmask & CLONE_DEEP_FLAG
const isFlat = bitmask & CLONE_FLAT_FLAG
const isFull = bitmask & CLONE_SYMBOLS_FLAG
if (customizer) {
result = object ? customizer(value, key, object, stack) : customizer(value)
}
if (result !== undefined) {
return result
}
if (!isObject(value)) {
return value
}
// 判断是否数组
const isArr = Array.isArray(value)
// 获取constructor
const tag = getTag(value)
if (isArr) {
// 初始化一个长度和源相等的数组
result = initCloneArray(value)
// 不是deep就直接复制了事
if (!isDeep) {
return copyArray(value, result)
}
} else {
const isFunc = typeof value == 'function'
// Buffer.isBuffer, 对于buffer对象就直接复制了事
if (isBuffer(value)) {
return cloneBuffer(value, isDeep)
}
if (tag == objectTag || tag == argsTag || (isFunc && !object)) {
// 是否需要继承proto
result = (isFlat || isFunc) ? {} : initCloneObject(value)
if (!isDeep) {
// 这里deepclone的isFlat是0,走copySymbols,这个方法主要是复制源上的Symbols
return isFlat
? copySymbolsIn(value, copyObject(value, keysIn(value), result))
: copySymbols(value, Object.assign(result, value))
}
} else {
// 如果是func或error, WeakMap就直接返回了
if (isFunc || !cloneableTags[tag]) {
return object ? value : {}
}
// 对tag位true的类型进行clone
result = initCloneByTag(value, tag, isDeep)
}
}
// Check for circular references and return its corresponding clone.
// 检查循环引用并返回其相应的克隆。
stack || (stack = new Stack)
const stacked = stack.get(value)
if (stacked) {
return stacked
}
stack.set(value, result)
// 对map递归clone
if (tag == mapTag) {
value.forEach((subValue, key) => {
result.set(key, baseClone(subValue, bitmask, customizer, key, value, stack))
})
return result
}
// 对set递归调用
if (tag == setTag) {
value.forEach((subValue) => {
result.add(baseClone(subValue, bitmask, customizer, subValue, value, stack))
})
return result
}
// 是否是TypedArray类型
if (isTypedArray(value)) {
return result
}
const keysFunc = isFull
? (isFlat ? getAllKeysIn : getAllKeys)
: (isFlat ? keysIn : keys)
const props = isArr ? undefined : keysFunc(value)
arrayEach(props || value, (subValue, key) => {
if (props) {
key = subValue
subValue = value[key]
}
// Recursively populate clone (susceptible to call stack limits).
assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack))
})
return result
}
数组这里主要是会有个exec的特殊数组
function initCloneArray(array) {
const { length } = array
const result = new array.constructor(length)
// Add properties assigned by `RegExp#exec`.
// RegExp.exec返回的特殊数组
if (length && typeof array[0] == 'string' && hasOwnProperty.call(array, 'index')) {
result.index = array.index
result.input = array.input
}
return result
}