这一次,完全理解JavaScript深拷贝

导语

这一次,经过本文完全理解JavaScript深拷贝!javascript

阅读本文前能够先思考三个问题:前端

  • JS世界里,数据是如何存储的?
  • 深拷贝和浅拷贝的区别是什么?
  • 如何写出一个真正合格的深拷贝?

本文会一步步解答这三个问题java

数据是如何存储的

先看一个问题,下面这段代码的输出结果是什么:数组

function foo(){
    let a = {name:"dellyoung"}
    let b = a
    a.name = "dell" 
    console.log(a)
    console.log(b)
}
foo()

JS的内存空间

要解答这个问题就要先了解,JS中数据是如何存储的。浏览器

要理解JS中数据是如何存储的,就要先明白其内存空间的种类。下图就是JS的内存空间模型。性能优化

从模型中咱们能够看出JS内存空间分为:代码空间、栈空间、堆空间。微信

代码空间:代码空间主要是存储可执行代码的。函数

栈空间:栈(call stack)指的就是调用栈,用来存储执行上下文的。(每一个执行上下文包括了:变量环境、词法环境)post

堆空间:堆(Heap)空间,通常用来存储对象的。性能

JS的数据类型

如今咱们已经了解JS内存空间了。接下来咱们了解一下JS中的数据类型 :


JS中一共有8中数据类型:Number、BigInt、String、Boolean、Symble、Null、Undefined、Object。

前7种称为原始类型,最后一种Object称为引用类型,之因此把它们区分红两种类型,是由于它们在内存中存放的位置不一样

原始类型存放在栈空间中,具体点到执行上下文来讲就是:用var定义的变量会存放在变量环境中,而用let、const定义的变量会存放在词法环境中。而且对原始类型来讲存放的是值,而引用类型存放的是指针,指针指向堆内存中存放的真正内容。

好啦,如今咱们就明白JS中数据是如何存储的了:原始类型存放在栈空间中,引用类型存放在堆空间中

深拷贝和浅拷贝的区别

咱们先来明确一下深拷贝和浅拷贝的定义:

浅拷贝

建立一个新对象,这个对象有着原始对象属性值的一份精确拷贝。若是属性是基本类型,拷贝的就是基本类型的值,若是属性是引用类型,拷贝的就是内存地址 ,因此修改新拷贝的对象会影响原对象。

深拷贝

将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象

接下来咱们就开始逐步实现一个深拷贝

自带版

通常状况下若是不使用loadsh的深拷贝函数,咱们可能会这样写一个深拷贝函数

JSON.parse(JSON.stringify());

可是这个方法局限性比较大:

  • 会忽略 undefined
  • 会忽略 symbol
  • 不能序列化函数
  • 不能解决循环引用的对象

显然这绝对不是咱们想要的一个合格的深拷贝函数

基本版

手动实现的话咱们很容易写出以下函数

const clone = (target) => {
    let cloneTarget = {};
    Object.keys(target).forEach((item) => {
        cloneTarget[item] = target[item]
    });
    return cloneTarget
}

先看下这个函数作了什么:建立一个新对象,遍历原对象,而且将须要拷贝的对象依次添加到新对象上,返回新对象。

既然是深拷贝的话,对于引用了类型咱们不知道对象属性的深度,咱们能够经过递归来解决这个问题,接下来咱们修改一下上面的代码:

  • 判断是不是引用类型,若是是原始类型的话直接返回就能够了。
  • 若是是原始类型,那么咱们须要建立一个对象,遍历原对象,将须要拷贝的对象执行深拷贝后再依次添加到新对象上。
  • 另外若是对象有更深层次的对象,咱们就能够经过递归来解决。

这样咱们就实现了一个最基本的深拷贝函数:

// 是不是引用类型
const isObject = (target) => {
    return typeof target === 'object';
};

const clone = (target) => {
    // 处理原始类型直接返回(Number BigInt String Boolean Symbol Undefined Null)
    if (!isObject(target)) {
        return target;
    }
    let cloneTarget = {};
    Object.keys(target).forEach((item) => {
        cloneTarget[item] = clone(target[item])
    });
    return cloneTarget
}

显然这个深拷贝函数还有不少缺陷,好比:没有考虑包含数组的状况

考虑数组

