javascript深入系列之浅拷贝与深拷贝

导读

在平时工作中或者面试中都会接触到对数据的复制,下面来实现一个比较完整的功能,提供全部思路,直接进入正题。
复制代码

定义

回顾一下JavaScript中存在两大数据类型:
复制代码
  • 基本数据类型:String、Number、Boolean、Null、Undefined、Symbol(ES6 引入了一种新的原始数据类型,表示独一无二的值)

  • 引用数据类型:Object(Array、Function、RegExp、Date…)

浅拷贝.jpg

  • 浅拷贝:创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。

深拷贝.png

  • 深拷贝:将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,并且在修改新对象不会影响原对象。

浅拷贝

浅拷贝常见简单用法:

  • Object.assign
  • Array.prototype.slice、Array.prototype.concat()
  • ES6拓展运算符

Object.assign

const obj = {
    age: 20,
    array: ['one', 'two'],
    objs: {
        obj1: 'obj1',
        obj2: 'obj2'
    },
    demo: function () {
        console.log('This is demo!')
    }
}
const newObj = Object.assign({}, obj);
复制代码

Array.prototype.slice

const array = [ 1, 2, 3 ];
const copyArray = array.slice(0);
copyArray[1] = "改变新数组";
console.log(copyArray) // [ 1, '改变新数组', 3 ]
console.log(array) // [ 1, 2, 3 ] 
复制代码

Array.prototype.concat()

const array = [ 1, 2, 3 ];
const copyArray = array.concat();
copyArray[1] = "改变新数组";
console.log(copyArray) // [ 1, '改变新数组', 3 ]
console.log(array) // [ 1, 2, 3 ] 
复制代码

ES6拓展运算符

const array = [ 1, 2, 3 ];
const copyArray = [...array];
copyArray[1] = "改变新数组";
console.log(copyArray) // [ 1, '改变新数组', 3 ]
console.log(array) // [ 1, 2, 3 ] 
复制代码

深拷贝

常见的深拷贝:

  • JSON.parse(JSON.stringify())
  • _.cloneDeep() (lodash库中的方法,lodash是一套工具库,内部封装了很多字符串、数组、对象等常见数据类型的处理函数等等)
  • jQuery.extend() (jQuery中的方法)
  • 循环递归克隆数据

JSON.parse(JSON.stringify())

const obj = {
	age: 20,
	array: [ 'one', 'two' ],
	objs: {
		obj1: 'obj1',
		obj2: 'obj2'
	},
	demo: function() {
		console.log('This is demo!');
	},
    undefinedTest: undefined,
    symbolTest:  Symbol('A')
};
const newObj = JSON.parse(JSON.stringify(obj));
console.log(newObj);
// {
//     age: 20,
//     array: [ 'one', 'two' ],
//     objs: { obj1: 'obj1', obj2: 'obj2' }
//  }
// 缺点没有把demo方法/undefinedTest/symbolTest复制,忽略undefined、symbol和函数,所以这个克隆对象不安全,不完善
复制代码

_.cloneDeep()

const _ = require('lodash'); 
const obj1 = {
    a: 1,
    b: { 
        f: { g: 1 } },
    c: [1, 2, 3]
};
const obj2 = _.cloneDeep(obj1);
console.log(obj1.b.f === obj2.b.f); // false
// 等于false表示新对象和原对象不是同一个内存引用地址
复制代码

jQuery.extend()

const $ = require('jquery');
const obj1 = {
    a: 1,
    b: { f: { g: 1 } },
    c: [1, 2, 3]
};
const obj2 = $.extend(true, {}, obj1);
console.log(obj1.b.f === obj2.b.f); // false
// 等于false表示新对象和原对象不是同一个内存引用地址
复制代码

循环递归

既然是深度克隆,那就建一个新对象,然后把需要被克隆对象的每一个值都复制给新对象。

简单版本

const obj = {
	test: '我是文字',
	age: 20,
    name: undefined,
	array: [ 'one', 'two', [ 1, 2 ] ],
	objs: {
		obj1: 'obj1',
		obj2: {
			obj2_1: 'obj2_1'
		}
	}
};

