我正在参加「掘金·启航计划」
序
想必大家都知道浅拷贝和深拷贝,深拷贝和浅拷贝都是针对的引用类型,JS中的变量类型分为值类型和引用类型;对值类型进行赋值操作会进行一份拷贝,而对引用类型赋值,则会进行地址的拷贝,最终两个变量指向同一份数据.如下所示:
// 基本类型
var a = 1;
var b = a;
a = 2;
console.log(a, b); // 2, 1 ,a b指向不同的数据
// 引用类型指向同一份数据
var a = {c: 1};
var b = a;
a.c = 2;
console.log(a.c, b.c); // 2, 2 全是2,a b指向同一份数据
很多时候我们是不希望a,b指向同一个地址的,那么如何切断a和b之间的关系呢,可以拷贝一份a的数据,根据拷贝的层级不同可以分为浅拷贝和深拷贝,浅拷贝就是只进行一层拷贝,深拷贝就是无限层级拷贝,我们可以尝试调用一下浅拷贝和最简深拷贝:
var a1 = {b: {c: {}};
var a2 = shallowClone(a1); // 浅拷贝
a2.b.c === a1.b.c // true
var a3 = clone(a3); // 深拷贝
a3.b.c === a1.b.c // false
浅拷贝
function shallowClone(source) {
var target = {};
for(var i in source) {
if (source.hasOwnProperty(i)) {
target[i] = source[i];
}
}
return target;
}
最简单的深拷贝
深拷贝的问题其实可以分解为两个问题,浅拷贝+递归,假设我们有如下数据:
var a1 = {b: {c: {d: 1}};
只需稍微改动上面浅拷贝的代码即可:
function clone(source) {
var target = {};
for(var i in source) {
if (source.hasOwnProperty(i)) {
if (typeof source[i] === 'object') {
target[i] = clone(source[i]); // 注意这里
} else {
target[i] = source[i];
}
}
}
return target;
}
但是上面的代码,问题太多了,例如:
- 没有对参数做检验
- 判断是否对象的逻辑不够严谨
- 没有考虑数组的兼容
上面的问题都不是这次解决的重点.
问题一 其实使用递归进行深拷贝最大的问题是爆栈,当数据的层次很深时就会栈溢出.
可以先定义一个生成指定深度和每层广度的代码,来测测
function createData(deep, breadth) {
var data = {};
var temp = data;
for (var i = 0; i < deep; i++) {
temp = temp['data'] = {};
for (var j = 0; j < breadth; j++) {
temp[j] = j;
}
}
return data;
}
createData(1, 3); // 1层深度,每层有3个数据 {data: {0: 0, 1: 1, 2: 2}}
createData(3, 0); // 3层深度,每层有0个数据 {data: {data: {data: {}}}}
当clone层级很深的时候就会栈溢出,但数据的广度不会造成溢出
clone(createData(1000)); // ok
clone(createData(10000)); // Maximum call stack size exceeded
clone(createData(10, 100000)); // ok 广度不会溢出
问题二 还有一个致命的问题,即循环引用
const a={};
a.a=a;
clone(a) // Maximum call stack size exceeded 死循环
关于循环引用的问题解决思路有两种,一种是循环检测 ,一种是暴力破解
先来看看循环检测:
function cloneJSON(source){
return JSON.parse(JSON.stringify(source))
}
cloneJSON(createData(10000)); // Maximum call stack size exceeded
var a = {};
a.a = a;
cloneJSON(a) // Uncaught TypeError: Converting circular structure to JSON
很明显,使用JSON来做深拷贝,它依然使用的是递归,循环引用的话使用的是循环检测
破解递归爆栈
破解递归爆栈最佳方法就是不用递归,改用循环
如以下例子:
var a = {
a1: 1,
a2: {
b1: 1,
b2: {
c1: 1
}
}
}
树型如下:
a
/ \
a1 a2
| / \
1 b1 b2
| |
1 c1
|
1
用循环遍历一棵树,需要借助一个栈,当栈为空时就遍历完了,栈里面存储下一个需要拷贝的节点
首先我们向栈里面加入种子数据,key 用来存放哪一个父元素的哪一个子元素拷贝对象
然后遍历当前节点下的子元素,如果是对象就放在栈里,否则直接拷贝
const cloneLoop = (x) => {
const root = {};
// 栈
const loopList = [
{
parent: root,
key: undefined,
data: x,
},
];
while (loopList.length) {
// 深度优先
const node = loopList.pop();
const parent = node.parent;
const key = node.key;
const data = node.data;
// 初始化赋值目标,key为undefined则拷贝都父元素,否则拷贝都子元素
let res = parent;
if (typeof key !== "undefined") {
res = parent[key] = {};
}
for (let k in data) {
if (data.hasOwnProperty(k)) {
if (typeof data[k] === "object") {
// 下一次循环
loopList.push({
parent: res,
key: k,
data: data[k],
});
} else {
res[k] = data[k];
}
}
}
}
return root;
};
破解循环引用
上面的代码都存在一个问题即引用丢失,这在某些情况下是不能接受的.
保留引用关系
如下所示:
var b = [1];
var a = { a1: b, a2: b };
console.log(a.a1 === a.a2); // true
var c = clone(a);
console.log(c.a1 === c.a2); //false
每次我们都是直接拷贝对象,即无法保留引用关系,如果每次拷贝前都先看一下这个对象是不是已经拷贝过了,如果拷贝过了,就不需要拷贝了,直接用原来的,这样我们就能保留引用关系了
可以引入一个数组uniqueList 用来存储已经拷贝的数组,每次循环遍历时,先判读对象是否在uniqueList 中了,如果在的话就不执行拷贝逻辑了,代码如下:
// find
const find = (arr, item) => {
for (let i = 0; i < arr.length; i++) {
if (arr[i].source === item) {
return arr[i];
}
}
return null;
};
const cloneForce = (x) => {
const uniqueList = []; // 用来去重
let root = {};
// 循环数组
const loopList = [
{
parent: root,
key: undefined,
data: x,
},
];
while (loopList.length) {
// 深度优先
const node = loopList.pop();
const parent = node.parent;
const key = node.key;
const data = node.data;
// 初始化赋值目标,key为undefined则拷贝到父元素,否则拷贝到子元素
let res = parent;
if (typeof key !== "undefined") {
res = parent[key] = {};
}
// 数据已经存在
let uniqueData = find(uniqueList, data);
if (uniqueData) {
parent[key] = uniqueData.target;
break; // 中断本次循环
}
// 数据不存在
// 保留源数据,在拷贝数据中对应的引用
uniqueList.push({
source: data,
target: res,
});
for (let k in data) {
if (data.hasOwnProperty(k)) {
if (typeof data[k] === "object") {
// 下一次循环
loopList.push({
parent: res,
key: k,
data: data[k],
});
} else {
res[k] = data[k];
}
}
}
}
return root;
};
貌似我们循环引用也被破解了,快来试试
var a = {};
a.a = a;
cloneForce(a);
看起来非常之完美的cloneForce 是不是就没问题呢?
问题一:如果不想保持引用,就不需要用cloneForce问题二:cloneForce在对象数量很多时会出现很大的问题,如果数据量很大不适合使用cloneForce
性能对比
影响clone 性能的原因有两个,一个是深度,一个是每层的广度,我们采用固定一个变量,只让一个变量变化的方式来测试性能.
测试方法,在指定的时间内,深拷贝执行的次数,次数越多,证明性能越好
测试代码如下:
function runTime(fn, time) {
var stime = Date.now();
var count = 0;
while(Date.now() - stime < time) {
fn();
count++;
}
return count;
}
runTime(function () { clone(createData(500, 1)) }, 2000);
规律:
- 随着深度变小,相互之间的差异在变小
- clone和cloneLoop的差别并不大
- cloneLoop>cloneForce>cloneJSON
时间计算:
- clone时间=创建递归函数+每个对象处理时间
- cloneJSON时间=循环检测+每个对象处理时间*2(递归转字符串+递归解析)
- cloneLoop时间=每个对象处理时间
- cloneForce时间=判断对象是否缓存中+每个对象处理时间
cloneJSON的速度只有clone的50%,很容易理解,因为其会多进行一次递归时间
排除宽度测试
将深度固定在2000,宽度固定为0,记录1秒内执行的次数
| 宽度 | clone | cloneJSON | cloneLoop | cloneForce |
|---|---|---|---|---|
| 0 | 17591 | 495 | 18787 | 143 |
排除宽度的干扰,来看看深度对各个方法的影响
- 随着对象的增多,cloneForce的性能低下凸显
- cloneJSON的性能也大打折扣,这是因为循环检测占用了很多时间
- cloneLoop的性能高于clone,深度越深,效果越明显
总结
| clone | cloneJSON | cloneLoop | cloneForce | |
|---|---|---|---|---|
| 循环引用 | 一层 | 不支持 | 一层 | 支持 |
| 栈溢出 | 会 | 会 | 不会 | 不会 |
| 保持引用 | 否 | 否 | 否 | 是 |
| 适合场景 | 一般数据拷贝 | 一般数据拷贝 | 层级很多 | 保持引用关系 |