JavaScript中如何实现深度克隆

《JavaScript中如何实现深度克隆》 js.png

一:为什么要实现深度克隆?

这是一个前端面试经常问到的问题,并且在知乎上我看到很多的前端大神也都探讨过。这个问题背后的考察点相当丰富,涉及JS的数据类型、数据存储、内存管理。还涉及很多边界条件的考虑,很具有代表性。所以为了巩固这个这些知识点,查阅了很多资料,整理一篇文章,供学习交流使用,如有不足之处,欢迎指正。

二:JavaScript中的内存管理

JS内存管理,往深了挖很复杂,这里只做简单的介绍,帮助理解js的基本类型和引用类型,为了后面讲解深度克隆做铺垫,我们知道JS拥有自动的垃圾回收机制,这样就使得很多前端开发人员不是很重视内存管理这一块。但是其实这一部分的内容对于理解JS中原型与原型链,闭包,递归都是非常有帮助的。

在JS中,每一个数据都需要一个内存空间。内存空间又被分为两种:

栈内存(stock)
堆内存(heap)
  • 基础数据类型和栈内存

    JS中的基础数据类型,我们也称之为原始数据类型,这些值都有固定的大小,往往都保存在栈内存中,由系统自动分配存储空间。我们可以直接操作保存在栈内存空间的值,因此基础数据类型都是按值访问。也就是说,它们的值直接存储在变量访问的位置。

    数据在栈内存中的存储与使用方式类似于数据结构中的堆栈数据结构,遵循后进先出的原则

    基础数据类型: 
    Number String Null Undefined Boolean Symbol(ES6新增)
    

    要简单理解栈内存空间的存储方式,我们可以通过类比乒乓球盒子来分析。

《JavaScript中如何实现深度克隆》 乒乓球盒子.png

乒乓球的存放方式与栈内存中存储数据的方式如出一辙。处于盒子中最顶层的乒乓球,它一定是最后被放进去的,但可以最先被使用。而我们想要使用底层的乒乓球,就必须将上面的两个乒乓球取出来,让最底层的乒乓球处于盒子顶层。这就是栈空间 “先进后出,后进先出” 的特点。

  • 引用数据类型与堆内存

与java等其他语言不同,JS的引用数据类型,比如数组Array,它们值的大小是不固定的,可以再不声明长度的情况下,动态填充。引用数据类型的值是保存在堆内存中的对象。

JavaScript不允许直接访问堆内存中的位置,因此我们不能直接操作对象的堆内存空间。

在操作对象时,实际上是在操作对象的引用而不是实际的对象。因此,引用类型的值都是按引用访问的。

这里的引用,我们可以粗浅地理解为保存在栈内存中的一个地址,该地址与堆内存的实际值相关联。

为了更好的搞懂栈内存与堆内存,我们可以结合以下例子与图解进行理解。

```
var a1 = 0;   // 栈 
var a2 = 'this is string'; // 栈
var a3 = null; // 栈

var b = { m: 20 }; // 变量b存在于栈中,{m: 20} 作为对象存在于堆内存中
var c = [1, 2, 3]; // 变量c存在于栈中,[1, 2, 3] 作为对象存在于堆内存中
```

《JavaScript中如何实现深度克隆》 堆栈内存图解.png

上例变量的内存分配情况图解

因此当我们要访问堆内存中的引用数据类型时,实际上我们首先是从栈中获取了该对象的地址引用(或者地址指针),然后再从堆内存中取得我们需要的数据。

三:JavaScript中基础类型和引用类型的特点。

既然已经明白了栈内存和堆内存的存储数据的特点,那么接下来就看一些小的例子,这些小的例子专门用来考察基础类型和引用类型的存储特点

  • 例一

    let a = 20;
    let b = a;
    b = 30;
    console.log(a) // 这时a的值是多少?
    

《JavaScript中如何实现深度克隆》 原始类型的复制.png

在栈内存中的数据发生复制行为时,系统会自动为新的变量分配一个新的内存空间。上例中 let b = a 执行之后,a与b虽然值都等于20,但是他们其实已经是相互独立互不影响的值了。具体如图。所以我们修改了b的值以后,a的值并不会发生变化。因此输出的 a 的值还是 20。

  • 例二

    let m = { a: 10, b: 20 }
    let n = m;
    n.a = 15;
    console.log(m.a) // 这时m.a的值是多少
    

《JavaScript中如何实现深度克隆》 引用类型的复制.png