function deepClone(data) {
	let cloneData = {};
	for (const key in data) {
		cloneData[key] = data[key];
	}
	return cloneData;
}
const newObj1 = deepClone(obj);
console.log(newObj1);
// {
//   test: '我是文字',
//   age: 20,
//   name: undefined,
//   array: [ 'one', 'two', [ 1, 2 ] ],
//   objs: { obj1: 'obj1', obj2: { obj2_1: 'obj2_1' } }
// }
复制代码

简单版本版本就完成了。表面上可以打印新对象跟原来的对象一样了。但是我们上述说了,深度克隆取决于新对象和原来的对象是否共用同一个内存的地址。所以我改一下原对象某一个值,是否影响了新对象。

const obj = {
	test: '我是文字',
	age: 20,
	name: undefined,
	array: [ 'one', 'two', [ 1, 2 ] ],
	objs: {
		obj1: 'obj1',
		obj2: {
			obj2_1: 'obj2_1'
		}
	}
};

function deepClone(data) {
	let cloneData = {};
	for (const key in data) {
		cloneData[key] = data[key];
	}
	return cloneData;
}
const newObj1 = deepClone(obj);
console.log(newObj1);
// {
//   test: '我是文字',
//   age: 20,
//   name: undefined,
//   array: [ 'one', 'two', [ 1, 2 ] ],
//   objs: { obj1: 'obj1', obj2: { obj2_1: 'obj2_1' } }
// }
obj.age = 18;
obj.objs.obj2 = null; // 改原对象某一个值,看看是否影响新对象。
console.log(newObj1)
// {
//   test: '我是文字',
//   age: 20,
//   name: undefined,
//   array: [ 'one', 'two', [ 1, 2 ] ],
//   objs: { obj1: 'obj1', obj2: null } // obj2: null 数据被改变了
// }
复制代码

上述我们也看到了,修改了原对象数据【obj.age和obj.objs.obj2】,其中只有obj.objs.obj2数据变了,影响了新对象的数据。表示obj.objs.obj2这个数据,新对象和原对象在内存中还是共用了同一个地址。对于这种对象中还有多层数据的,我们使用递归遍历克隆数据到新对象。总结一下:

  • 基本数据类型,不用继续拷贝,直接返回
  • 引用数据类型,创建新对象,遍历需要克隆的数据添加到新对象,如果有多层对象的,并将需要克隆的对象继续深度克隆后添加给新对象,这里就使用递归实现。

那就继续完善一下代码

const obj = {
	test: '我是文字',
	age: 20,
	name: undefined,
	array: [ 'one', 'two', [ 1, 2 ] ],
	objs: {
		obj2: {
			obj2_1: 'obj2_1'
		}
	}
};
function deepClone(data) {
    // 非对象直接返回对应的值(可能是普通的值、函数、正则等等)
	if (typeof data !== 'object') return data;
	let cloneData = {};
	for (const key in data) {
		cloneData[key] = deepClone(data[key]);
	}
	return cloneData;
}
const newObj1 = deepClone(obj);
console.log(newObj1);
// {
//     test: '我是文字',
//     age: 20,
//     name: undefined,
//     array: { '0': 'one', '1': 'two', '2': { '0': 1, '1': 2 } },
//     objs: { obj2: { obj2_1: 'obj2_1' } }
//  }
obj.age = 18;
obj.objs.obj2 = null;
console.log(newObj1);
// {
//     test: '我是文字',
//     age: 20,
//     name: undefined,
//     array: { '0': 'one', '1': 'two', '2': { '0': 1, '1': 2 } },
//     objs: { obj2: { obj2_1: 'obj2_1' } }
//   }
复制代码

原对象改动了数据【obj.objs.obj2】,新对象 newObj1 不受影响。说明方法对象之间没有共用同一个内存地址,但是方法还有很多不完善,例如数组还没完善,那么我们下面继续完善。

兼容数组

上述方法只考虑了数据是object的情况下,在js中数组用typeof方法来判断数组,也是显示object,那么我们继续来完善一下方法:

