JavaScript中如何实现深度克隆

js.png

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

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

二:JavaScript中的内存管理

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

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

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

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

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

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

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

乒乓球盒子.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] 作为对象存在于堆内存中
```
堆栈内存图解.png

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

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

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

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

  • 例一

    let a = 20;
    let b = a;
    b = 30;
    console.log(a) // 这时a的值是多少?
    
原始类型的复制.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的值是多少
    
引用类型的复制.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/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容

  • 1.ios高性能编程 (1).内层 最小的内层平均值和峰值(2).耗电量 高效的算法和数据结构(3).初始化时...
    欧辰_OSR阅读 29,300评论 8 265
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,084评论 1 32
  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,182评论 11 349
  • Reflect on your powers of concentration. Do you find it d...
    黄虎阅读 439评论 0 2
  • 我爱篮球,我更喜欢篮球了,总之就是更喜欢了! 我今天也更喜欢老女人了!哈哈哈哈哈哈哈!我不知道我的喜欢可以持续多久...
    嘎旺阅读 202评论 0 0