deepClone深拷贝一次到位

94 阅读5分钟

我正在参加「掘金·启航计划」

想必大家都知道浅拷贝和深拷贝,深拷贝和浅拷贝都是针对的引用类型,JS中的变量类型分为值类型和引用类型;对值类型进行赋值操作会进行一份拷贝,而对引用类型赋值,则会进行地址的拷贝,最终两个变量指向同一份数据.如下所示:

// 基本类型
var a = 1;
var b = a;
a = 2;
console.log(a, b); // 2, 1a b指向不同的数据
​
// 引用类型指向同一份数据
var a = {c: 1};
var b = a;
a.c = 2;
console.log(a.c, b.c); // 2, 2 全是2a 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 exceededclone(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);

image-20220917191553269

规律:

  • 随着深度变小,相互之间的差异在变小
  • clone和cloneLoop的差别并不大
  • cloneLoop>cloneForce>cloneJSON

时间计算:

  • clone时间=创建递归函数+每个对象处理时间
  • cloneJSON时间=循环检测+每个对象处理时间*2(递归转字符串+递归解析)
  • cloneLoop时间=每个对象处理时间
  • cloneForce时间=判断对象是否缓存中+每个对象处理时间

cloneJSON的速度只有clone的50%,很容易理解,因为其会多进行一次递归时间

排除宽度测试

将深度固定在2000,宽度固定为0,记录1秒内执行的次数

宽度clonecloneJSONcloneLoopcloneForce
01759149518787143

排除宽度的干扰,来看看深度对各个方法的影响

  • 随着对象的增多,cloneForce的性能低下凸显
  • cloneJSON的性能也大打折扣,这是因为循环检测占用了很多时间
  • cloneLoop的性能高于clone,深度越深,效果越明显

总结

clonecloneJSONcloneLoopcloneForce
循环引用一层不支持一层支持
栈溢出不会不会
保持引用
适合场景一般数据拷贝一般数据拷贝层级很多保持引用关系