function deepClone(data) {
	// 非对象直接返回对应的值(可能是普通的值、函数、正则等等)
	if (typeof data !== 'object') return data;
    // Object.prototype.toString.call(data) 和 Array.isArray 都可以用来判断数据是否数组
	// let cloneData = Array.isArray(data) === '[object Array]' ? [] : {};
	let cloneData = Object.prototype.toString.call(data) === '[object Array]' ? [] : {};
	for (const key in data) {
		cloneData[key] = deepClone(data[key]);
	}
	return cloneData;
}
复制代码

循环引用

我们来看一个例子:

const obj = {
	test: '我是文字',
	age: 20,
	name: undefined,
	array: [ 'one', 'two', [ 1, 2 ] ],
	objs: {
		obj2: {
			obj2_1: 'obj2_1'
		}
	}
};
obj.objs = obj;
// 我们把方法改一下这样引用。然后执行方法
const newObj1 = deepClone(obj);
console.log(newObj1);
// 控制台看到
// RangeError: Maximum call stack size exceeded // 代表堆栈溢出
复制代码

运行一下可以看到下图:

堆栈溢出.png

一般看到这种情况是堆栈溢出,原因是对象属性引用了自身的地址,循环引用了。由于直接和间接引用了自身,在克隆对象的时候,就不断的循环创建一块内存地址来存放数据,导致堆栈溢出。

解决这种情况,其实我们可以首先创建一个公共的内存空间来放这个数据,存储当前的对象和引用对象之间的关系,在需要拷贝的时候,首先去存储的公共内存空间找一下,这个公共空间有没拷贝过数据,有的话不用拷贝了,没有的话再拷贝。这样就避免了,一直循环引用,不断创建新的内存空间,导致堆栈溢出。

根据上述描述,这个公共的存储空间,就需要用到 key – value 这种数据结构形式,obj[objData] = objData; 其中objData是一个对象,由于JavaScript 的对象(Object),本质上是键值对的集合(Hash 结构),但是传统上只能用字符串当作键。这给它的使用带来了很大的限制。所以我们可以考虑ES6中的 map 数据结构

map数据结构:它类似于对象,也是键值对的集合,但是“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。也就是说,Object 结构提供了“字符串—值”的对应,Map 结构提供了“值—值”的对应,是一种更完善的 Hash 结构实现。如果你需要“键值对”的数据结构,Map 比 Object 更合适。

Map结构有以下2种方法:

  • Map:

    • 1、本质上是键值对的集合,类似集合。
    • 2、可以遍历,方法很多可以跟各种数据格式转换。
  • WeakMap:

    • 1、只接受对象作为键名(null除外),不接受其他类型的值作为键名。
    • 2、键名是弱引用,键值可以是任意的,键名所指向的对象可以被垃圾回收,此时键名是无效的。
    • 3、不能遍历,方法有get、set、has、delete。

Map与WeakMap:WeakMap相对Map是弱引用(在计算机程序设计中,弱引用与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,并因此可能在任何时刻被回收), 可以看出来WeakMap在性能方面比Map更胜一筹,在浏览器中垃圾回收机制用来清理不会再使用的对象,在某个时刻不用的时候自动被回收了,从而降低内存泄露和数据损坏的可能性。

举个例子:Map

const goodBoy = { 
    name: '程序员自来耶', 
    age: 18 
};
const mapObj = new Map();
mapObj.set(goodBoy, '程序员自来耶');
goodBoy = null;
复制代码

上述使用 Map 创建对象,并引用了对象 goodBoy作为键,存在一个强关联关系,虽然把goodBoy置为null,但是mapObj依然对goodBoy还是一个强引用关系,内存还是一直没有释放。

举个例子:WeakMap

const goodBoy = { 
    name: '程序员自来耶', 
    age: 18 
};
const mapObj = new WeakMap();
mapObj.set(goodBoy, '程序员自来耶');
goodBoy = null;
复制代码

上述使用 WeakMap 创建对象,并引用了对象 goodBoy作为键,存在弱引用关系,不需要使用的时候,在下一次垃圾回收机制,浏览器会自动回收,内存得到释放。

继续完善一下代码