上面代码中,咱们只考虑了是object的状况,并无考虑存在数组的状况。改为兼容数组也很是简单:

  • 判断传入的对象是数组仍是对象,咱们分别对它们进行处理
  • 判断类型的方法有不少好比 type of、instanceof,可是这两种方法缺陷都比较多,这里我使用的是Object.prototype.toString.call()的方法,它能够精准的判断各类类型
  • 当判断出是数组时,那么咱们须要建立一个新数组,遍历原数组,将须要数组中的每一个值执行深拷贝后再依次添加到新的数组上,返回新数组。

代码以下:

const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 是不是引用类型
const isObject = (target) => {
    return typeof target === 'object';
};

// 获取标准类型
const getType = (target) => {
    return Object.prototype.toString.call(target);
};

const clone = (target) => {
    // 处理原始类型直接返回(Number BigInt String Boolean Symbol Undefined Null)
    if (!isObject(target)) {
        return target;
    }
    const type = getType(target);
    let cloneTarget;
    switch (type) {
        case typeArray:
            // 数组
            cloneTarget = [];
            target.forEach((item, index) => {
                cloneTarget[index] = clone(item)
            });
            return cloneTarget;
        case typeObject:
            // 对象
            cloneTarget = {};
            Object.keys(target).forEach((item) => {
                cloneTarget[item] = clone(target[item])
            });
            return cloneTarget;
        default:
            return target;
    }
    return cloneTarget
}

OK,这样咱们的深拷贝函数就兼容了最经常使用的数组和对象的状况。

循环引用

可是若是出现下面这种状况

const target = {
    field1: 1,
    field2: {
        child: 'dellyoung'
    },
    field3: [2, 4, 8]
};
target.target = target;

咱们来拷贝这个target对象的话,就会发现会出现报错:循环引用致使了栈溢出。

解决循环引用问题,咱们须要额外有一个空间,来专门存储已经被拷贝过的对象。当须要拷贝对象时,咱们先从这个空间里找是否已经拷贝过,若是拷贝过了就直接返回这个对象,没有拷贝过就进行接下来的拷贝。须要注意的是只有可遍历的引用类型才会出现循环引用的状况。

很显然这种状况下咱们使用Map,以key-value来存储就很是的合适:

  • 用has方法检查Map中有无克隆过的对象
  • 有的话就获取Map存入的值后直接返回
  • 没有的话以当前对象为key,以拷贝获得的值为value存储到Map中
  • 继续进行克隆
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 是不是引用类型
const isObject = (target) => {
    return typeof target === 'object';
};

// 获取标准类型
const getType = (target) => {
    return Object.prototype.toString.call(target);
};

const clone = (target, map = new Map()) => {
    // 处理原始类型直接返回(Number BigInt String Boolean Symbol Undefined Null)
    if (!isObject(target)) {
        return target;
    }
    const type = getType(target);
    // 用于返回
    let cloneTarget;

    // 处理循环引用
    if (map.get(target)) {
        // 已经放入过map的直接返回
        return map.get(target)
    }
    
    switch (type) {
        case typeArray:
            // 数组
            cloneTarget = [];
            map.set(target, cloneTarget);
            target.forEach((item, index) => {
                cloneTarget[index] = clone(item, map)
            });
            return cloneTarget;
        case typeObject:
            // 对象
            cloneTarget = {};
            map.set(target, cloneTarget);
            Object.keys(target).forEach((item) => {
                cloneTarget[item] = clone(target[item], map)
            });
            return cloneTarget;
        default:
            return target;
    }
    
    return cloneTarget
}

性能优化

循环性能优化:

其实咱们写代码的时候已经考虑到了性能优化了,好比:循环没有使用 for in 循环而是使用的forEach循环,使用forEach或while循环会比for in循环快上很多的

WeakMap性能优化:

咱们可使用WeakMap来替代Map,提升性能。

const clone = (target, map = new WeakMap()) => {
    // ...
};

为何要这样作呢?,先来看看WeakMap的做用:

WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值能够是任意的。

那什么是弱引用呢?

在计算机程序设计中,弱引用与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。 一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,并所以可能在任什么时候刻被回收。

咱们默认建立一个对象:const obj = {},就默认建立了一个强引用的对象,咱们只有手动将obj = null,它才会被垃圾回收机制进行回收,若是是弱引用对象,垃圾回收机制会自动帮咱们回收。

咱们来举个例子:

let obj = { name : 'dellyoung'}
const target = new Map();
target.set(obj,'dell');
obj = null;

虽然咱们手动将obj赋值为null,进行释放,可是target依然对obj存在强引用关系,因此这部份内存依然没法被释放。

