深入剖析 JavaScript 的深浅拷贝

avatar
前端开发工程师 @bigo

file

本文首发于:github.com/bigo-fronte… 欢迎关注、转载。

前言

  • 放之四海皆准的方法是不存在的,不同的深浅拷贝实现方法和实现粒度有各自的优劣以及各自适合的应用场景。
  • 本文会从实现原理进行分析,并将在 JavaScript 中实现深浅拷贝所需要考虑的问题呈现给大家。让大家对深浅拷贝有个更深刻的认识,以便大家可以更好的选择适合自己的拷贝方法。

什么是浅拷贝/深拷贝

  • 浅拷贝:

image.png

创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。

  • 深拷贝:

image.png

将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象

如何实现浅拷贝

方法一 Object.assign

// Object.assign() 实现 shallowClone
let student = {
  name: "小明",
  score: {
    english: 88,
    chinese: 77,
    math: 99,
  },
};

let shallowStudent = Object.assign({}, student);
shallowStudent.name = "李雷";
shallowStudent.score.english = 98; // score 是 object, 共用内存地址, 都会被改成98

console.log("shallowStudent: ", shallowStudent);
console.log("student: ", student);
  • 缺陷:没能处理数组,不够通用

浅拷贝更为通用的做法:遍历赋值

var simpleClone = function (target) {
  if (typeof target === "object") {
    let cloneTarget = Array.isArray(target) ? [] : {};
    for (const key in target) {
      cloneTarget[key] = target[key];
    }
    return cloneTarget;
  } else {
    return target;
  }
};

如何实现 深拷贝

方法一 JSON.parse(JSON.stringify())

let student = {
  name: "小明",
  score: {
    english: 88,
    chinese: 77,
    math: 99,
  },
};

let deepStudent = JSON.parse(JSON.stringify(student));
deepStudent.name = "李雷";
deepStudent.score.english = 98;

console.log("deepStudent: ", deepStudent);
console.log("student: ", student);

存在问题:JSON.stringify 对于拷贝其他引用类型、拷贝函数、循环引用等情况无法很好处理,只能运用于简单 JSON。

深拷贝更为通用的做法:递归遍历赋值

  • 如果是深拷贝的话,考虑到我们要拷贝的对象是不知道有多少层深度的,我们可以用递归来解决问题:

    • 如果是原始类型,无需继续拷贝,直接返回
    • 如果是引用类型,创建一个新的对象,遍历需要克隆的对象,将需要克隆对象的属性执行深拷贝后依次添加到新对象上。
var deepClone = function (target) {
  if (typeof target === "object") {
    let cloneTarget = Array.isArray(target) ? [] : {};
    for (const key in target) {
      cloneTarget[key] = deepClone(target[key]);
    }
    return cloneTarget;
  } else {
    return target;
  }
};

考虑循环引用

  • 考虑以下 case:
const target = {
  field1: 1,
  field2: undefined,
  field3: {
    child: "child",
  },
  field4: [2, 4, 8],
};
target.target = target;
// 这个case如果还用以上递归代码的话,会导致死循环、栈内存溢出。
  • 解决循环引用问题,我们可以额外用一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就可以解决的循环引用的问题。
  • 这个存储空间,需要可以存储 key-value 形式的数据,且 key 可以是一个引用类型,我们可以选择 Map 这种数据结构:检查 map 中有无克隆过的对象,如果有则直接返回,如果没有则将当前对象作为 key,克隆对象作为 value 进行存储,继续克隆
var deepClone = function (target, map = new Map()) {
  if (typeof target === "object") {
    let cloneTarget = Array.isArray(target) ? [] : {};
    if (map.get(target)) {
      return map.get(target);
    }
    map.set(target, cloneTarget);
    for (const key in target) {
      cloneTarget[key] = deepClone(target[key], map);
    }
    return cloneTarget;
  } else {
    return target;
  }
};

