一个递归爆栈引起的对深拷贝的理解

2,763 阅读10分钟

有人问了我一个深拷贝的问题,我像往常一样回答了他,忽然他问我如果数据量大,爆栈了该怎么办,没有见识的我一时懵逼,什么爆栈😢😢😢,看着他的王之蔑视,我准备研究研究,然后发现了爆栈和深拷贝的问题,之前写过一篇关于浅拷贝和深拷贝的文章,当时写的有些浅,现在想深入的再理解一下。

1. 为什么会出现深浅拷贝的现象

要解释这个现象,首先就要知道基本计算机的存储。

栈内存存储的是局部变量,凡是定义在方法中的都是局部变量,栈里存放的都是单个变量,变量被释放了,就没有。
堆内存存储的是数组和对象,凡是new建立的都在堆中,堆中存放的都是实体,实体用于封装数据,而且是封装多个,如果一个数据消失,这个实体也没有消失,还可以用,所以堆是不会随时释放的。

这里简单的描述一下栈和堆区别。 众所周知,深浅拷贝主要是对对象的复制上,当对象存在堆中的时候,此时会生成一个引用地址,这个引用地址会和栈中的变量进行关联。

1.png 当将一个引用数据类型复制给另一个变量的时候,其实就是将该引用数据类型的引用地址指向新的变量,当新的变量去改变对象时,由于是共用同一个引用地址,导致原先变量的值也会发生变化。

2.png

2.实现深拷贝方法的问题

2.1 JSON.parse(JSON.stringify())

首先我脑海中提出的问号是为什么它可以实现深拷贝?

上面说到因为新老变量都绑定了一个对象的引用地址,才会导致浅拷贝的现象出现,所以当 JSON.stringify()作用于对象时,是将对象转换为 JSON 字符串,将对象与引用地址断开,然后再用 JSON.parse()反序列化,形成一个新的对象。序列化的作用就是存储和传输。

在之前文章中有提过,JSON.parse(JSON.stringify())这个方法虽然能够实现深拷贝,但是只提到了对 function 是失效的,后来根据知识的累计,发现不仅仅是 function。

let obj = {
  a: 1,
  c: function () {},
  d: Symbol(),
  e: undefined,
  f: null,
  g: "111",
};
console.log(JSON.parse(JSON.stringify(obj))); // {a: 1, f: null, g: "111"}

发现不止只有 function 被过滤,Symbol、undefined 都会。

let obj = {
  a: new Date(),
  reg: new RegExp(""),
  err: new Error("error message"),
  nan: NaN,
  isInfinite: 1.7976931348623157e10308,
  minusInfinity: -1.7976931348623157e10308,
};
console.log(JSON.parse(JSON.stringify(obj))); // {a: "2021-04-21T01:38:11.267Z", err: {}, isInfinite: null, minusInfinity: null, nan: null, reg: {}}

从上可知,日期对象会转换为字符串,RegExp、Error 对象则会转换为空对象,NaN、Infinity 和-Infinity 则会转换为 null。 之前一直不明白为什么会这样,直到我在 MDN 查找了 JSON.stringify(),MDN 清晰地列出了: JSON.stringify()将值转换为相应的 JSON 格式:

  1. 转换值如果有 toJSON() 方法,该方法定义什么值将被序列化。
  2. 非数组对象的属性不能保证以特定的顺序出现在序列化后的字符串中。
  3. 布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。
  4. undefined、任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)。函数、undefined 被单独转换时,会返回 undefined,如 JSON.stringify(function(){}) or JSON.stringify(undefined).
  5. 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。
  6. 所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。
  7. Date 日期调用了 toJSON() 将其转换为了 string 字符串(同 Date.toISOString()),因此会被当做字符串处理。
  8. NaN 和 Infinity 格式的数值及 null 都会被当做 null。
  9. 其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性。

所以 JSON.parse(JSON.stringify())的特性完全是因为 JSON.stringify()将对象序列化造成的。

2.2 递归

现阶段来说实现深拷贝的方法,递归是最靠谱的,但是递归本身的问题很多,当数据量很大,递归层级很深的时候,递归的最大问题爆栈就出现了。

