手写实现深度拷贝

手写实现深度拷贝

本文参考:面试题之如何实现一个深拷贝

基础理论

拷贝的基础是赋值,在 js 中,将一个变量赋值给另一个变量时,有两种场景:

  • 基本类型数据的值拷贝
  • 引用类型数据的引用拷贝
var a = 1;
var b = {a: 1};

var a1 = a;
var b1 = b;
var b2 = b;

a1 = 2;
a; // 1   原始类型的赋值是值拷贝,两不影响

b1 = null;
b; // {a: 1}  对象类型的赋值是引用拷贝,修改引用指向,对原变量无影响
b2.a = 2; 
b; // {a: 2}  对象类型的赋值是引用拷贝,指向同一份对象,修改对象属性,会对原变量指向的对象有所影响

那么,对一个对象进行拷贝,无非就是对对象的属性进行拷贝,按照拷贝处理的方式不同,可分为浅拷贝和深拷贝:

  • 浅拷贝是只拷贝对象的第一层属性
  • 深拷贝则是无限层次的拷贝对象的属性,只要属性值不是基本类型,就继续深度遍历进去

浅拷贝的双方仍旧有所关联,因为有些属性只是引用拷贝而已,都是指向同一份数据,一方修改就会影响到另一方;

深拷贝的双方则是相互独立,互不影响。

在 js 中,内置的各种复制、拷贝的 API,都是浅拷贝,比如:Object.assign(),{...a},[].slice() 等等。

如果项目中有需要使用到深拷贝,那么就只能是自行实现,或者使用三方库。

实现深拷贝

有人可能会觉得自己实现个深拷贝很简单,毕竟都已经知道浅拷贝只拷贝一层,那深拷贝不就等效于浅拷贝 + 递归?

function cloneDeep(source) {
    let target = {};
    for (let key in source) {
        if (typeof source[key] === 'object') {
            target[key] = cloneDeep(source[key]);
        } else {
            target[key] = source[key];
        }
    }
    return target;
}

那么,上面的深拷贝实现有问题吗?

虽然从概念上,深拷贝就是需要层层遍历对象属性,只拷贝基本类型数据,对象类型再继续深入遍历,反应到代码上,的确也就是像上面的处理:基本类型值拷贝 + 对象类型递归处理。

但上例的代码,欠缺各种细节和场景的处理。

比如说:

  • 参数 source 的校验
  • typeof null 也是 object 的过滤处理
  • 属性 key 值类型是 Symbol 的场景
  • source 是数组时的兼容处理
  • 循环引用的场景
  • 引用关系丢失问题
  • 递归栈溢出的场景
  • 等等

所以,本篇想讲的深拷贝实现,就是希望能把这些细节和特殊场景考虑进去,同时,也会介绍一些不同的实现方案。

通用版

想要实现通用版,其实也就是要将上面列出来的细节和各自场景考虑进行,思考每个问题该如何解决:

  • 参数 source 的校验 & null 的过滤处理

毕竟如果不是对象的话,也就没有什么拷贝的意义了,直接原值返回即可,所以这里需要对参数进行是否是对象的判断处理。

使用 typeof 的话,由于 null 也是 object,所以还需要将 null 的场景过滤掉;

function isObject(o) {
    return typeof o === 'object' && o !== null;
}
  • Symbol 的处理

symbol 是 ES6 中新增的特性,使用 for in 方式遍历不到,所以需要 ES6 中新增的遍历方式:

  • Object.getOwnPropertySymbols()
  • Reflect.ownKeys()

前者是单独遍历对象键值类型为 Symbol 的属性,使用这种方式的话,等于是分两次处理对象,先深拷贝一次 Symbol 属性,再深拷贝一次其他属性。

后者 Reflect.ownKeys() 可以遍历到对象所有的自有属性,包括 Symbol 属性,它相当于 Object.getOwnPropertyNames() 和 Object.getOwnPropertySymbols() 的并集。使用这种方式的话,等于替换掉 for in 的遍历方式。

  • 数组的兼容处理

