提到深浅拷贝就会涉及到两种数据类型,分别是基础数据类型和引用数据类型,在 JavaScript 的编程中经常需要对数据进行复制;

Tasking

  • 什么时候用深拷贝?
  • 什么时候用浅拷贝?
  • 拷贝一个很多嵌套的对象怎么实现?
  • 写成什么样的深拷贝代码才能算合格?

浅拷贝

浅拷贝的原理

对于浅拷贝的定义我们可以初步理解为: 自己创建一个新的对象,来接受你要重新复制或引用的对象值。如果对象属性是基本的数据类型,复制的就是基本类型的值给新对象;但如果属性是引用数据类型,复制的就是内存中的地址,如果其中一个对象改变了这个内存中的地址,肯定会影响到另一个对象。

常用的拷贝方法

object.assign

object.assign 是 ES6 中 object 的一个方法,该方法可以用于 JS 对象的合并等多个用途,其中一个用途就是可以进行浅拷贝。该方法的第一个参数是拷贝的目标对象,后面的参数是拷贝的来源对象(也可以是多个来源)。 object.assign 的语法为:Object.assign(target, ...sources)

  1. let target = {version: 'es6+', code: 'async await'};
  2. let source = {support: true};
  3. let newObj = Object.assign(obj, status);
  4. console.log('newObj:', newObj); // {version: "es6+", code: "async await", support: true}

从上面的代码中可以看到,通过 object.assign 我们的确简单实现了一个浅拷贝,“newObj”就是我们新拷贝的对象,

  1. let target = {};
  2. let source = { a: { b: 2 } };
  3. Object.assign(target, source);
  4. console.log(target); // { a: { b: 10 } };
  5. source.a.b = 10;
  6. console.log(source); // { a: { b: 10 } };
  7. console.log(target); // { a: { b: 10 } };

从上面代码中我们可以看到,首先通过 Object.assign 将 source 拷贝到 target 对象中,然后我们尝试将 source 对象中的 b 属性由 2 修改为 10。通过打印控制台可以发现,打印结果中,三个 target 里的 b 属性都变为 10 了,证明 Object.assign 暂时实现了我们想要的拷贝效果(浅拷贝)。

但是使用 object.assign 方法有几点需要注意:
  • 它不会拷贝对象的继承属性;
  • 它不会拷贝对象的不可枚举的属性;
  • 可以拷贝 Symbol 类型的属性。
  • 可以简单理解为:Object.assign 循环遍历原对象的属性,通过复制的方式将其赋值给目标对象的相应属性,来看一下这段代码,以验证它可以拷贝 Symbol 类型的对象。
  1. let obj1 = { a:{ b:1 }, sym:Symbol(1)};
  2. // Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
  3. Object.defineProperty(obj1, 'innumerable' ,{
  4. value:'不可枚举属性',
  5. enumerable:false
  6. });
  7. let obj2 = {};
  8. Object.assign(obj2,obj1)
  9. obj1.a.b = 2;
  10. console.log('obj1',obj1); // {a: { b: 1 }, sym: Symbol(1), innumerable: "不可枚举属性"}
  11. console.log('obj2',obj2); // {a: { b: 2 }, sym: Symbol(1)} 并未将innumerable属性拷贝到obj2中

利用 object.assign 也可以拷贝 Symbol 类型的对象,但是如果到了对象的第二层属性 obj1.a.b 这里的时候,前者值的改变也会影响后者的第二层属性的值,说明其中依旧存在着访问共同堆内存的问题,也就是说这种方法还不能进一步复制,而只是完成了浅拷贝的功能。

扩展运算符方式

我们也可以利用 JS 的扩展运算符,在构造对象的同时完成浅拷贝的功能。 扩展运算符的语法为:> let cloneObj = { ...obj }; ```javascript let obj = { a: 1, b: { c: 1 } } let obj2 = { …obj } obj.a = 2 console.log(obj) // { a: 2, b: { c: 1 } } console.log(obj2); // { a: 1, b: { c: 1 } } obj.b.c = 2 console.log(obj) // { a: 2, b: { c: 2 } } console.log(obj2); //{a:1,b:{c:2}}

let arr = [1, 2, 3]; let newArr = […arr]; //跟arr.slice()是一样的效果

  1. > d扩展运算符 object.assign 有同样的缺陷,也就是实现的浅拷贝的功能差不多,但是如果属性都是基本类型的值,使用扩展运算符进行浅拷贝会更加方便。
  2. <a name="JevfD"></a>
  3. #### concat 拷贝数组
  4. > 数组的 concat 方法其实也是浅拷贝,所以连接一个含有引用类型的数组时,需要注意修改原数组中的元素的属性,因为它会影响拷贝之后连接的数组。不过 concat 只能用于数组的浅拷贝,使用场景比较局限。代码如下所示。
  5. ```javascript
  6. let arr = [1, 2, 3];
  7. let newArr = arr.concat();
  8. newArr[1] = 100;
  9. console.log(arr); // [ 1, 2, 3 ]
  10. console.log(newArr); // [ 1, 100, 3 ]