我们通过let n = m 执行一次复制引用类型的操作。引用类型的复制同样也会为新的变量自动分配一个新的值保存在栈内存中,但不同的是,这个新的值,仅仅只是引用类型存在栈内存中的一个地址指针。当地址指针相同时,尽管他们相互独立,但是在堆内存中访问到的具体对象实际上是同一个。如图所示。

因此当我改变n时,m也发生了变化。此时输出的m.a的值也变成了15,这就是引用类型的特性。

如果这样还不好理解,就举一个生活中的例子,假设甲乙两个人一起租房子,那么他们都共同拥有同一个大门进入房间,如果一个人将屋子里面的仅有的空调弄坏了,那么两个人就都没有空调使用了。

四:JavaScript浅克隆和深度克隆

既然已经理解了JS中基础类型和引用类型的特点,下面就开始真正探讨关于深度克隆问题了。

  • 1、浅克隆

浅克隆之所以被称为浅克隆,是因为对象只会被克隆最外部的一层,至于更深层的对象,则依然是通过引用指向同一块堆内存.

// 浅克隆函数
function shallowClone(o) {
  const obj = {};
  for ( let i in o) {
    obj[i] = o[i];
  }
  return obj;
}
// 被克隆对象
const oldObj = {
  a: 1,
  b: [ 'e', 'f', 'g' ],
  c: { h: { i: 2 } }
};

const newObj = shallowClone(oldObj);
console.log(newObj.c.h, oldObj.c.h); // { i: 2 } { i: 2 }
console.log(oldObj.c.h === newObj.c.h); // true

我们可以很明显地看到,虽然oldObj.c.h被克隆了,但是它还与oldObj.c.h相等,这表明他们依然指向同一段堆内存,我们上面讨论过了引用类型的特点,这就造成了如果对newObj.c.h进行修改,也会影响oldObj.c.h。这本身不是我们想要的,因此就不算是一版好的克隆。

newObj.c.h.i = '我们两个都变了';
console.log(newObj.c.h, oldObj.c.h); // { i: '我们两个都变了' } { i: '我们两个都变了' }

