浅谈js深拷贝

前言

最近在整理js相关的文档,回想起一个前端说难不难,说简单又能考验职业素养(基本功)扎实程度的问题:js之深浅拷贝的问题,相信前端的同窗都或少或多的了解及懂得其根由,在这里我就很少赘述,我们先简单的抛出几个问题:javascript

  • 如何理解深浅拷贝?
  • 怎么实现一个较为完善的深拷贝?

咱们先来聊下如何理解深浅拷贝。前端

值和引用

咱们知道,许多变成语言,赋值和参数传递能够经过值复制(value-copy)或者引用复制(reference-copy)来完成,这取决于语法的不一样。vue

JavaScript 中引用指向的是值。若是一个值有不止一个引用,这些引用都指向的是同一值,他们相互之间没有引用 / 指向关系。java

JavaScript 对值和引用的赋值 / 传递在语法上没法区分,彻底取决于值得类型来决定。数组

来看一下下面的列子:缓存

var a = 3;
var b = a; // b是a值的一个副本
b++;
a = 3;
b = 4;

var c = [0, 1, 2];
var d = c; // d是[0, 1, 2]的一个引用
d.push(3);
c; // [1, 2, 3, 4]
d; // [1, 2, 3, 4]

简单值(即标量基本类型)老是经过值复制的方式来赋值 / 传递, 包括 nullundefined字符串number布尔值和 ES6 中的 symbol 以及最新的 bigint 七种基本类型。数据结构

复合值(compound value)—— 对象(包括数组和封装对象)和函数,老是经过引用复制的方式来赋值 / 传递。函数

上例中 3 是一个标量基本数据类型,因此变量 a 持有该值的一个副本,b 持有它的另外一个副本。b 更改时,a 值保持不变。工具

c 和 d则分别指向同一个符合值 [0, 1, 2] 的两个不一样引用。请注意,c 和 d 仅仅是指向值 [0, 1, 2], 并不是持有。因此它们更改的是同一值(调用 push 方法),随后它们都指向了更改后的新值 [0, 1, 2, 3]。性能

因为引用指向的是值自己而非变量,因此一个引用没法更改另外一个引用的指向。

var a = [1, 2, 3];
var b = a;
a; // [1, 2, 3]
b; // [1, 2, 3]

b = [4, 5, 6];
a; // [1, 2, 3]
b; // [4, 5, 6]

b=[4, 5, 6] 并不影响 a 指向 [1, 2, 3],除非 b 指向的不是数组的引用,而是指向 a 的指针,这样 b 的赋值就会影响到 a ,可是 JavaScript 中不存在这种状况。

注意:JavaScript 中没有指针的概念,引用的工做机制也不尽相同,在 JavaScript 中变量不可能成为指向另外一个变量的引用。

<img :src="$withBase('/栈内存.jpg')" alt="foo">

咱们再看一个例子:

var m = [1, 2, 3];

function fn(n) {
    n.push(4);
    n; // [1, 2, 3, 4]
    
    n = [4, 5, 6];
    n.push(7);
    n; // [4, 5, 6, 7]
}
fn(m);
m; // 是[1, 2, 3, 4] 而不是 [4, 5, 6, 7]

在调用函数传递参数的时候,其实是将引用 m 的一个副本赋值给 n,因此在当调用 push(4) 操做的时候,由于都指向同一对象 [1, 2, 3, 4] ,可是当手动改变 n 的引用的时候,这时候并不影响 m,因此才会出现最终的 m 是[1, 2, 3, 4] 而不是 [4, 5, 6, 7]。

若是咱们不想要函数外的变量受到牵连,能够先建立一个复本,这样就不会影响原始值。例如:

fn(n.slice())

不带参数的slice() 方法会返回当前数组的一个浅复本,因为传递给函数的是指向该副本的引用,因此内部操做n 就再也不影响 m 。

目前为止,咱们大体理解了不一样类型的数据在复制的时候可能会形成相互的影响,因此实现一个深拷贝就显得颇有必要了。

接下来咱们将会逐步的展开介绍,最终实现一种较为完善的深拷贝。

序列化

