如何写出优雅的深复制
发布于 4 年前 作者 jingdeng 1675 次浏览 来自 分享

前言

无论在项目开发或者学习中,深拷贝已经是一个老生常谈的话题了,但是在实际中,如何优雅地写出深拷贝是我们值得思考的一个问题

内容

深拷贝 与 浅拷贝

  • 深拷贝
    将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象
  • 浅拷贝
    如果一个对象有着原始对象属性值的一份精确拷贝。如果这个对象属性是基本类型,那么拷贝的就是基本类型的值,如果属性是引用类型,那么拷贝的就是内存地址。

区别

其实深拷贝和浅拷贝的主要区别就是其在内存中的存储类型不同。

堆和栈都是内存中划分出来用来存储的区域。

栈(stack)为自动分配的内存空间,它由系统自动释放;而堆(heap)则是动态分配的内存,大小不定也不会自动释放。

对于js中的基本数据类型,他们的值被以键值对的形式保存在栈中。

与基本类型不同的是,引用类型的值被保存在堆内存中,对象的引用被保存在栈内存中,而且我们不可以直接访问堆内存,只能访问栈内存。所以我们操作引用类型时实际操作的是对象的引用。

了解相关的基础知识后,我们话不多说,直奔主图

简单版

在不使用第三方库的情况下,我们想要深拷贝一个对象,用的最多的就是下面这个方法。

  JSON.parse(JSON.stringify());

这种写法非常简单,而且可以应对大部分的应用场景,但是它还是有很大缺陷的,比如拷贝其他引用类型、拷贝函数、循环引用等情况。

基础版

如果是浅拷贝对象时,我们可以很容易的就写出代码

function clone(obj) {
    let cloneObj = {};
    for (const key in obj) {
        cloneObj [key] = obj[key];
    }
    return cloneObj ;
};

对于浅拷贝而言,只需要简单地将对象的每一个属性进行复制即可。然而,对于深拷贝而言,我们拷贝对象的话是需要知道目标对象的属性是否是基本数据类型以及对象的深度。这些我们可以通过递归的方法来实现。

/*
*  作用: 深复制对象属性
*/
function clone(obj) {  
    if (typeof obj=== 'object') {
        let cloneObj = {};
        for (const key in obj) {
            cloneObj [key] = clone(obj[key]);
        }
        return cloneObj ;
    } else {
        return obj;
    }
};

这时候,我们实现了一个基础的深复制,那么问题来了,对于数组,该如何实现呢?

加深版

在上面的版本中,我们的初始化结果只考虑了普通的object,下面我们只需要把初始化代码稍微一变,就可以兼容数组了:

function clone(obj) {
    if (typeof obj=== 'object') {
        let cloneObj = Array.isArray(obj) ? [] : {};
        for (const key in target) {
            cloneObj[key] = clone(obj[key]);
        }
        return cloneObj;
    } else {
        return obj;
    }
};

在判断目标对象是引用类型时,则通过Array.isArray方法判断是否是数组,如果是则赋值为空数组,否则赋值为空对象。

循环引用

当对象子属性的值是父对象时,则递归的方法将不再适用。原因就是对象存在循环引用的情况,即对象的属性间接或直接的引用了自身的情况,这将导致递归进入死循环导致栈内存溢出。
为了解决这个问题,我们可以通过WeakMap这种数据结构来实现。首先我们通过WeakMap来存储当前对象和拷贝对象的对应关系。当需要拷贝当前对象时,先去WeakMap中找,有则返回,无则set。

WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。
function clone(target, weakMap = new WeakMap()) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        if (weakMap.get(target)) {
            return weakMap.get(target);
        }
        weakMap.set(target, cloneTarget);
        for (const key in target) {
            cloneTarget[key] = clone(target[key], weakMap);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

考虑一下性能,while循环的性能要比for on的要好,因此改造一下

function forEach(array, iteratee) {
    let index = 0;
    while (index < array.length) {
        iteratee(index, array[index]);
        index++;
    }
    return array;
}

function clone(target, weakMap = new WeakMap()) {
    if (typeof target === 'object') {
        const isArray = Array.isArray(target);
        let cloneTarget = Array.isArray(target) ? [] : {};
        if (weakMap.get(target)) {
            return weakMap.get(target);
        }
        weakMap.set(target, cloneTarget);
        const keyList = isArray ? undefined : Object.keys(target);
        forEach( keyList || target , function(key, value){
            if(keyList){ // 对象而言,其值才是他的key
                key = value;
            }
            cloneTarget[key] = clone(target[key], weakMap);
        })
        return cloneTarget;
    } else {
        return target;
    }
};

其他数据类型

综上我们考虑到的只是普通的object以及array俩种数据类型,但引用类型并不单只有这俩个,还有很多。。。

判断是否是引用类型

在判断是否是引用类型时,我们可以通过typeof字段,此时我们还需要考虑typeof可能返回’function’字符串以及对象有可能是null的情况,因此可写出判断函数如下所示

function isObject(target) {
    const type = typeof target;
    return target !== null && (type === 'object' || type === 'function');
}
获取数据类型

我们可以使用toString来获取准确的引用类型:

function getType(target) {
    return Object.prototype.toString.call(target);
}



根据上面的返回的字符串,我们可以抽离出一些常用的数据类型以便后面使用:

const mapTag = '[object Map]';
const setTag = '[object Set]';
const arrayTag = '[object Array]';
const objectTag = '[object Object]';

const boolTag = '[object Boolean]';
const dateTag = '[object Date]';
const errorTag = '[object Error]';
const numberTag = '[object Number]';
const regexpTag = '[object RegExp]';
const stringTag = '[object String]';
const symbolTag = '[object Symbol]';

在上面的集中类型中,我们简单将他们分为两类:

可以继续遍历的类型
不可以继续遍历的类型

可继续遍历的类型

上面我们已经考虑的object、array都属于可以继续遍历的类型,因为它们内存都还可以存储其他数据类型的数据,另外还有Map,Set等都是可以继续遍历的类型
这时候我们需要一个通过对象原型上的constructor属性获取构造函数,从而对要复制的对象进行初始化。方法如下:

function getInit(target) {
    const Ctor = target.constructor;
    return new Ctor();
}

下面我们改写一下clone函数,让他兼容map,set。

const mapTag = '[object Map]';
const setTag = '[object Set]';
const arrayTag = '[object Array]';
const objectTag = '[object Object]';

const deepTag = [mapTag, setTag, arrayTag, objectTag];

function clone(target, weakMap = new WeakMap()) {
    // 克隆基本数据类型
    if (!isObject(target)) {
        return target;
    }

    // 初始化
    const type = getType(target);
    let cloneTarget;
    if (deepTag.includes(type)) {
        cloneTarget = getInit(target, type);
    }

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

    // 克隆set
    if (type === setTag) {
        target.forEach(value => {
            cloneTarget.add(clone(value,weakMap));
        });
        return cloneTarget;
    }

    // 克隆map
    if (type === mapTag) {
        target.forEach((value, key) => {
            cloneTarget.set(key, clone(value,weakMap));
        });
        return cloneTarget;
    }

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

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

其他剩余的类型我们把它们统一归类成不可处理的数据类型,我们依次进行处理:

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

function cloneOtherType(targe, type) {
    const Ctor = targe.constructor;
    switch (type) {
        case boolTag:
        case numberTag:
        case stringTag:
        case errorTag:
        case dateTag:
            return new Ctor(targe);
        case regexpTag:
            return cloneReg(targe);
        case symbolTag:
            return cloneSymbol(targe);
        default:
            return null;
    }
}
function cloneSymbol(targe) {
    return Object(Symbol.prototype.valueOf.call(targe));
}

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

对于克隆函数,实际上是没有太大的意义。。。因为不同的俩个对象使用同一个函数是没有任何问题的。
首先,我们可以通过prototype来区分下箭头函数和普通函数,箭头函数是没有prototype的。
我们可以直接使用eval和函数字符串来重新生成一个箭头函数,注意这种方法是不适用于普通函数的。

function 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);
    }
}

总结

综上,我们围绕深复制进行了解析,了解到了应该如何写出优雅的深复制,在实际开发中,可以根据不同的场景,合理的选择如何书写深复制。

2 回复

最后一个正向后行断言好多平台都不支持

回到顶部