我们改变了newObj.c.h.i的值,oldObj.c.h.i也被改变了,这就是浅克隆的问题所在.

  • 2、深克隆

    • 2.1 JSON.parse方法

      JSON对象parse方法可以将JSON字符串反序列化成JS对象,stringify方法可以将JS对象序列化成JSON字符串,这两个方法结合起来就能产生一个便捷的深克隆.

      const newObj = JSON.parse(JSON.stringify(oldObj));
      

      我们依然使用上述中的那个例子做演示。

      const oldObj = {
        a: 1,
        b: [ 'e', 'f', 'g' ],
        c: { h: { i: 2 } }
      };
      
      const newObj = JSON.parse(JSON.stringify(oldObj)); // 将oldObj先序列化再反序列化。
      console.log(newObj.c.h, oldObj.c.h); // { i: 2 } { i: 2 }
      console.log(oldObj.c.h === newObj.c.h); // false 这时候就已经不一样了
      newObj.c.h.i = '我和oldObj相互独立';
      console.log(newObj.c.h, oldObj.c.h); // { i: '我和oldObj相互独立' } { i: 2 }
      

      果然,这是一个实现深克隆的好方法,但是这个解决办法是不是太过简单了.

      确实,这个方法虽然可以解决绝大部分是使用场景,但是却有很多坑.

      • 1.他无法实现对函数 、RegExp等特殊对象的克隆;
      • 2.会抛弃对象的constructor,所有的构造函数会指向Object;
      • 3.对象有循环引用,会报错;

      针对以上的情况,我们可以测试一下:

      // 构造函数
      function person(pname) {
        this.name = pname;
      }
      
      const Messi = new person('Messi');
      
      // 函数
      function say() {
        console.log('hi');
      };
      
      const oldObj = {
        a: say,
        b: new Array(1),
        c: new RegExp('ab+c', 'i'),
        d: Messi
      };
      
      const newObj = JSON.parse(JSON.stringify(oldObj));
      
      // 无法复制函数
      console.log(newObj.a, oldObj.a); // undefined [Function: say]
      // 稀疏数组 复制错误
      console.log(newObj.b[0], oldObj.b[0]); // null undefined
      // 无法复制正则对象
      console.log(newObj.c, oldObj.c); // {} /ab+c/i
      // 构造函数指向错误
      console.log(newObj.d.constructor, oldObj.d.constructor); // [Function: Object] [Function: person]
      

      我们可以看到在对函数、正则对象、稀疏数组等对象克隆时会发生意外,构造函数指向也会发生错误。

      const oldObj = {};
      
      oldObj.a = oldObj;
      
      const newObj = JSON.parse(JSON.stringify(oldObj));
      console.log(newObj.a, oldObj.a); // TypeError: Converting circular structure to JSON
      

      对象的循环引用会抛出错误。

  • 2.2 构造一个深度克隆函数

    由于要面对不同的对象(正则、数组、Date等)要采用不同的处理方式,我们需要实现一个对象类型判断函数

    const isType = (obj, type) => {
      if (typeof obj !== 'object') return false;
      // 判断数据类型的经典方法:
      const typeString = Object.prototype.toString.call(obj);
      let flag;
      switch (type) {
        case 'Array':
          flag = typeString === '[object Array]';
          break;
        case 'Date':
          flag = typeString === '[object Date]';
          break;
        case 'RegExp':
          flag = typeString === '[object RegExp]';
          break;
        default:
          flag = false;
      }
      return flag;
    };
    

    这样我们就可以对特殊对象进行类型判断了,从而采用针对性的克隆策略.

    const arr = Array.of(3, 4, 5, 2);
    console.log(isType(arr, 'Array')); // true
    

    对于正则对象,我们在处理之前要先补充一点新知识.
    我们需要通过正则的扩展了解到flags属性等等,因此我们需要实现一个提取flags的函数

    const getRegExp = re => {
      var flags = '';
      if (re.global) flags += 'g';
      if (re.ignoreCase) flags += 'i';
      if (re.multiline) flags += 'm';
      return flags;
    };
    

    做好了这些准备工作,我们就可以进行深克隆的实现了.

    /**
    * deep clone
    * @param  {[type]} parent object 需要进行克隆的对象
    * @return {[type]}        深克隆后的对象
    */
    const clone = parent => {
      // 维护两个储存循环引用的数组
      const parents = [];
      const children = [];
    
      const _clone = parent => {
        if (parent === null) return null;
        if (typeof parent !== 'object') return parent;
    
        let child, proto;
    
        if (isType(parent, 'Array')) {
          // 对数组做特殊处理
          child = [];
        } else if (isType(parent, 'RegExp')) {
          // 对正则对象做特殊处理
          child = new RegExp(parent.source, getRegExp(parent));
          if (parent.lastIndex) child.lastIndex = parent.lastIndex;
        } else if (isType(parent, 'Date')) {
          // 对Date对象做特殊处理
          child = new Date(parent.getTime());
        } else {
          // 处理对象原型
          proto = Object.getPrototypeOf(parent);
          // 利用Object.create切断原型链
          child = Object.create(proto);
        }
    
        // 处理循环引用
        const index = parents.indexOf(parent);
    
        if (index != -1) {
          // 如果父数组存在本对象,说明之前已经被引用过,直接返回此对象
          return children[index];
        }
        parents.push(parent);
        children.push(child);
    
        for (let i in parent) {
          // 递归
          child[i] = _clone(parent[i]);
        }
    
        return child;
      };
      return _clone(parent);
    };
    

    我们做一下测试

    function person(pname) {
      this.name = pname;
    }
    
    const Messi = new person('Messi');
    
    function say() {
      console.log('hi');
    }
    
    const oldObj = {
      a: say,
      c: new RegExp('ab+c', 'i'),
      d: Messi,
    };
    
    oldObj.b = oldObj;
    
    const newObj = clone(oldObj);
    console.log(newObj.a, oldObj.a); // [Function: say] [Function: say]
    console.log(newObj.b, oldObj.b); 
    // { a: [Function: say], c: /ab+c/i, d: person { name: 'Messi' }, b: [Circular] } { a: [Function: say], c: /ab+c/i, d: person { name: 'Messi' }, b: [Circular] }
    console.log(newObj.c, oldObj.c); // /ab+c/i /ab+c/i
    console.log(newObj.d.constructor, oldObj.d.constructor); 
    // [Function: person] [Function: person]
    

当然,我们这个深克隆还不算完美,例如Buffer对象、Promise、Set、Map可能都需要我们做特殊处理,另外对于确保没有循环引用的对象,我们可以省去对循环引用的特殊处理,因为这很消耗时间,不过一个基本的深克隆函数我们已经实现了。

实现一个完整的深克隆是由许多坑要踩的,npm上一些库的实现也不够完整,在生产环境中最好用lodash的深克隆实现.

参考链接:
https://juejin.im/post/5abb55ee6fb9a028e33b7e0a
https://juejin.im/entry/589c29a9b123db16a3c18adf
https://www.zhihu.com/question/20289071
https://www.zhihu.com/question/47746441?from=profile_question_card
http://laichuanfeng.com/study/javascript-immutable-primitive-values-and-mutable-object-references/

点赞