const obj = {
	test: '我是文字',
	age: 20,
	name: undefined,
	array: [ 'one', 'two', [ 1, 2 ] ],
	objs: {
		obj2: {
			obj2_1: 'obj2_1'
		}
	}
};
obj.objs = obj;
function deepClone(data, map = new WeakMap()) {
	// 非对象直接返回对应的值
	if (typeof data !== 'object') return data;
	// Object.prototype.toString.call(data) 和 Array.isArray 都可以用来判断数据是否数组
	// let cloneData = Array.isArray(data) === '[object Array]' ? [] : {};
	let cloneData = Object.prototype.toString.call(data) === '[object Array]' ? [] : {};
    // 内存公共空间是否有复制过的数据,有就直接返回
    if (map.get(data)) return map.get(data);
    // 存在公共空间对象数据
    map.set(data, cloneData);
	for (const key in data) {
		cloneData[key] = deepClone(data[key], map); // 这里记得存 map
	}
	return cloneData;
}
const newObj1 = deepClone(obj);
复制代码
结论:假设数据量很大的情况下,在考虑性能方面上,优先选用WeakMap,避免过多的消耗,毕竟这也算JavaScript性能优化的一部分,能优化则优化的原则,让我们写的程序达到最优。
复制代码

兼容各种数据类型

目前只对普通object和array进行了拷贝。还有其它详细的数据类型没有拷贝,我们详细的列出来,一个一个分别归类进行拷贝。归类的前提需要对数据进行类型判断,我们使用 Object.prototype.toString.call 进行判断。

// Boolean 类型,tag 为 "Boolean"
Object.prototype.toString.call(true);            // => "[object Boolean]"

// Number 类型,tag 为 "Number"
Object.prototype.toString.call(1);               // => "[object Boolean]"

// String 类型,tag 为 "String"
Object.prototype.toString.call("");              // => "[object String]"

// Array 类型,tag 为 "String"
Object.prototype.toString.call([]);              // => "[object Array]"

// Arguments 类型,tag 为 "Arguments"
Object.prototype.toString.call((function() {
return arguments;
})());                                           // => "[object Arguments]"

// Function 类型, tag 为 "Function"
Object.prototype.toString.call(function(){});    // => "[object Function]"

// Error 类型(包含子类型),tag 为 "Error"
Object.prototype.toString.call(new Error());     // => "[object Error]"

// RegExp 类型,tag 为 "RegExp"
Object.prototype.toString.call(/\d+/);           // => "[object RegExp]"

// Date 类型,tag 为 "Date"
Object.prototype.toString.call(new Date());      // => "[object Date]"

// 其他类型,tag 为 "Object"
Object.prototype.toString.call(new class {});    // => "[object Object]

...

// 获取数据类型
function getType(data) {
	return Object.prototype.toString.call(data);
}
复制代码

那我整理一下标识,这些都是平常用,其实还有更多的,有兴趣的话,自己再查详细点文档,这里不展开

// 可遍历的类型
const mapTag = '[object Map]';
const setTag = '[object Set]';
const arrayTag = '[object Array]';
const objectTag = '[object Object]';
const argsTag = '[object Arguments]';

// 不可遍历的类型
const booleanTag = '[object Boolean]';
const dateTag = '[object Date]';
const errorTag = '[object Error]';
const numberTag = '[object Number]';
const regexpTag = '[object RegExp]';
const stringTag = '[object String]';
const symbolTag = '[object Symbol]';
...
复制代码

上述判断数据类型,出现可遍历属性和不可遍历属性,主要是可遍历属性我们需要用到这些对象原型prototype方法和构造函数constructor,需要遍历这些对象原型上和构造函数上的方法。下面我们就用 constructor这种方式来获取。

function getInit(data) {
    const newCtor = data.constructor;
    return new newCtor();
}
复制代码

继续改造

const map = new Map();
map.set('flag', '程序员自来耶');
const set = new Set();
set.add('程序员自来耶');

