阅读 447

深入理解JS的深拷贝

导语

「 本文共 4127 字,预计阅读全文需要 14 分钟 」

经常在网上看到各种讲解深拷贝的文章,但是说实话很多关于深拷贝的文章,无论是概念还是代码都是非常简陋的,甚至是有误导性的。

鉴于此,我希望通过本文和大家由浅入深的学习深拷贝。

阅读本文前可以先思考三个问题:

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

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

深入理解JS系列

本文为《深入理解JS系列》第一更,敬请关注!
下一更预告:《深入理解JS的原型和原型链》(2020年02月26日已更
将会从上帝视角讲解原型和原型链,并且拓展相关知识点

数据是如何存储的

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

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)指的就是调用栈,用来存储执行上下文的。(每个执行上下文包括了:变量环境、词法环境)

堆空间:堆(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);
};

/*
* 1、处理原始类型 Number String Boolean Symbol Null Undefined
* 2、处理循环引用情况 WeakMap
* 3、处理可遍历类型 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);
};

/*
* 1、处理原始类型 Number String Boolean Symbol Null Undefined
* 2、处理不可遍历类型 Date RegExp Function
* 3、处理循环引用情况 WeakMap
* 4、处理可遍历类型 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

如果对你有帮助的话,点个赞吧! 有任何疑问随时与我在评论区交流

关注下面的标签,发现更多相似文章
评论