slice 拷贝数组

slice 方法也比较有局限性,因为它仅仅针对数组类型。slice 方法会返回一个新的数组对象,这一对象由该方法的前两个参数来决定原数组截取的开始和结束时间,是不会影响和改变原始数组的。 slice 的语法为:arr.slice(begin, end);

  1. let arr = [ 1, 2, { val: 4 } ];
  2. let newArr = arr.slice();
  3. newArr[2].val = 1000;
  4. console.log(arr); // [ 1, 2, { val: 1000 } ]

从上面的代码中可以看出,这就是浅拷贝的限制所在了——它只能拷贝一层对象。如果存在对象的嵌套,那么浅拷贝将无能为力。因此深拷贝就是为了解决这个问题而生的,它能解决多层对象嵌套问题,彻底实现拷贝。

手工实现一个浅拷贝

Tasking & 思路:

  • 对基础类型做一个最基本的一个拷贝;
  • 对引用类型开辟一个新的存储,并且拷贝一层对象属性。
  1. const shallowClone = (target) => {
  2. if (typeof target === 'object' && target !== null) {
  3. const cloneTarget = Array.isArray(target) ? []: {};
  4. for (let prop in target) {
  5. // hasOwnProperty() 方法会返回一个布尔值,指示对象自身属性中是否具有指定的属性
  6. if (target.hasOwnProperty(prop)) {
  7. cloneTarget[prop] = target[prop];
  8. }
  9. }
  10. return cloneTarget;
  11. } else {
  12. return target;
  13. }
  14. }

从上面这段代码可以看出,利用类型判断,针对引用类型的对象进行 for 循环遍历对象属性赋值给目标对象的属性,基本就可以手工实现一个浅拷贝的代码了。

深拷贝

这两个对象是相互独立、不受影响的,彻底实现了内存上的分离

深拷贝的原理

将一个对象从内存中完整地拷贝出来一份给目标对象,并从堆内存中开辟一个全新的空间存放新对象,且新对象的修改并不会改变原对象,二者实现真正的分离。

常用的深拷贝方法

JSON.stringfy

JSON.stringfy() 是目前开发过程中最简单的深拷贝方法,其实就是把一个对象序列化成为 JSON 的字符串,并将对象里面的内容转换成字符串,最后再用 JSON.parse() 的方法将JSON 字符串生成一个新的对象

  1. let obj1 = { a: 1, b: [1, 2, 3] }
  2. let str = JSON.stringify(obj1);
  3. let obj2 = JSON.parse(str);
  4. console.log(obj2); //{a:1,b:[1,2,3]}
  5. obj1.a = 2;
  6. obj1.b.push(4);
  7. console.log(obj1); //{a:2,b:[1,2,3,4]}
  8. console.log(obj2); //{a:1,b:[1,2,3]}

从上面的代码可以看到,通过 JSON.stringfy 可以初步实现一个对象的深拷贝,通过改变 obj1 的 b 属性,其实可以看出 obj2 这个对象也不受影响。

但是使用 JSON.stringfy 实现深拷贝还是有一些地方值得注意,我总结下来主要有这几点:

  • 拷贝的对象的值中如果有函数、undefined、symbol 这几种类型,经过 JSON.stringify 序列化之后的字符串中这个键值对会消失;
  • 拷贝 Date 引用类型会变成字符串;
  • 无法拷贝不可枚举的属性;
  • 无法拷贝对象的原型链;
  • 拷贝 RegExp 引用类型会变成空对象;
  • 对象中含有 NaN、Infinity 以及 -Infinity,JSON 序列化的结果会变成 null;
  • 无法拷贝对象的循环应用,即对象成环 (obj[key] = obj)。
  1. function Obj() {
  2. this.func = function () { alert(1) };
  3. this.obj = {a:1};
  4. this.arr = [1,2,3];
  5. this.und = undefined;
  6. this.reg = /123/;
  7. this.date = new Date(0);
  8. this.NaN = NaN;
  9. this.infinity = Infinity;
  10. this.sym = Symbol(1);
  11. }
  12. let obj1 = new Obj();
  13. Object.defineProperty(obj1,'innumerable',{
  14. enumerable:false,
  15. value:'innumerable'
  16. });
  17. console.log('obj1',obj1);
  18. let str = JSON.stringify(obj1);
  19. let obj2 = JSON.parse(str);
  20. console.log('obj2',obj2);