const obj = {
	test: '我是文字',
	age: 20,
	name: undefined,
	nullFlag: null,
	array: [ 'one', 'two', [ 1, 2 ] ],
	objs: {
		obj2: {
			a: 1
		}
	},
	map,
	set
};
// 可遍历类型
const arrayTag = '[object Array]';
const objectTag = '[object Object]';
const mapTag = '[object Map]';
const setTag = '[object Set]';
const argsTag = '[object Arguments]';


// 不可遍历类型
const numberTag = '[object Number]';
const stringTag = '[object String]';
const booleanTag = '[object Boolean]';
const dateTag = '[object Date]';

const symbolTag = '[object Symbol]';
const errorTag = '[object Error]';
const regexpTag = '[object RegExp]';
const funcTag = '[object Function]';
// ....
// 可遍历类型数据标识
const deeptTagList = [ mapTag, setTag, arrayTag, objectTag, argsTag ];
// 获取数据类型
function getType(data) {
	return Object.prototype.toString.call(data);
}
// 初始类型
function getInit(data) {
	const NewCtor = data.constructor;
	return new NewCtor();
}
// 克隆正则
function deepCloneReg(obj) {
    const data = new obj.constructor(obj.source, /\w*$/.exec(targe));
    data.lastIndex = obj.lastIndex;
    return data;
}
// 其它类型
function otherType(obj, type) {
    const NewCtor = obj.constructor;
    switch (type) {
        case boolTag:
        case numberTag:
        case stringTag:
        case errorTag:
        case symbolTag:
        case dateTag:
            return new NewCtor(obj);
        case regexpTag:
            return deepCloneReg(obj);
        default:
            return null;
    }
}
function deepClone(data, map = new WeakMap()) {
	// 非对象直接返回对应的值
	if (typeof data !== 'object' || !data) return data;
	let cloneData = null;
	// 判断数据类型
	const type = getType(data);
	// 判断是否可遍历类型
	if (deeptTagList.includes(type)) {
		cloneData = getInit(data, type);
	}
	// 克隆map || 克隆set
	if (type === mapTag || type === setTag) {
		data.forEach((val, key) => {
			if (type === mapTag) {
				// 克隆map
				cloneData.set(key, deepClone(val, map));
			} else {
				// 克隆set
				cloneData.add(key, deepClone(val, map));
			}
		});
		return cloneData;
	}
	// Object.prototype.toString.call(data) 和 Array.isArray 都可以用来判断数据是否数组
	// let cloneData = Array.isArray(data) === '[object Array]' ? [] : {};
	cloneData = Object.prototype.toString.call(data) === '[object Array]' ? [] : {};
	// 内存公共空间是否有复制过的数据,有就直接返回
	if (map.get(data)) return map.get(data);
	// 存在公共空间对象数据
	map.set(data, cloneData);
	for (const key in data) {
		cloneData[key] = deepClone(data[key], map); // 这里记得存 map
	}
	return cloneData;
}
const newObj1 = deepClone(obj);
console.log(newObj1);
// {
//     test: '我是文字',
//     age: 20,
//     name: undefined,
//     nullFlag: null,
//     array: [ 'one', 'two', [ 1, 2 ] ],
//     objs: { obj2: { a: 1 } },
//     map: Map(1) { 'flag' => '程序员自来耶' },
//     set: Set(1) { '程序员自来耶' }
//   }
复制代码

我们拷贝已经完成了80%了,下面我们继续完成没有完成的类型:String、Number、Boolean、Date、Error

// 其它类型
function otherType(data, type) {
	const NewCtor = data.constructor;
	switch (type) {
		case booleanTag:
			return new Boolean(data);
		case numberTag:
			return new Number(data);
		case stringTag:
			return new String(data);
		case errorTag:
			return new Error(data);
		case symbolTag:
			return new Symbol(data); // 这个我偷懒了,感兴趣可以查一下文档,这里不详细写了
		case dateTag:
			return new NewCtor(data);
		case regexpTag:
			return deepCloneReg(data);
        // 还有函数、等等 可以到loadsh工具库看一下
		default:
			return null;
	}
}
// 克隆正则
function deepCloneReg(obj) {
	const data = new obj.constructor(obj.source, /\w*$/.exec(targe));
	data.lastIndex = obj.lastIndex;
	return data;
}
复制代码