没错,这也是最容易想到的一种,仅仅经过序列化和反序列化实现。

JSON.parse(JSON.stringify());

这种写法虽然能够应对大部分的应用场景,可是它仍是有很大缺陷的,好比拷贝拷贝函数、循环引用结构、其余类型的对象(Reg、Map)等状况。

使用JSON.stringify注意:

  1. undefined、任意的函数以及 symbol 值,在序列化过程当中会被忽略
  2. 对包含循环引用的对象(对象之间相互引用,造成无限循环)执行此方法,会抛出错误
  3. 其余类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性

因此使用这种方法仅适用数据格式简单的对象。

属性复制(浅复制)

若是是浅拷贝的话,相似于 jqery 里面的extend,很简单仅仅作的是遍历属性进行赋值:

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

若是是深拷贝的话,而且不知道嵌套对象的层级结构,咱们可使用递归来实现:

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

虽然这里基本实现了一个深拷贝的 demo,可是咱们应该会想到缺乏点什么。没错,就是Array ,其实考虑到Array 的状况也很简单,咱们稍微该一下。

数组和对象字面量

其实思路很简单,就是在咱们每次遍历建立新对象的时候对数组进行兼容就 ok 了:

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

目前为止,咱们基本上实现一个深拷贝的例子。可是,就像咱们会考虑到数组的状况,还有一种状况不常见,但却不能忽视的一个问题: 循环引用(circularReference)。

循环引用

咱们执行下面这样一个测试用例:

const target = {
    refer: 'circularReference'
};
target.refer = target;

咱们在控制台上输出一下:

<img :src="$withBase('/circleReference.jpg')" alt="foo">

能够看到一个无限展开的结构(即对象的属性间接或直接的引用了自身的状况)。

首先来分析一下循环引用结构:若是咱们不对存在循环引用的结构作处理的话,每次递归都会指向自身对象,这样下去就会形成内存泄漏的问题。解决这个问题咱们就从根本点出发,针对于循环结构咱们能够再每次循环的时候找一个chche 存储当前对象,下次拷贝的时候就能够去cache 中查找有无当前对象,有就返回,没有就继续遍历拷贝,咱们先来实现一下这个:

function deepClone(target, cache = []) {
      if (target === null || typeof target !== 'object') {
       return target
    }
    
    let circleStructure = cache.filter(e => e.original === target)[0]
    if (circleStructure) {
        return circleStructure.copy
    }
    
    let copy = Array.isArray(target) ? [] : {}
    cache.push({
        original: target,
        copy
    })
    
    Object.keys(target).forEach(key => {
        copy[key] = deepClone(target[key], cache)
    })
    
    return copy
}

该方法缺陷:

  1. 只能克隆 {} 格式的对象,对于拥有有原型链的对象却无能为力
  2. 不能克隆其它类型的对象(可迭代的集合、RegExpSymbolDateTypedArray)等等

针对目前的缺陷咱们寻找解决方案:

  • 既然针对 {} 类型对象不能拷贝原型链,咱们能够拷贝它的原型对象而且扩展其熟悉
  • 针对于可迭代的集合(Map、Set)由于Object.keys()没法对其进行遍厉,那咱们可使用它们自身的构造器
  • 针对其它类型的对象咱们同要可使用它们各自的构造器进行拷贝

要区分对象类型,咱们首先要找到一个能够严格判断对象类型的方法。以前由于看vue源码的时候看到一个严格判断对象类型的方法,经过Object.toString方法能够返回对象的具体类型:

function getPlainObjType(obj) {
    return Object.prototype.toString.call(obj)
}

相信不少小伙伴在阅读其余组件库的时候该方法能够随处可见。其次咱们想想(先把function 排除在外)针对于集合类型,咱们可使用键-值对的map进行存储,可是若是使用对象做为映射的键,这个对象即使后来全部的引用被解除了,某一时刻(GC)开始回收其内存,那map自己仍然会保持其项目(值对象),除非手动的移除项目(clear)来支持 GC

这个时候WeakMap的做用便显现出来,其实它们两者外部的行为特性基本同样,区别就体如今了内存分配的工做方式。