那什么是爆栈,为什么会出现爆栈?

在 OS / 浏览器 / ... 等平台运行程序的时候,每一次对函数的调用前,都会将当前的运行环境保存起来,以便于函数调用完毕后,恢复现场。这些数据,通常保存在堆栈里。一般在具体实现的时候,这个空间并不是无限的。也正是因此,函数的最大调用深度也是有限的。由于递归函数的特点,导致如果边界检查存在缺陷,那么就可能导致超过这个最大深度,从而超出堆栈的存储能力,也就是一般所说的“溢出”。

那么如何解决爆栈问题,就成了递归优化的重中之重

在之前的文章【重学 JS 之路】深拷贝和浅拷贝中我写了一个简单实现深拷贝的递归,但是没有对爆栈问题进行检验。

首先我们创建一个很大的数据量,来检验之前的写的深拷贝的递归算法是否会出现溢出的问题。

/**
 *
 * @param deep {number} 对象的深度
 * @param breadth {number} 每层有几个数据
 * @returns data
 */
const createData = (deep, breadth) => {
  let data = {};
  let temp = data;
  for (let i = 0; i < deep; i++) {
    temp = temp["data"] = {};
    for (let j = 0; j < breadth; j++) {
      temp[j] = j;
    }
  }
  return data;
};

通过验证

deepCopy(createData(1000)); // ok
deepCopy(createData(10000)); // Maximum call stack size exceeded
deepCopy(createData(10, 100000)); // ok 广度不会溢出

那我们来看一下,上述所说的 JSON.parse(JSON.stringify())是否会出现爆栈的问题。

JSON.parse(JSON.stringify(createData(1000))); // ok
JSON.parse(JSON.stringify(createData(10000))); // Maximum call stack size exceeded
JSON.parse(JSON.stringify(createData(10, 100000))); // ok 广度不会溢出

与咱们自己封装的代码的结果一致,所以可以推断出 JSON.parse(JSON.stringify())内部应该也是使用了递归的方式实现

解决循环引用

循环引用的情况,即对象的属性间接或直接的引用了自身,导致递归进入死循环,栈溢出。

解决循环引用问题,我们可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝。

这个存储空间,需要可以存储key-value形式的数据,且key可以是一个引用类型,我们可以选择Map这种数据结构:

  1. 检查map中有无克隆过的对象
  2. 有 - 直接返回
  3. 没有 - 将当前对象作为key,克隆对象作为value进行存储
  4. 继续克隆
function clone(target, map = new Map()) {
  if (typeof target === 'object') {
    let cloneTarget = Array.isArray(target) ? [] : {};
    if (map.get(target)) {
      return map.get(target);
    }
    map.set(target, cloneTarget);
    for (const key in target) {
      cloneTarget[key] = clone(target[key], map);
    }
    return cloneTarget;
  } else {
    return target;
  }
};

使用 WeakMap 再次优化

map 和 WeakMap 的区别可以看我之前写的文章带你重学ES6 | Set和Map,WeakMap 和 map 最大的不同,就是 WeakMap 是弱引用。

那什么是弱引用,并且弱引用有什么好处,我们先讲解一下。

在百度百科中写道:在计算机程序设计中,弱引用与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,并因此可能在任何时刻被回收。一些配有垃圾回收机制的语言,如 Java、C#、Python、Perl、Lisp 等都在不同程度上支持弱引用。

其实弱引用的好处就是可以在任何时刻被回收,提高性能,map 虽然可以进行手动释放提高性能,但是在某些情况下,是无法进行手动清除的。

兼容其他数据类型

上述实现的深拷贝我们只兼容了对象和数组,但是除此之外还有很多引用数据类型。 首先我们判断是否为引用数据类型,并且判断是否为 function 和 null。

const isObject = (obj) => {
  const type = typeof obj;
  return obj !== null && (type === "object" || type === "function");
};

接下来判断数据类型我们要将 typeOf 改为 Object.prototype.toSting.call()具体讲解可看我写的【重学JS之路】js基础类型和引用类型