基于此咱们再来看WeakMap:

let obj = { name : 'dellyoung'}
const target = new WeakMap();
target.set(obj,'dell');
obj = null;

若是是WeakMap的话,target和obj存在的就是弱引用关系,当下一次垃圾回收机制执行的时候,这块内存就会被释放掉了。

若是咱们要拷贝的对象很是庞大时,使用Map会对内存形成很是大的额外消耗,并且咱们须要手动delete Map的key才能释放这块内存,而WeakMap会帮咱们解决这个问题。

更多的数据类型

到如今其实咱们已经解决了Number BigInt String Boolean Symbol Undefined Null Object Array,这9种状况了,可是引用类型中咱们其实只考虑了Object和Array两种数据类型,可是实际上全部的引用类型远远不止这两个。

判断引用类型

判断是不是引用类型还须要考虑null和function两种类型。

// 是不是引用类型
const isObject = (target) => {
    if (target === null) {
        return false;
    } else {
        const type = typeof target;
        return type === 'object' || type === 'function';
    }
};

获取数据类型

获取类型,咱们可使用toString来获取准确的引用类型:

每个引用类型都有toString方法,默认状况下,toString()方法被每一个Object对象继承。若是此方法在自定义对象中未被覆盖,toString() 返回 "[object type]",其中type是对象的类型。

可是因为大部分引用类型好比Array、Date、RegExp等都重写了toString方法,因此咱们能够直接调用Object原型上未被覆盖的toString()方法,使用call来改变this指向来达到咱们想要的效果

// 获取标准类型
const getType = (target) => {
    return Object.prototype.toString.call(target);
};

类型很是多,本文先考虑大部分经常使用的类型,其余类型就等小伙伴来探索啦

// 可遍历类型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 非原始类型的 不可遍历类型  Date RegExp Function
const typeDate = '[object Date]';
const typeRegExp = '[object RegExp]';
const typeFunction = '[object Function]';

可继续遍历类型

上面咱们已经考虑的Object、Array都属于能够继续遍历的类型,由于它们内存都还能够存储其余数据类型的数据,另外还有Map,Set等都是能够继续遍历的类型,这里咱们只考虑这四种经常使用的,其余类型等你来探索咯。

下面,咱们改写clone函数,使其对可继续遍历的数据类型进行处理:

// 可遍历类型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';

// 是不是引用类型
const isObject = (target) => {
    if (target === null) {
        return false;
    } else {
        const type = typeof target;
        return type === 'object' || type === 'function';
    }
};

// 获取标准类型
const getType = (target) => {
    return Object.prototype.toString.call(target);
};

/*
* 一、处理原始类型 Number String Boolean Symbol Null Undefined
* 二、处理循环引用状况 WeakMap
* 三、处理可遍历类型 Set Map Array Object
* */
const clone = (target, map = new WeakMap()) => {
    // 处理原始类型直接返回(Number BigInt String Boolean Symbol Undefined Null)
    if (!isObject(target)) {
        return target;
    }

    // 用于返回
    let cloneTarget;

    // 处理循环引用
    if (map.get(target)) {
        // 已经放入过map的直接返回
        return map.get(target)
    }

    // 处理可遍历类型
    switch (type) {
        case typeSet:
            // Set
            cloneTarget = new Set();
            map.set(target, cloneTarget);
            target.forEach((item) => {
                cloneTarget.add(clone(item, map))
            });
            return cloneTarget;
        case typeMap:
            // Map
            cloneTarget = new Map();
            map.set(target, cloneTarget);
            target.forEach((value, key) => {
                cloneTarget.set(key, clone(value, map))
            });
            return cloneTarget;
        case typeArray:
            // 数组
            cloneTarget = [];
            map.set(target, cloneTarget);
            target.forEach((item, index) => {
                cloneTarget[index] = clone(item, map)
            });
            return cloneTarget;
        case typeObject:
            // 对象
            cloneTarget = {};
            map.set(target, cloneTarget);
            Object.keys(target).forEach((item) => {
                cloneTarget[item] = clone(target[item], map)
            });
            return cloneTarget;
        default:
            return target;
    }
};

这样咱们就完成了对Set和Map的兼容

考虑对象键名为Symbol类型

对于对象键名为Symbol类型时,用Object.keys(target)是获取不到的,这时候就须要用到Object.getOwnPropertySymbols(target)方法。