通过上面这段代码可以看到执行结果如下图所示。
image.png
使用 JSON.stringify 方法实现深拷贝对象,虽然到目前为止还有很多无法实现的功能,但是这种方法足以满足日常的开发需求,并且是最简单和快捷的。而对于其他的也要实现深拷贝的,比较麻烦的属性对应的数据类型,JSON.stringify 暂时还是无法满足的,那么就需要下面的几种方法了。

手写递归实现

Tasking & 实现思路

简易版深拷贝实现思路: ①、遍历key、value

②、判断是否是是否是对象,是对象则递归调用该方法;反之直接赋值

  1. function deepClone(obj) {
  2. let cloneObj = {}
  3. for (let key in obj) {
  4. cloneObj[key] = typeof obj[key] === 'object' ? deepClone(obj[key]) : cloneObj[key] = obj[key]
  5. }
  6. return cloneObj
  7. }

虽然利用递归能实现一个深拷贝,但是同上面的 JSON.stringfy 一样,还是有一些问题没有完全解决,例如:

  • 这个深拷贝函数并不能复制不可枚举的属性以及 Symbol 类型;这种方法只是针对普通的引用类型的值做递归复制,而对于 Array、Date、RegExp、Error、Function 这样的引用类型并不能正确地拷贝;
  • 对象的属性里面成环,即循环引用没有解决。这种基础版本的写法也比较简单,可以应对大部分的应用情况。但是你在面试的过程中,如果只能写出这样的一个有缺陷的深拷贝方法,有可能不会通过。

改进后递归实现

针对上面几个待解决问题,我先通过四点相关的理论告诉你分别应该怎么做。

  • 针对能够遍历对象的不可枚举属性以及 Symbol 类型,我们可以使用 Reflect.ownKeys 方法;
  • 当参数为 Date、RegExp 类型,则直接生成一个新的实例返回;
  • 利用 Object 的 getOwnPropertyDescriptors 方法可以获得对象的所有属性,以及对应的特性,顺便结合 Object 的 create 方法创建一个新对象,并继承传入原对象的原型链;
  • 利用 WeakMap 类型作为 Hash 表,因为 WeakMap 是弱引用类型,可以有效防止内存泄漏(你可以关注一下 Map 和 weakMap 的关键区别,这里要用 weakMap),作为检测循环引用很有帮助,如果存在循环,则引用直接返回 WeakMap 存储的值。 WeakMap MDN & es6 阮一峰
  1. const isComplexDataType = obj => (typeof obj === 'object' || typeof obj === 'function') && (obj !== null)
  2. const deepClone = function (obj, hash = new WeakMap()) {
  3. if (obj.constructor === Date) return new Date(obj) // 日期对象直接返回一个新的日期对象
  4. if (obj.constructor === RegExp) return new RegExp(obj) //正则对象直接返回一个新的正则对象
  5. //如果循环引用了就用 weakMap 来解决
  6. if (hash.has(obj)) return hash.get(obj)
  7. /*
  8. * Object.getOwnPropertyDescriptors() 方法用来获取一个对象的所有自身属性的描述符。
  9. * 所指定对象的所有自身属性的描述符,如果没有任何自身属性,则返回空对象。
  10. */
  11. let allDesc = Object.getOwnPropertyDescriptors(obj)
  12. //遍历传入参数所有键的特性
  13. let cloneObj = Object.create(Object.getPrototypeOf(obj), allDesc)
  14. //继承原型链
  15. hash.set(obj, cloneObj)
  16. for (let key of Reflect.ownKeys(obj)) {
  17. cloneObj[key] = (isComplexDataType(obj[key]) && typeof obj[key] !== 'function') ? deepClone(obj[key], hash) : obj[key]
  18. }
  19. return cloneObj
  20. }
  21. // 下面是验证代码
  22. let obj = {
  23. num: 0,
  24. str: '',
  25. boolean: true,
  26. unf: undefined,
  27. nul: null,
  28. obj: { name: '我是一个对象', id: 1 },
  29. arr: [0, 1, 2],
  30. func: function () { console.log('我是一个函数') },
  31. date: new Date(0),
  32. reg: new RegExp('/我是一个正则/ig'),
  33. [Symbol('1')]: 1,
  34. };
  35. Object.defineProperty(obj, 'innumerable', {
  36. enumerable: false, value: '不可枚举属性'
  37. }
  38. );
  39. obj = Object.create(obj, Object.getOwnPropertyDescriptors(obj))
  40. obj.loop = obj // 设置loop成循环引用的属性
  41. let cloneObj = deepClone(obj)
  42. cloneObj.arr.push(4)
  43. console.log('obj', obj)
  44. console.log('cloneObj', cloneObj)

我们看一下结果,cloneObj 在 obj 的基础上进行了一次深拷贝,cloneObj 里的 arr 数组进行了修改,并未影响到 obj.arr 的变化,如下图所示。
image.png