抽丝剥笋JS“深拷贝”

181 阅读8分钟

简单的深拷贝:JSON深拷贝

const newObj = JSON.parse(JSON.stringify(obj))

JSON方式实现深拷贝的弊端是:

  1. 无法拷贝Symbol和undefined1。Symbol作为 属性/值 都无法被拷贝到新对象中;
  2. 不支持循环引用。循环引用指的是对象中的一个属性指向了对象本身,原对象若使用了循环引用,那么JSON拷贝的时候就会报错;
  3. 无法拷贝出原对象中的函数
const s1 = Symbol();
const s2 = Symbol();
const obj = {
    foo: function() { // 函数的拷贝
        console.log("foo function");
    }, 
    [s1]: "abc", // Symbol作为key
    s2: s2, // Symbol作为value
    temp: undefined
}
obj.inner = obj; // 循环引用
const newObj = JSON.parse(JSON.stringify(obj)); // 直接报错

注:上面说了深拷贝,这里也注明一下常见的浅拷贝

  • Object.assign:Object.assign( { }, obj )
  • Array.prototype.slice()Array.prototype.concat():数组深拷贝
  • 使用扩展运算符实现的复制:{ ... obj }, [ ... arr ]  

手写深拷贝

实现深拷贝1 - 递归基本实现

递归的终止条件

当值不是对象时,停止向下递归;

递归的参数和返回值

参数是要被拷贝的对象值,返回值是拷贝出来的新对象;

单层递归逻辑

首先创建一个新对象,然后遍历传入将被拷贝对象的属性,对该原对象属性执行递归向下遍历,并将遍历创建的结果返回赋给新对象的属性。当for循环遍历结束后,将新对象返回

注:如何判断是否为对象类型】我们可以自己封装判断对象类型的函数,typeof就可以,但要特判一下null,因为typeof null的结果是true,所以要去掉这一情况:

function isObject(value) {
    const valueType = typeof value;
    // 注意函数也可以当做对象
    return (value !== null) && (valueType === "object" || valueType === "function")
}

代码示例:

function deepClone(originValue) {
    // 不是对象就直接返回,这样做的好处是可以保证在向函数传入基本数据类型的时候不出错,直接进行值拷贝
    if(!isOjbect(originValue)) return originValue; 
    // 否则就直接创建一个新的对象
    const newObject = {};
    for(const key in originValue) { // 遍历对象的key,这样新对象也可以用 [] 来创建新的属性
        newObject[key] = deepClone(originValue[key]); 
    }
    return newObject; // 将新对象返回
}

function isObject(value) { // 判断是否为对象
    const valueType = typeof value;
    return (value !== null) && (valueType === "object" || valueType === "function")
}

 

实现深拷贝2 - 特殊数据类型的处理

Version 1 的做法中,无论是数组,还是函数,还是Set/Map 最后都是被解析成了一个对象(即中括号:{}),且如Symbol作为键会被忽略,Symbol作为值则拷贝两个对象的值可能是一样的,这不符合我们对Symbol的期待等问题,这样的拷贝是合理的,我们应该分情况讨论:

数组

数组和对象类似,同时也可能是要做深拷贝的,所以若类型是数组,我们要创建一个空数组去接收之后深拷贝的内容;

函数

函数本身就是大家共享使用的,所以直接返回,不用特殊处理

Symbol作为值

由于Symbol的值要保持独一无二性,所以我们不能直接拷贝原对象中Symbol的值,而要重新创建,创建时拿到原对象中Symbol的描述再去创建即可:s.description(s为Symbol创建出的)

Symbol作为键

Symbol作为键在for/for-of/for-in直接遍历时遍历不出来的,我们可以通过Object.getOwnPropertySymbols(obj)拿到所有的Symbol键,在根据这些键按照深拷贝的逻辑再遍历一次

Set/Map

这里也是重新创建,创建过程中通过扩展运算符将原对象中的Set/Map展开后重新创建一遍: [... originSet][... originMap]

】所有特殊类型的判断处理逻辑都写在正常逻辑的前面,防止其进入正常逻辑处理环节。

代码示例

// 优化后的深拷贝
function deepClone(originValue) {
    // Set/Object通过typeof无法直接判断,只能得出是object的结论,所以要用到instanceof
    if(originValue instanceof Set) return new Set([... originValue]);
    // 获取set内部的内容,最好的方式就是解构为数组,lc中也有所体现

    // map和set是类似(instanceof判断),都是new Map(展开)
    if(originValue instanceof Map) return new Map([... originValue]);
    
    // 注意typeof的结果都是小写的
    // 判断如果是Symbol的value,那么也创建一个新的Symbol
    if(typeof originValue === "symbol") return Symbol(originValue.description);
    
    // 如果是一个函数类型,就直接返回即可(共用),比如java中的类就是这样做的
    if(typeof originValue === "function") return originValue; 
    
    // 在完成特判后,若发现是基本类型就直接返回
    if(!isOjbect(originValue)) return originValue; 
    
    // 判断传入的对象是数组,还是对象:
    const newObject = Array.isArray(originValue) ? [] : {}; // 是数组就建立数组,否则就是对象
    for(const key in originValue) { 
        newObject[key] = deepClone(originValue[key]); 
    }

    // 上面的for循环是拿不到Symbol作为key的属性的,对Symbol的key进行特殊的处理
    const symbolKeys = Object.getOwnPropertySymbols(originValue);
    // Object上有这样一个方法可以拿到对象中所有的Symbol作为键的一个数组
    for(const sKey of symbolKeys) { // 深拷贝的递归逻辑
        newObject[sKey] = deepClone(originValue[sKey]);
    } 
    
    return newObject;
}