case typeObject:
    // 对象
    cloneTarget = {};
    map.set(target, cloneTarget);
    [...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach((item) => {
        cloneTarget[item] = clone(target[item], map)
    });
    return cloneTarget;

这样就实现了对于对象键名为Symbol类型的兼容。

不可继续遍历类型

不可遍历的类型有Number BigInt String Boolean Symbol Undefined Null Date RegExp Function 等等,可是前7中已经被isObject拦截了,因而咱们先对后面Date RegExp Function进行处理,其实后面不止有这几种,其余类型等你来探索咯。

其中对函数的处理要简单说下,我认为克隆函数是没有必要的其实,两个对象使用一个在内存中处于同一个地址的函数也是没有任何问题的,以下是lodash对函数的处理:

const isFunc = typeof value == 'function'
 if (isFunc || !cloneableTags[tag]) {
        return object ? value : {}
 }

显然若是发现是函数的话就会直接返回了,没有作特殊的处理,这里咱们暂时也这样处理,之后有时间我会把拷贝函数的部分给补上。

// 可遍历类型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 非原始类型的 不可遍历类型  Date RegExp Function
const typeDate = '[object Date]';
const typeRegExp = '[object RegExp]';
const typeFunction = '[object Function]';

// 非原始类型的 不可遍历类型的 集合(原始类型已经被过滤了不用再考虑了)
const simpleType = [typeDate, typeRegExp, typeFunction];

// 是不是引用类型
const isObject = (target) => {
    if (target === null) {
        return false;
    } else {
        const type = typeof target;
        return type === 'object' || type === 'function';
    }
};

// 获取标准类型
const getType = (target) => {
    return Object.prototype.toString.call(target);
};

/*
* 一、处理原始类型 Number String Boolean Symbol Null Undefined
* 二、处理不可遍历类型 Date RegExp Function
* 三、处理循环引用状况 WeakMap
* 四、处理可遍历类型 Set Map Array Object
* */
const clone = (target, map = new WeakMap()) => {
    // 处理原始类型直接返回(Number BigInt String Boolean Symbol Undefined Null)
    if (!isObject(target)) {
        return target;
    }

    // 处理不可遍历类型
    const type = getType(target);
    if (simpleType.includes(type)) {
        switch (type) {
            case typeDate:
                // 日期
                return new Date(target);
            case typeRegExp:
                // 正则
                const reg = /\w*$/;
                const result = new RegExp(target.source, reg.exec(target)[0]);
                result.lastIndex = target.lastIndex; // lastIndex 表示每次匹配时的开始位置
                return result;
            case typeFunction:
                // 函数
                return target;
            default:
                return target;
        }
    }

    // 用于返回
    let cloneTarget;

    // 处理循环引用
    if (map.get(target)) {
        // 已经放入过map的直接返回
        return map.get(target)
    }

    // 处理可遍历类型
    switch (type) {
        case typeSet:
            // Set
            cloneTarget = new Set();
            map.set(target, cloneTarget);
            target.forEach((item) => {
                cloneTarget.add(clone(item, map))
            });
            return cloneTarget;
        case typeMap:
            // Map
            cloneTarget = new Map();
            map.set(target, cloneTarget);
            target.forEach((value, key) => {
                cloneTarget.set(key, clone(value, map))
            });
            return cloneTarget;
        case typeArray:
            // 数组
            cloneTarget = [];
            map.set(target, cloneTarget);
            target.forEach((item, index) => {
                cloneTarget[index] = clone(item, map)
            });
            return cloneTarget;
        case typeObject:
            // 对象
            cloneTarget = {};
            map.set(target, cloneTarget);
            [...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach((item) => {
                cloneTarget[item] = clone(target[item], map)
            });
            return cloneTarget;
        default:
            return target;
    }
};

至此这个深拷贝函数已经能处理大部分的类型了:Number String Boolean Symbol Null Undefined Date RegExp Function Set Map Array Object,而且也能优秀的处理循环引用状况了

参考

总结

如今咱们应该能理清楚写一个合格深拷贝的思路了:

  • 处理原始类型 如: Number String Boolean Symbol Null Undefined
  • 处理不可遍历类型 如: Date RegExp Function
  • 处理循环引用状况 使用: WeakMap
  • 处理可遍历类型 如: Set Map Array Object

看完两件事

  • 欢迎加我微信(iamyyymmm),拉你进技术群,长期交流学习
  • 关注公众号「呆鹅实验室」,和呆鹅一块儿学前端,提升技术认知

🌈点个赞支持我吧👉​

相关文章
相关标签/搜索