完整版

const map = new Map();
map.set('flag', '程序员自来耶');
const set = new Set();
set.add('程序员自来耶');

const obj = {
	name: undefined,
	nullFlag: null,
	array: [ 'one', 'two', [ 1, 2 ] ],
	objs: {
		obj2: {
			a: 1
		}
	},
	map,
	set,
    str: '程序员自来耶',
    boolean_: true,
    num: 20,
    symbol: Object(Symbol('程序员自来耶')),
    date: new Date(),
    reg: /\d+/,
    error: new Error('错误信息'),
	fn: function(a) {
		return a + b;
	}
};
// 可遍历类型
const arrayTag = '[object Array]';
const objectTag = '[object Object]';
const mapTag = '[object Map]';
const setTag = '[object Set]';
const argsTag = '[object Arguments]';

// 不可遍历类型
const numberTag = '[object Number]';
const stringTag = '[object String]';
const booleanTag = '[object Boolean]';
const dateTag = '[object Date]';

const symbolTag = '[object Symbol]';
const errorTag = '[object Error]';
const regexpTag = '[object RegExp]';
const funcTag = '[object Function]';
// ....
// 可遍历类型数据标识
const deeptTagList = [ mapTag, setTag, arrayTag, objectTag, argsTag ];
// 获取数据类型
function getType(data) {
	return Object.prototype.toString.call(data);
}
// 初始类型
function getInit(data) {
	const NewCtor = data.constructor;
	return new NewCtor();
}

// 其它类型
function otherType(data, type) {
	const NewCtor = data.constructor;
	switch (type) {
		case booleanTag:
		case numberTag:
		case stringTag:
		case errorTag:
		case symbolTag:
			return data;
		case dateTag:
			return new NewCtor(data);
		case regexpTag:
			return deepCloneReg(data);
		// 还有函数、等等 可以到loadsh工具库看一下
		default:
			return null;
	}
}
// 克隆正则
function deepCloneReg(obj) {
	const data = new obj.constructor(obj.source, /\w*$/.exec(obj));
	data.lastIndex = obj.lastIndex;
	return data;
}
function deepClone(data, map = new WeakMap()) {
	// 非对象直接返回对应的值
	if (typeof data !== 'object' || !data) return data;
	let cloneData = null;
	// 判断数据类型
	const type = getType(data);
	// 判断是否可遍历类型
	if (deeptTagList.includes(type)) {
		cloneData = getInit(data, type);
	} else {
        // 不可遍历 
		return otherType(data, type);
	}
	// 克隆map || 克隆set
	if (type === mapTag || type === setTag) {
		data.forEach((val, key) => {
			if (type === mapTag) {
				// 克隆map
				cloneData.set(key, deepClone(val, map));
			} else {
				// 克隆set
				cloneData.add(key, deepClone(val, map));
			}
		});
		return cloneData;
	}
	// Object.prototype.toString.call(data) 和 Array.isArray 都可以用来判断数据是否数组
	// let cloneData = Array.isArray(data) === '[object Array]' ? [] : {};
	cloneData = Object.prototype.toString.call(data) === '[object Array]' ? [] : {};
	// 内存公共空间是否有复制过的数据,有就直接返回
	if (map.get(data)) return map.get(data);
	// 存在公共空间对象数据
	map.set(data, cloneData);
	for (const key in data) {
		cloneData[key] = deepClone(data[key], map); // 这里记得存 map
	}
	return cloneData;
}
const newObj1 = deepClone(obj);
复制代码

最后输出

error.png

基本的全部功能基本实现,有部分更细节的地方,自己可以查阅相关文档或者lodash工具库,例如:克隆Symbol类型、
克隆Function类型、等等,我这边没有添加上。

参考

结语

希望看完这篇文章对你有帮助:

  • 理解深浅拷贝背后拷贝的原理
  • 手写一个比较完整的深拷贝方法
  • 理解实践这个功能的思路

文中如有错误,欢迎在评论区指正,如果这篇文章帮助到了你,欢迎点赞和关注,后续会输出更好的分享。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享