WeakMap 优化

  • 可以使用 WeakMap 进一步优化。WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。
  • 我们默认创建一个对象:const obj = {},就默认创建了一个强引用的对象,我们只有手动将 obj = null,它才会被垃圾回收机制进行回收,如果是弱引用对象,垃圾回收机制会自动帮我们回收。
let obj = { name: "ConardLi" };
const target = new Map();
target.set(obj, "code秘密花园");
obj = null;
console.log(target); // Map(1) { { name: 'ConardLi' } => 'code秘密花园' } 即时obj释放了,Map还存留
let obj = { name: "ConardLi" };
const target = new WeakMap();
target.set(obj, "code秘密花园");
obj = null;
console.log(target); // WeakMap { <items unknown> } 不再存留obj

考虑其他数据类型

  • 上面的代码只考虑了普通的 object 和 array 两种数据类型,实际上所有的引用类型远远不止这两个。下面的代码,在上述深拷贝的基础上,通过类型判断与处理,解决了绝大部分其他类型的拷贝问题。
const mapTag = "[object Map]";
const setTag = "[object Set]";
const arrayTag = "[object Array]";
const objectTag = "[object Object]";
const argsTag = "[object Arguments]";

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

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

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

function isObject(target) {
  const type = typeof target;
  return target !== null && (type === "object" || type === "function");
}

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

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

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

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

// 处理 不可继续遍历的类型
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);
    case funcTag:
      return cloneFunction(targe);
    default:
      return null;
  }
}

function clone(target, map = new WeakMap()) {
  // 克隆原始类型
  if (!isObject(target)) {
    return target;
  }

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

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

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

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

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

  return cloneTarget;
}

module.exports = {
  clone,
};

第三方库的实现

Underscore —— _.clone()

  • 在 Underscore 中有这样一个方法:_.clone(),这个方法实际上是一种浅复制 (shallow-copy),所有嵌套的对象和数组都是直接复制引用而并没有进行深复制。
// 源码地址 https://github.com/jashkenas/underscore/blob/master/modules/clone.js
export default function clone(obj) {
  if (!isObject(obj)) return obj;
  return isArray(obj) ? obj.slice() : extend({}, obj);
}

jQuery —— .clone()/.clone() / .extend()

  • 在 jQuery 中也有这么一个叫 .clone()的方法,可是它并不是用于一般的JS对象的深复制,而是用于DOM对象。与Underscore类似,我们也是可以通过.clone() 的方法,可是它并不是用于一般的 JS 对象的深复制,而是用于 DOM 对象。与 Underscore 类似,我们也是可以通过 .extend() 方法来完成深复制。
  • 源码

lodash —— .clone() /.cloneDeep()

  • 在 lodash 中关于复制的方法有两个,分别是*.clone()和*.cloneDeep()。其中*.clone(obj, true)等价于*.cloneDeep(obj)。使用上,lodash 和前两者并没有太大的区别,但看了源码会发现,Underscore 的实现只有 30 行左右,而 jQuery 也不过 60 多行。可 lodash 中与深复制相关的代码却有上百行,为什么呢?

  • 因为 jQuery 无法正确深复制 JSON 对象以外的对象,而 lodash 花了大量的代码来实现 ES6 引入的大量新的标准对象。而且,lodash 针对存在环的对象的处理也是非常出色的。因此相较而言,lodash 在深复制上的行为反馈比前两个库好很多,是更拥抱未来的一个第三方库。

  • 源码

总结

  • 本文由浅入深地介绍了深浅拷贝的定义,实现深拷贝中需要考虑的问题与要点,一步步地完善了一个深拷贝,最后对比了一下市面上的常用库的深拷贝实现。
  • 希望看完本篇文章能让大家对深浅拷贝有个更深刻的认识。能自行实现一个基本的深拷贝函数。

欢迎大家留言讨论,祝工作顺利、生活愉快!

我是bigo前端,下期见。