这个的意思是说,需要区分当前拷贝的对象是数组还是对象,毕竟总不能将数组的数据拷贝到对象里把,所以 target 的初始化需要处理一下,区分数组的场景:

let target = Array.isArray(source) ? [] : {};
  • 循环引用 & 引用关系丢失问题

这种场景还是用代码来解释比较清晰:

var a = {};
var o = {
    a: a,
    b: a
}
o.c = o; 
o; // {a: {}, b: {}, c: {a: {}, b: {}, c:{...}}}
o.a === o.b; // true

var o1 = cloneDeep(o); // 栈溢出异常,Maximum call stack size
// 把 o.c = o 注释掉, o1.a === o1.b  输出 false,原本的引用关系丢失了

循环引用指的是,对象的某个属性又指向了对象本身,这样就造成了具有无限深层次的结构,递归时自然就会栈溢出了。

引用关系丢失指的是,对象的多个属性都指向同一个某对象,但经过深拷贝后,这多个属性却都指向了不同的对象,虽然被指向的这些对象的值是一致的。

造成这两个问题的根本原因,其实就是,对于对象数据,每次都会重新创建一个新对象来存储拷贝过来的值。

所以,解决这两个问题,其实也很简单,就是不要每次都重新创建新的对象,复用已创建的对象即可。

比如说,在遍历拷贝 o.a 时,先创建一个新对象拷贝了 o.a,之后再处理 o.b 时,发现 o.b 也指向 o.a,那么就不要重新创建对象来拷贝了,直接将引用指向之前拷贝 o.a 时创建的对象即可,这样引用关系就保留下来了。

这样即使遇到循环引用,就将引用指向拷贝生成的新对象即可,就不会有栈溢出的场景了。

代码上的话,可以利用 ES6 的 map 数据结构,因为可以直接让 source 对象作为 key 来存储。

否则就得自己用数组存储,但由于数组 key 值也只能是字符串和 Symbol,所以映射关系只能自己用对象存,这么一来,还得自己写寻找的逻辑。

function cloneDeep(source, hash = new WeakMap()) {
    // ... 省略
    if (hash.get(source)) {
        return hash.get(source)
    }
    let target = Array.isArray(source) ? [] : {};
    hash.set(source, target);
    
    // target[key] = cloneDeep(source[key], hash); // 对象类型递归调用时,将 hash 传递进去 
    // .., 省略
}

function cloneDeep(source, hash = []) {
    // ... 省略
    let cache = hash.find(v => v.source === source);
    if (cache) {
        return cache.target;
    }
    let target = Array.isArray(source) ? [] : {};
    hash.push({ source: source, target: target });
    
    // target[key] = cloneDeep(source[key], hash); // 对象类型递归调用时,将 hash 传递进去
    // ... 省略
}
  • 栈溢出问题

递归的最大问题,就是怕遇到栈溢出,一旦递归层次多的话。

循环引用会导致递归层次过多而栈溢出,但可以通过已拷贝对象的缓存来解决这个问题。

但如果对象的结构层次过多时,这种现象就无法避免了,就必须来解决栈溢出问题了。

解决栈溢出两种思路:

  • 尾递归优化
  • 不用递归,改成循环实现

尾递归优化是指函数的最后一行代码都是调用自身函数,如果可以修改成这种模式,就可以达到尾递归优化。而这种方式之所以可以解决栈溢出,是因为,函数的最后一行都是调用自身函数,那其实就意味着当前函数执行上下文其实没必要保留了,之所以会栈溢出,就是执行上下文栈中存在过多函数执行上下文。

每次调用函数都会创建一个函数执行上下文(EC),并放入执行上下文栈(ECS)中,当函数执行结束时,就将函数执行上下文移出栈。

所以,函数内部嵌套调用函数时,就会造成 ECS 中有过多的 EC,递归是不断的在函数内调用自己,所以一旦层次过多,必然导致 ECS 爆表,栈溢出。

