深浅拷贝

Posted on May 25, 2020

什么是拷贝,什么是深浅拷贝

在 JavaScript 中,变量中保存的是对象的引用。比如像下面这样的操作

var a = 对象1;
var b = a;

a和b都在引用对象1,如果对对象1进行修改,则a和b两个变量都会受到影响,有时我们不希望出现这种情况,就要对对象进行拷贝操作来切换引用。比如

var a = 对象1;
var b = 拷贝(a);

那么如何实现拷贝呢?

我们知道对象是由多个key和value组成的,所以拷贝就是创建一个新的对象,将旧对象的key和value拷贝到新对象中,让新旧对象的key和value完全一样。比如像下面这样

Object.keys(对象1).forEach(key => {
  新对象[key] = 对象1[key];
})

这样的操作需要注意value的类型,分为基本类型和引用类型1,如果所有的value都是基本类型,但是如果value是引用类型就会出现新旧对象中都引用了一个对象的情况,也就是上文提到的a和b变量的问题。..如果想完全切断引用,就要对引用类型的value在进行一次拷贝..。对于是否需要完全切断引用就有了两种拷贝方式,需要完全切断引用的就是「深拷贝」,不需要的就是「浅拷贝」。

简单概括一下。

「浅拷贝」是只对对象的key和value进行一次遍历拷贝,不管内部的引用。

「深拷贝」要将对象内部的引用完全切断,对对象进行递归地拷贝。

如何实现深拷贝

先看一种实现方式

 1function clone(旧对象) {
 2  var 新对象 = {};
 3  Object.keys(旧对象).forEach(key => {
 4    var value = 旧对象[key];
 5    if(is基本类型(value)) {
 6      新对象[key] = 旧对象[key];
 7    } else {
 8      新对象[key] = clone(value); // 递归
 9    }
10  })
11  return 新对象;
12}

这种方法用的是递归,即当value不是基本类型时就对它进行拷贝。该方法有一个明显的问题,当出现循环引用时,就进入了一个死循环。

我们知道,函数的调用会形成一个「执行栈」,函数执行过程中如果又调用了函数就像该函数压入栈顶并执行,前一个函数就暂停了等待新函数执行完。

递归就是当前对象还没有拷贝完,就开始了下一次拷贝,就是在「执行栈」又压入了一次函数调用,而循环引用会导致不停滴往执行栈顶添加,对后执行栈超过限制大小,而中断。

为了避免爆栈问题,可以让一次拷贝进行完之后再开始下一次拷贝。就是把遇到的对象放在一个地方(数组1),当遇到对象时把它保存起来,完成一次拷贝后去检查看还有没有需要拷贝的对象。

这样虽然解决了爆栈,但是仍是一个死循环,因为会不停地向那个地方添加对象。所以需要一种方法来判断已经拷贝过的对象。

我们再创建一个数组2,用来保存拷贝过的对象,当遇到新的对象时,先去数组内查找,就不向数组1添加了,也就阻止了死循环。

下面的代码是对上面分析的过程的实现

// value 参数为原对象
function cloneLoop(oldObject) {

  const uniqueList = []; // 用来去重
  // 新的空对象
  const root = {};

  // 存储子对象的数组
  const children = [
    {
      parent: root,
      key: undefined,
      value: oldObject,
    }
  ];

  // 如果children内有子对象,则将其copy
  while(children.length) {
    // 深度优先,如果使用shift()则是广度有限。
    const node = children.pop();
    const parent = node.parent;
    const key = node.key;
    const childObject = node.childObject;

    // 当key是undefined时,parent是{},也就是刚开始
    // 当key不是undefined时,就是已经复制到子对象了,这时key肯定时有值的,
    // 需要把key对应的value初始化为空对象,后面的循环把子对象的 key 和 value 复制到空对象上。
    let res = parent;
    if (typeof key !== 'undefined') {
      res = parent[key] = {};
    }

    // 当循环引用时,childObject会与拷贝过的对象相等,就可以把它直接赋值到新对象上。完成一次拷贝。
    let uniqueData = find(uniqueList, childObject);
    if (uniqueData) {
      parent[key] = uniqueData.target;
      break; // 中断本次循环
    }

    // 数据不存在
    // 保存源数据,在拷贝数据中对应的引用
    uniqueList.push({
      source: childObject,
      target: res,
    });

    // 遍历子对象
    for(let k in childObject) {
      if (data.hasOwnProperty(k)) {
        // 如果value时对象,将子对象放入「子对象数组」中,待下一次while读取遍历
        if (typeof childObject[k] === 'object') {
          // 下一次循环
          children.push({
            parent: res,
            key: k,
            childObject: childObject[k],
          });
        } else {
          res[k] = childObject[k];
        }
      }
    }
  }
  return root;
}
function find(arr, item) {
  for(let i = 0; i < arr.length; i++) {
    if (arr[i].source === item) {
      return arr[i];
    }
  }
  return null;
}

总结一下要点:

  1. 使用一个数组存储copy过程中遇到的对象,以破解爆栈的问题。
  2. 使用另一个数组存储copy过的对象,以破解循环引用的问题。

对于拷贝一个对象的过程lodash使用的是「结构化克隆算法」2,普通对象直接复制key和value,数组等其他对象使用相应的复制方法。


  1. Nicholas C.Zakas;JavaScript高级程序设计(第3版);4.1 基本类型与引用类型的值 ↩︎

  2. MDN web docs;结构化克隆算法
    HTML Living Standard;结构化克隆算法中文翻译 ↩︎