WeakMap接受对象做为键,而且这些对象是被弱持有的,也就是说若是键对象自己被垃圾回收的话,那么WeakMap中的这个项目也会被自动移除,这也是为何WeakMap在这方面会优于Map。咱们看个例子:

var wm = new WeakMap();

var x = {id: 1},
    y = {id: 2},
    z = {id: 3},
    w = {id: 4};

wm.set(x, y);

x = null;       // {id: 1} 可被垃圾回收
y = null;       // {id: 2} 可被垃圾回收, 实际上 x = null; weakMap里面的项目也就被回收了

wm.set(z, w);

y = null;         // {id: 4} 并未被回收,由于键还软关联着 {id: 4} 这个对象

接下来完善一下上面的deepClone方法,lodash上实现了比较全面的深拷贝,咱们能够借鉴一下lodash的思路,实现一个简化版的:

  • 声明克隆须要的几种工具函数
  • cache 替换为WeakMap
  • 若是判断是基础类型的数据,直接返回
  • 声明deepInit
  • 若是是map或者是set使用它们自身的添加方法拷贝
  • 若是是数组或者是{}使用Object.keys遍历拷贝属性
  • 若是是包装类型对象或者是Date、RegExp、Symbol类型的对象使用它们的构造器进行拷贝
var boolTag = '[object Boolean]',
    dateTag = '[object Date]',
    errTag = '[object Error]',
    mapTag = '[object Map]',
    arrTag = '[object Array]',
    objTag = '[object Object]',
    numberTag = '[object Number]',
    regexpTag = '[object RegExp]',
    setTag = '[object Set]',
    stringTag = '[object String]',
    argsTag = '[object Arguments]',
    symbolTag = '[object Symbol]';


function getPlainObjType(obj) {
    return Object.prototype.toString.call(obj)
}

// 判断对象类型
function isObject(obj) {
    var type = typeof obj;
    return obj != null && (type == 'object' || type == 'function');
}

// 其它(内置)引用类型对象
function isReferObj(type) {
    return ~([dateTag, errTag, regexpTag, symbolTag].indexOf(type))
}

function isSet(type) {
    return type === '[object Set]'
}

function isMap(type) {
    return type === '[object Map]'
}

// 返回传入对象构造器,这样就能够拷贝原型链属性
function deepInit(obj) {
    const Ctor = obj.constructor;
    return new Ctor();
}

function cloneObjByTag(object, tag) {
    var Ctor = object.constructor;
    switch (tag) {
      case dateTag:
        return new Ctor(+object);

      case errTag:
        return new Ctor(object);

      case regexpTag:
        return cloneRegExp(object);

      case symbolTag:
        return cloneSymbol(object);
  }
}

function cloneRegExp(object) {
    let reFlags = /\w*$/;
    let result = new object.constructor(object.source, reFlags.exec(object));
    result.lastIndex = object.lastIndex;
    return result;
}

function cloneSymbol(object) {
    return Object(Symbol.prototype.valueOf.call(object));
}

function deepClone(target, wm = new WeakMap()) {
    if (!isObject(target)) {
        return target;
    }
    
    let type = getPlainObjType(target);
    let copy = deepInit(target);
    
    // 判断是否存在循环引用结构
    let hit = wm.get(target);
    if (hit) {
        return hit;
    }
    wm.set(target, copy);
    
    if(isReferObj(type)) {
        copy = cloneObjByTag(target, type);
        return copy;
    }
    
    if (isSet(type)) {
        target.forEach(value => {
            copy.add(deepClone(value));
        });
        return copy;
    }
    
    if (isMap(type)) {
        target.forEach((value, key) => {
            copy.set(key, deepClone(value));
        });
        return copy;
    }
    
    Object.keys(target).forEach(key => {
        copy[key] = deepClone(target[key], wm)
    });
    
    return copy;
}
注意: 由于拷贝对象属性的时候使用的是 Object.keys 暂且先不考虑 typedArray 类型对象和 Function,咱们将列举出来的对象类型分为可遍历对象和不遍历代对象(内置对象),使用不一样的遍历方法进行属性复制, MapSet类型可使用其自带的 forEach遍历,对象、数组使用 Object.keys进行遍历,其它内置的引用类型对象直接使用其构造器从新生成新对象。