而尾递归,让递归函数的最后一行执行的代码都是调用自身,这就意味着,在递归调用自身时,当前函数的职责已结束,那么 EC 其实就可以从 ECS 中移出了,这样一来,不管递归层次多深,始终都只有一个递归函数的 EC 在 ECS 中,自然就不会造成栈溢出。

不过尾递归优化有个局限性,只在严格模式下才开启,因为非严格模式下,函数内部有 arguments 和 caller 两个变量会追踪调用栈,尾递归优化会导致这两变量失真报错,所以只在严格模式下才开启。

而且,正常递归函数改写成尾递归,基本操作都是将局部变量变成参数,保证最后执行的一行代码是调用自身。但由于深拷贝场景,是在遍历属性过程中递归调用自身,调用完自身后面肯定还需要遍历处理其他属性,所以无法做到最后一行调用自身的要求,也就无法改写成尾递归形式。

所以,尾递归优化这种方案放弃。

用循环替代递归是另外一种解决栈溢出方案,这种方式其实就是思考,原本需要使用递归的方式,有没有办法通过循环来实现。循环的话,也就不存在什么嵌套调用函数,也就不存在栈溢出的问题了。

对象的属性结构,其实就是一颗树结构,递归方案的深拷贝,其实也就是以深度优先来遍历对象的属性树。

但遍历树结构数据,除了使用递归方案外,也可以使用循环来遍历,但是需要借助相应的数据结构。

当使用循环来遍历树,且深度优先时,那么就需要借助栈;如果是广度优先时,则是需要借助队列。

具体做法则是,一次只处理一个节点,处理节点时遍历取出它所有子节点,代码上也就是双层循环,比如说:

  1. 从树根节点开始,遍历它的第一层子节点,把这些节点都放入栈或队列中,结束本次循环;
  2. 下次循环开始,取出栈顶或队头节点处理:若该节点还有子节点,那么遍历取出所有子节点,放入栈或队列中,结束本次循环;
  3. 重复第2步,直至栈或队列中无节点;
  4. 如果是用栈辅助,则对应深度优先遍历;如果是用队列辅助,则对应广度优先。

所以,这里用循环遍历对象属性树的方式来解决栈溢出问题。

  • 代码

最后就看看实现的代码,这里给出两个版本,分别是未处理栈溢出场景(递归方案)、循环替代递归:

未处理栈溢出版(递归方案):

// 递归遍历对象的属性树
function cloneDeep(source, hash = new WeakMap()) {
    // 1. 非对象类型数据,直接返回
    if (!(typeof source === 'object' && source !== null)) {
        return source;
    }
    // 2. 复用已拷贝的对象,解决引用关系丢失和循环引用问题
    if (hash.get(source)) {
        return hash.get(source);
    }
    
    // 3. 区分对象和数组
    let target = Array.isArray(source) ? [] : {};
    hash.set(source, target);  // 缓存已拷贝的对象
    
    // 4. 遍历对象所有自有属性,包括 Symbol
    Reflect.ownKeys(source).forEach(key => {
        // 跳过自有的不可枚举的属性
        if (!Object.getOwnPropertyDescriptor(source, key).enumerable) {
            return;
        }
        // 对象类型再继续递归遍历,其他类型直接赋值拷贝
        if (typeof source === 'object' && source !== null) {
            target[key] = cloneDeep(source[key], hash);
        } else {
            target[key] = source[key];
        }
    });
    
    return target;
}

循环替代递归版(循环方案):