const getType = (obj) => {
  return Object.prototype.toString.call(obj);
};

在数据结构类型中可以分为两种,一种是可继续遍历的数据类型,还有一种是不可继续遍历的数据类型。

可继续遍历的数据类型

上面我们所写的数组和对象,就是可继续遍历的数据类型,除此之外还有 set 和 map。

我们需要获取初始化数据:

const getInit = (obj) => {
  const Con = obj.constructor;
  return new Con();
};

将 set 和 map 加入深拷贝函数中:

const forEach = (array, iteratee) => {
  let index = -1;
  const length = array.length;
  while (++index < length) {
    iteratee(array[index], index);
  }
  return array;
};

const cloneDeep = (target, map = new WeakMap()) => {
  // 克隆原始类型
  if (!isObject(target)) {
    return target;
  }

  // 初始化
  const type = getType(target);
  let cloneTarget;
  if (
    [
      "[object Map]",
      "[object Set]",
      "[object Array]",
      "[object Object]",
      "[object Arguments]",
    ].includes(type)
  ) {
    cloneTarget = getInit(target, type);
  }

  // 防止循环引用
  if (map.get(target)) {
    return map.get(target);
  }
  map.set(target, cloneTarget);

  // 克隆set
  if (type === "[object Set]") {
    target.forEach((value) => {
      cloneTarget.add(clone(value, map));
    });
    return cloneTarget;
  }

  // 克隆map
  if (type === "[object Map]") {
    target.forEach((value, key) => {
      cloneTarget.set(key, clone(value, map));
    });
    return cloneTarget;
  }

  // 克隆对象和数组
  const keys = type === "[object Array]" ? undefined : Object.keys(target);
  forEach(keys || target, (value, key) => {
    if (keys) {
      key = value;
    }
    cloneTarget[key] = cloneDeep(target[key], map);
  });

  return cloneTarget;
};
不可继续遍历的数据类型

除此之外的数据类型,我们称之为不可继续遍历的数据类型。

Bool、Number、String、String、Date、Error 这几种类型我们都可以直接用构造函数和原始数据创建一个新对象:

const cloneOtherType = (targe, type) => {
  const Con = targe.constructor;
  switch (type) {
    case boolTag:
    case numberTag:
    case stringTag:
    case errorTag:
    case dateTag:
      return new Con(targe);
    case regexpTag:
      return cloneReg(targe);
    case symbolTag:
      return cloneSymbol(targe);
    default:
      return null;
  }
};

// 克隆正则
const cloneReg = (target) => {
  const reFlags = /\w*$/;
  const result = new targe.constructor(targe.source, reFlags.exec(targe));
  result.lastIndex = targe.lastIndex;
  return result;
};

// 克隆symbol
const cloneSymbol = (targe) => {
  return Object(Symbol.prototype.valueOf.call(targe));
};
函数

函数分为两种,一种是普通函数,一种是箭头函数,普通函数和箭头函数的区别就是箭头函数没有 prototype。