其实写到这里至关于完成了一大部分,lodash 作了不少细节上面的优化工做,好比针对于对象层级很是多的时候特地对遍历这块作了些手脚:

function arrayEach(array, iteratee) {
  var index = -1,
      length = array == null ? 0 : array.length;

  while (++index < length) {
    if (iteratee(array[index], index, array) === false) {
      break;
    }
  }
  return array;
}
比较 forfor..in while循环,因为 for..in 会遍历整个对象上包括(原型链)的除 Symbol之外的可枚举属性,因此会慢些。可是网上诸多帖子的测试结果发现 forwhile相差很少,总的单纯从执行时间长短来说 while 更快一些。

还有一些使用迭代器遍历的方法,例如:forEacheverysome 它们的惟一区别在于对回调函数返回值的处理方式不一样:forEach 会遍历数组中的全部之并忽略回调函数的返回值。every 会一直运行直到 callback 返回 falsysome 会一直运行知道回调函数返回 truthy 。上例即是模仿 every 行为来进行遍历。

还有一些遍历是访问对象属性时用到的:Object.keysObject.getOwnPropertyNames。这些和 in 的区别:Object.keys 只会遍厉对象直接包含的可枚举属性,Object.getOwnPropertyNames 会遍历对象直接包含的属性(不论它们是否可枚举)。 in 操做符会查找对象(包括原型链)属性是否存在(不管是否能够遍历),for..in 会遍历整个对象上包括(原型链)的除Symbol之外的可枚举属性。

此外,ES6新增的 for..of 能够对数组的值进行遍历(若是对象自身定义了迭代器也能够进行遍历)。

能够很清楚的看到使用while是重写了forEach,这里的 array 须要值得注意:

  • 若是遍历的是对象那么keyvalue须要对调,由于对象的 key 是数组的值而非下标
  • 自定义迭代回调函数的时候能够根据不一样逻辑设置返回值来中断遍历

咱们能够改一下deepClone 中的遍历的逻辑:

let keys = Array.isArray(target) ? undefined : Object.keys(target);

// Object.keys(target).forEach(key => {
//     copy[key] = deepClone(target[key], wm);
// });

arrayEach(keys || target, (value, key) => {
    if (keys) {
        key = value
    }
    copy[key] = deepClone(target[key], wm);
})

目前来说Function类型和二进制数组的typedArray还未实现深拷贝没不过目前来说,平常开发使用最多的也仍是序列化和饭序列化版本。并且我也不常用这个封装的深拷贝,写这么些东西只是出于学习和扩展思路用的,真正用的话 lodash 的彻底够用了,后续还会去继续研究 functionarrayBuffer的拷贝。

因为深拷贝须要考虑的edge case太多,相信你们也会有不少探讨,写一个深拷贝不容易。具体孰优孰劣也须要跟业务想结合一下。

参考

小结

  1. 针对于深浅拷贝咱们先引出了值和引用,简单值(即标量基本类型)老是经过值复制的方式来赋值 / 传递复合值(compound value)—— 对象(包括数组和封装对象)和函数,老是经过引用复制的方式来赋值 / 传递,而且引用之间并不相互影响,从而点出深拷贝的相关思路。
  2. 首先使用最普遍的序列化进行拷贝,但限于序列化对function、集合、包装对象、引用对象自动忽略,由此引出了递归拷贝。
  3. 考虑到循环引用问题引出利用缓存避免拷贝陷入死循环。
  4. 因为未考虑到原型链的属性,引出了利用构造器来拷贝对象,进而引出了更多数据类型的深拷贝。针对于集合形式的对象,咱们引用了对内存分配支持更好的WeakMap来做为缓存对象。从而引出了像symbol正则、及其余引用类型的对象的拷贝问题。
  5. 最后分析了lodash对于拷贝时遍历的性能的优化,给了咱们一个在遍历数据量很大时一个思路。
相关文章
相关标签/搜索