导读
在平时工作中或者面试中都会接触到对数据的复制,下面来实现一个比较完整的功能,提供全部思路,下面直接进入正文。
定义
回顾一下JavaScript中存在两大数据类型:
基本数据类型:String、Number、Boolean、Null、Undefined、Symbol(ES6 引入了一种新的原始数据类型,表示独一无二的值)
引用数据类型:Object(Array、Function、RegExp、Date...)
- 浅拷贝:创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。
- 深拷贝:将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,并且在修改新对象不会影响原对象。
浅拷贝
浅拷贝常见简单用法:
- 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 // 代表堆栈溢出
运行一下可以看到下图:
一般看到这种情况是堆栈溢出,原因是对象属性引用了自身的地址,循环引用了。由于直接和间接引用了自身,在克隆对象的时候,就不断的循环创建一块内存地址来存放数据,导致堆栈溢出。
解决这种情况,其实我们可以首先创建一个公共的内存空间来放这个数据,存储当前的对象和引用对象之间的关系,在需要拷贝的时候,首先去存储的公共内存空间找一下,这个公共空间有没拷贝过数据,有的话不用拷贝了,没有的话再拷贝。这样就避免了,一直循环引用,不断创建新的内存空间,导致堆栈溢出。
根据上述描述,这个公共的存储空间,就需要用到 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);
最后输出
基本的全部功能基本实现,有部分更细节的地方,自己可以查阅相关文档或者lodash工具库,例如:克隆Symbol类型、
克隆Function类型、等等,我这边没有添加上。
参考
结语
希望看完这篇文章对你有帮助:
- 理解深浅拷贝背后拷贝的原理
- 手写一个比较完整的深拷贝方法
- 理解实践这个功能的思路
文中如有错误,欢迎在评论区指正,如果这篇文章帮助到了你,欢迎点赞和关注,后续会输出更好的分享。
欢迎关注公众号:【程序员米粉】
公众号分享开发编程、职场晋升、大厂面试经验