const cloneFunction = (func) => {
  const bodyReg = /(?<={)(.|\n)+(?=})/m;
  const paramReg = /(?<=\().+(?=\)\s+{)/;
  const funcString = func.toString();
  if (func.prototype) {
    // 普通函数
    const param = paramReg.exec(funcString);
    const body = bodyReg.exec(funcString);
    if (body) {
      if (param) {
        const paramArr = param[0].split(",");
        return new Function(...paramArr, body[0]);
      } else {
        return new Function(body[0]);
      }
    } else {
      return null;
    }
  } else {
    return eval(funcString);
  }
};

完整代码可看 ConardLi 大神之作ConardLi/ConardLi.github.io

对优化后代码进行栈溢出测试:

cloneDeep(createData(1000)); // ok
cloneDeep(createData(10000)); // Maximum call stack size exceeded
cloneDeep(createData(10, 100000)); // ok 广度不会溢出

虽然将递归进行了优化,解决了循环引用的问题,更大限度的使用了弱引用,但是依旧没有解决递归的痛点,爆栈问题。

解决递归爆栈

解决递归爆栈一般来说有两种方法,第一种方法是常见的尾部调用,第二种则是把递归改为循环。

尾调用

在阮一峰大神的尾调用优化一文中写到:

尾调用的概念非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。

尾调用之所以与其他调用不同,就在于它的特殊的调用位置。

我们知道,函数调用会在内存形成一个"调用记录",又称"调用帧"(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用记录上方,还会形成一个B的调用记录。等到B运行结束,将结果返回到AB的调用记录才会消失。如果函数B内部还调用函数C,那就还有一个C的调用记录栈,以此类推。所有的调用记录,就形成一个"调用栈"(call stack)。

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。

尾调用优化"(Tail call optimization),即只保留内层函数的调用记录。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存

为了让大家能够理解,简单的实现一下尾调用:

1function f(x) {
  return g(x);
}

2function f(x) {
  return g(x) + 1;
}

3function f(x) {
  let y = g(x);
  return y;
}

上述几个函数,只有第一个是尾调用,剩下两个虽然形式相同,但在调用 g 函数之后都有其他操作,所以都不是尾调用。

接下来我们将尾调用优化来优化咱们的递归。

本人不才,尾递归优化只做到了如下,请求各位大佬能够帮忙指点一下:

let deepCopy = (obj, index = 0, new_obj = obj instanceof Array ? [] : {}) => {
  if (typeof obj !== "object") return;
  // new_obj = result ? result : new_obj;
  let keys = Object.keys(obj);
  if (index === keys.length) {
    return new_obj;
  } else {
    if (obj.hasOwnProperty(keys[index])) {
      if (typeof obj[keys[index]] === "object") {
        new_obj[keys[index]] = new_obj;
        return deepCopy(obj[keys[index]], 0, new_obj);
      } else {
        new_obj[keys[index]] = obj[keys[index]];
        return deepCopy(obj, index + 1, new_obj);
      }
    }
  }
};

将遍历改为循环

既然递归有那么多问题,那我们可以不用递归实现深拷贝,改用循环来实现,这大大提升实现难度。

这里推荐可以查看@jsmini/clone源码,站在巨人的肩膀上。

我们拿一个深层级的对象为例,看看如何将递归成为一个循环。

let obj = {
  a: 1,
  b: {
    a: 2,
    b: {
      c: {
        a: 4,
      },
      d: {
        a: 6,
      },
    },
  },
};

说到循环,我们就要创建一个栈,当栈为空时跳出循环,并且要考虑几个方面,父级 key 如何存储,该父级 key 下的子级 key 如何存储。

const cloneForce = (x) => {
  let result = {};
  let stack = [
    {
      parent: result,
      key: undefined,
      data: x,
    },
  ];
  while (stack.length) {
    let node = stack.pop();
    let { parent, key, data } = node;
    let res = parent;
    if (typeof key !== "undefined") {
      res = parent[key] = {};
    }
    for (let key in data) {
      if (data.hasOwnProperty(key)) {
        if (typeof data[key] === "object") {
          stack.push({
            parent: res,
            key,
            data: data[key],
          });
        } else {
          res[key] = data[key];
        }
      }
    }
  }
  return result;
};

测试一下是否会爆栈:

cloneForce(createData(1000)); // ok
cloneForce(createData(10000)); // ok
cloneForce(createData(10, 100000)); // ok 广度不会溢出

ok,完美解决爆栈问题

测试一下循环引用:

const target = {
  field1: 1,
  field2: undefined,
  field3: {
    child: "child",
  },
  field4: [2, 4, 8],
};
target.target = target;
cloneForce(target); // 页面崩溃

接下来我们解决循环引用的问题:

根据之前的经验,我们可以使用 WeakMap 来解决循环引用。

const cloneForceWeakMap = (x, map = new WeakMap()) => {
  let result;
  if (getType(x) === "[object Object]") {
    result = {};
  } else if (getType(x) === "[object Array]") {
    result = [];
  }
  let stack = [
    {
      parent: result,
      key: undefined,
      data: x,
    },
  ];
  while (stack.length) {
    let node = stack.pop();
    let { parent, key, data } = node;
    let res = parent;
    if (typeof key !== "undefined") {
      res = parent[key] = getType(data) === "[object Object]" ? {} : [];
    }
    if (map.get(data)) {
      parent[key] = map.get(data);
      continue;
    }
    map.set(data, res);
    if (getType(data) === "[object Object]") {
      for (let key in data) {
        if (data.hasOwnProperty(key)) {
          if (typeof data[key] === "object") {
            stack.push({
              parent: res,
              key,
              data: data[key],
            });
          } else {
            res[key] = data[key];
          }
        }
      }
    } else if (getType(data) === "[object Array]") {
      for (let i = 0; i < data.length; i++) {
        if (typeof data[i] === "object") {
          // 下一次循环
          stack.push({
            parent: res,
            key: i,
            data: data[i],
          });
        } else {
          res[i] = data[i];
        }
      }
    }
  }
  return result;
};

上述代码只兼容了数组和对象,剩下的可以看jsmini/clone颜海镜大佬写的源码,真是自叹不如。

各个深拷贝的性能测试

我们分别将上述三种深拷贝(由于尾递归没有实现,暂时排除)分别在广度和深度上进行性能上的测试和对比,看看哪个更有优势。

深度

固定几个深度值,500、1000、1500、2000、2500、3000,对其运行时间进行对比。

const runTime = (fn) => {
  let startTime = new Date().getTime();
  fn();
  let endTime = new Date().getTime();
  return endTime - startTime;
};

cloneJSON 为 JSON.parse(JSON.stringify())

cloneJSONcloneDeepcloneForceWeakMap
500111
1000111
1500222
2000332
2500442
30006栈溢出3

运行时间.jpg

从运行时间来看,cloneForceWeakMap 远远强于前两者,cloneDeep 到了深度为 3000 就出现了爆栈,这块还不如 cloneJSON。

除了运行时间外,我们还可以查看在指定时间内,深拷贝执行的次数,根据上述,我们将深度定为 500、1000、1500、2000、2500。

const runTime = (fn, time) => {
  var stime = Date.now();
  var count = 0;
  while (Date.now() - stime < time) {
    fn();
    count++;
  }

  return count;
};

我们将时间定为 2s,查看一下各深拷贝的执行次数。

cloneJSONcloneDeepcloneForceWeakMap
50089532949834116
100031361488317365
15001592872010408
200098770538132
250065257707082

运行次数.jpg

根据上图明显能够看出,在性能方面 cloneForceWeakMap > cloneDeep > cloneJSON

广度

按照上述方法,我们将深度恒定在 500,广度定为 100、1000、10000、100000

cloneJSONcloneDeepcloneForceWeakMap
100238446408
1000224643
10000255
100000011

从以上显示图可以看出,在广度上 cloneDeep > cloneForceWeakMap > cloneJSON,在广度很大情况下 cloneDeep 和 cloneForceWeakMap 是差不多的。

广度性能.jpg

总结

cloneJSONcloneDeepcloneForceWeakMap
循环引用不支持支持支持
爆栈不会
难度中等

当深度很浅并且只有对象和数组的情况下,使用cloneJSON就可以了,当数据量较大时使用cloneDeep,当数据量很大时,就要使用cloneForceWeakMap了。

感谢大家的阅读,尾递归实现深拷贝,在写完这篇文章的时候依旧没有想出来,还请各路大神们帮忙,再次谢过了。

参考文献

  1. 深拷贝的终极探索(99%的人都不知道)
  2. 如何写出一个惊艳面试官的深拷贝?

后语

觉得还可以的,麻烦走的时候能给点个赞,大家一起学习和探讨! 还可以关注我的博客希望能给我的github上点个Star,小伙伴们一定会发现一个问题,我的所有用户名几乎都与番茄有关,因为我真的很喜欢吃番茄❤️!!! 想跟车不迷路的小伙还希望可以关注公众号前端老番茄。 关注后我拉你进前端进阶群,大家一起交流,一起学习,还可以获取免费的资料哦!!! 我是一个编程界的小学生,您的鼓励是我不断前进的动力,😄希望能一起加油前进。