// 循环遍历对象的属性树,跟递归方案中相同代码用途是一样的,这里就不注释了
function cloneDeep(source) {
    if (!(typeof source === 'object' && source !== null)) {
        return source;
    }
    let target = Array.isArray(source) ? [] : {};
    let hash = new WeakMap();
    
    // 将根节点放入栈中,节点结构说明:data 存储当前属性值,key 存储属性名,target 含义:target[key] = data
    let stack = [{
        data: source,
        key: undefined,
        target: target
    }];
    
    // 因为是借助 stack 栈辅助,所以是深度优先遍历,每次循环只处理一个节点
    while(stack.length > 0) {
        let node = stack.pop();
        if (typeof node.data === 'object' && node.data !== null) {
            // 当前对象有已拷贝过的缓存,则直接用缓存,解决引用关系丢失问题
            if (hash.get(node.data)) {
                node.target[node.key] = hash.get(node.data);
            } else {
                let target;
                // 构建拷贝对象的属性层次结构
                if (node.key !== undefined) {
                    target = Array.isArray(node.data) ? [] : {};
                    node.target[node.key] = target;
                } else {
                    target = node.target;
                }
                hash.set(node.data, target);
                Reflect.ownKeys(node.data).forEach(v =>{
                    if (!Object.getOwnPropertyDescriptor(node.data, v).enumerable) {
                        return;
                    }
                    stack.push({
                        data: node.data[v],
                        key: v,
                        target: target
                    }) 
                });
            }
        } else {
            // 当前节点是非对象类型,直接拷贝
            node.target[node.key] = node.data;
        }
    }
    return target;
}

测试用例:

// 测试用例
var a = {};
var o = {
    a: a,
    b: a,
    c: Symbol(),
    [Symbol()]: 1,
    d: function() {},
    e(){},
    f: () => {},
    get g(){},
    h: 1,
    i: 'sdff',
    j: null,
    k: undefined,
    o: /sdfdf/,
    p: new Date()
}

var o1 = cloneDeep(o);
o1;
/**
{
    a: {}
    b: {}
    c: Symbol()
    d: ƒ ()
    e: ƒ e()
    f: () => {}
    g: undefined
    h: 1
    i: "sdff"
    j: null
    k: undefined
    l: {l: {…}, p: {…}, o: {…}, k: undefined, j: null, …}
    o: {}
    p: {}
    Symbol(): 1
}
*/
// 正则的数据和 Date 数据都丢失了,这是因为判断对象的逻辑导致,typeof xx === 'object' 无法区别内置对象,想要解决,可以修改判断对象的逻辑,比如使用 Object.prototype.toString.call(xxx) 结合 Array.isArray 来只筛选出基本对象和数组类型
// get 存取器也只能拷贝到读取的时,无法拷贝 get 方法


// 测试栈溢出场景可借助该方法
function createData(deep, breadth) {
    var data = {};
    var temp = data;

    for (var i = 0; i < deep; i++) {
        temp = temp['data'] = {};
        for (var j = 0; j < breadth; j++) {
            temp[j] = j;
        }
    }

    return data;
}

var a = createData(10000);

cloneDeep(a); // 是否会栈溢出

其实,这通用版也不是100%通用,它仍旧有一些局限性,比如:

  • 没有考虑 ES6 的 set,Map 等新的数据结构类型
  • 拷贝后的对象的原型链结构,继承关系丢失问题
  • get,set 存取器逻辑无法拷贝
  • 没有考虑属性值是内置对象的场景,比如 /sfds/ 正则,或 new Date() 日期这些类型的数据
  • 等等

虽然如此,但这种方案已经大体上适用于绝大多数的场景了,如有问题,或者有新的需求,再根据需要进行扩展就可以了,欢迎指点一下。

JSON.parse/stringify 版

这是实现深拷贝最简单的一种方案,但是有很大局限性,只适用于部分场景:

var o = {
    a: 1,
    b: [1, 2, {a: 1}]
}

var o1 = JSON.parse(JSON.stringify(o));

它的原理很简单,就是借助现有工具 JSON,先将对象序列化,再反序列化得到一个新对象,这样一来,新对象跟原对象就是两个相互独立,互不影响的对象了,以此来实现深拷贝。

但它有很大的局限性,因为需要依赖于 JSON 的序列化和反序列化基础,比如说:

  • 不能序列化函数,属性值是函数的会丢失掉
  • 不能处理 Symbol 数据,不管是属性名还是属性值是 Symbol 的,都会丢失掉
  • 不能识别属性值手动设置为 undefined 的场景,会被认为是访问一个不存在的属性,从而导致丢失
  • 不能解决循环引用问题
  • 不能处理正则
  • 等等