// 判断是否为大范围的对象类型(但我们是反过来用,判断其是否为基本类型)
function isOjbect(value) {
    const valueType = typeof value;
    return (value !== null) && (valueType === "object" || valueType === "function")
}

 

实现深拷贝3(终版) - 解决循环引用

上面几个版本没有解决的一个重要问题是:循环引用,循环引用就是对象中的某个属性指向对象自己,我们在递归遍历对象属性时,若有这一指向对象自身的属性,那么就会一直向下递归没有尽头 。。。

一直遍历下去就会出现死循环,然后就堆栈崩了

  在obj创建后,我们希望再次deepClone的时候可直接将其返回,而不是深度递归,具体的实现方案就是:维护一个map,每次创建了一个新的newOjbect后就按照originValue 作为键装入到map中,下一次再遇到时就直接返回结果,不陷入无限递归中(注意map的键可以是对象的,只要下次再遇到原对象自己<虽然本质上对象还是被转为了字符串,但不同对象的字符串是不同的,这和某个对象作为对象属性是不同的,且WeakMap只能接收对象作为键值>,直接将新对象返回,中止递归)。 即循环引用可以obj.key.key.key...一直引用下去,现在我们在第二时就直接将新创建的对象返回回去。

【对象作为map的键和对象的属性】

某个对象1在对象2中作为属性时,该属性会被直接转为字符串:'[object Object]',若在对象中多个对象作属性,那么因为属性值相同,是会发生属性覆盖的:

const info1 = { name: 'info1' };
const info2 = { name: 'info2' };
const obj = {
    [info1]: 'obj1', 
    [info2]: 'obj2'
}
console.log(obj); // { '[object Object]': 'obj2' },只有一个属性

在Map中对象作为属性,虽然也会被转为字符串,但是不同对象所对应的字符串是不同的,所以不同对象可以作为不同的键存在:

const info1 = { name: 'info1' };
const info2 = { name: 'info2' };
const map = new Map([[info1, 'info1'], [info2, 'info2']]);
// Map(2) { { name: 'info1' } => 'info1', { name: 'info2' } => 'info2' }
console.log(map)

map要在哪里创建

  若在全局,那么不同对象进行深拷贝的时候都用到map,这样map会不堪重负,所以应该放入到函数当中,但放到函数中每次都会创建一个新的map,这里 提供了一个绝妙的解决思路:就是让map作为函数的一个参数,然后默认创建,每次传递的时候 就将开始创建的map口口相传即可了

map在垃圾回收方面的优化

  还有map作为参数创建时,构造函数可以用WeakMap(),这样外界在想销毁传入的map时,可以销毁而不是因为强引用的关系而无法销毁(WeakMap是弱引用,对GC而言,弱引用是不起作用的,只有强引用才能防止被GC垃圾回收掉)。  

代码示例

// 其中WeakMap类型的map作为参数传递
function deepClone(originValue, map = new WeakMap()) {
    if(originValue instanceof Set) return new Set([... originValue]);

    if(originValue instanceof Map) return new Map([... originValue]);
    
    if(typeof originValue === "symbol") return Symbol(originValue.description);
    
    if(typeof originValue === "function") return originValue; 
    
    if(!isOjbect(originValue)) return originValue; 
    
    // 若发现map中已经存在了,直接将结果返回,中止向下递归
    if(map.has(originValue)) return map.get(originValue);
    
    const newObject = Array.isArray(originValue) ? [] : {}; 
    
    // newObject本身是在最开始的时候obj传入的后就放入到了map当中的(地址),然后后面则不断填充其属性
    map.set(originValue, newObject); 
    
    for(const key in originValue) { 
        newObject[key] = deepClone(originValue[key], map); // 传递map
    }

    const symbolKeys = Object.getOwnPropertySymbols(originValue);
    for(const sKey of symbolKeys) {
        newObject[sKey] = deepClone(originValue[sKey], map); // 这里的递归同样要传递map
    } 

    return newObject;
}

// 判断是否为对象
function isOjbect(value) {
    const valueType = typeof value;
    return (value !== null) && (valueType === "object" || valueType === "function")
}

注:最后的一点优化】我们在进行深拷贝循环的时候还可以先判断一下: originValue.hasOwnProperty(key) 为 true 再拷贝,只拷贝自己的属性,至于原型链上的父类属性等就不做拷贝了!  

小结

手写深拷贝总共可以分为3个层次:

  1. 单层拷贝逻辑的编写:逐层遍历,填充新创建的对象
  2. 特殊数据类型的处理:数组,函数,Set/Map,Symbol键/值 除函数外,其他均要重新创建
  3. 循环引用的解决:利用WeakMap中止无限递归,且其放在参数位置较好