使用这种方案,还是有很多局限性,看个代码就清楚了:

var o = {
    a: 1,
    [Symbol()]: 1,
    c: Symbol(),
    d: null,
    e: undefined,
    f: function() {},
    g() {},
    h: /sdfd/
}
var o1 = JSON.parse(JSON.stringify(o));
o1; // {a: 1, d: null, h: {}}
// 属性 c, e, f, g 都丢失掉了,h 属性值为正则表达式,也无法正常处理

那么,这种方案的深拷贝就没有什么用处吗?

也不是,它有它适用的场景,想想 JSON 是什么,是处理 json 对象的工具啊,而 json 对象通常是用来跟服务端交互的数据结构,在这种场景里,你一个 json 对象里,会有那些 Symbol、正则、函数奇奇怪怪的属性吗?显然不会。

所以,对于规范的 json 对象来说,如果需要进行深拷贝,那么就可以使用这种方案。

通俗点说,在项目中的使用场景也就是对后端接口返回的 json 数据需要深拷贝时,就可以使用这种方案。

(以上个人理解,有误的话,欢迎指点一下)

Object.assign 版

上面的深拷贝方案只是将一个对象完完整整拷贝一份出来,新对象数据和原对象数据都是一模一样的,算是副本。

但如果,需求是要类似 Object.assign 这种,将一个原对象完完整整拷贝到另一个已存在的目标对象上面呢?这种场景,拷贝后的新对象就跟原对象不是一样的了,而是两者的交集,冲突的拷贝的原对象为主。

这种场景上面的深拷贝方案就不适用了,这里参考 Object.assign 原理扩展实现 assignDeep,实现可将指定的原对象们,拷贝到已存在的目标对象上:

// 递归版
function assignDeep(target, ...sources) {
    // 1. 参数校验
    if (target == null) {
        throw new TypeError('Cannot convert undefined or null to object');
    }

    // 2. 如果是基本类型数据转为包装对象
    let result = Object(target);
    
    // 3. 缓存已拷贝过的对象,解决引用关系丢失问题
    if (!result['__hash__']) {
        result['__hash__'] = new WeakMap();
    }
    let hash  = result['__hash__'];

    sources.forEach(v => {
        // 4. 如果是基本类型数据转为对象类型
        let source = Object(v);
        // 5. 遍历原对象属性,基本类型则值拷贝,对象类型则递归遍历
        Reflect.ownKeys(source).forEach(key => {
            // 6. 跳过自有的不可枚举的属性
            if (!Object.getOwnPropertyDescriptor(source, key).enumerable) {
                return;
            }
            if (typeof source[key] === 'object' && source[key] !== null) {
                // 7. 属性的冲突处理和拷贝处理
                let isPropertyDone = false;
                if (!result[key] || !(typeof result[key] === 'object') 
                    || Array.isArray(result[key]) !== Array.isArray(source[key])) {
                    // 当 target 没有该属性,或者属性类型和 source 不一致时,直接整个覆盖
                    if (hash.get(source[key])) {
                        result[key] = hash.get(source[key]);
                        isPropertyDone = true;
                    } else {
                        result[key] = Array.isArray(source[key]) ? [] : {};
                        hash.set(source[key], result[key]);
                    }
                }
                if (!isPropertyDone) {
                    result[key]['__hash__'] = hash;
                    assignDeep(result[key], source[key]);
                }
            } else {
                Object.assign(result, {[key]: source[key]});
            }
        });
    });

    delete result['__hash__'];
    return result;
}

上面只给了递归版,存在栈溢出可能性,但基本没这种对象层次太深的场景,想了解其他实现如循环版以及详细内容的,可以去我另一篇文章查阅:扩展 Object.assign 实现深拷贝

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

推荐阅读更多精彩内容