手写JS深浅拷贝

117 阅读10分钟

手写JS深浅拷贝

1.浅拷贝:

自己创建一个新的对象,来接受你要重新复制或引用的对象值。如果对象属性是基本的数据类型,复制的就是基本类型的值给新对象;但如果属性是引用数据类型,复制的就是内存中的地址,如果其中一个对象改变了这个内存中的地址,肯定会影响到另一个对象。

1.1 Object.assign()

let target = {};
let source = { a: { b: 2 } };
Object.assign(target, source);
console.log(target); // { a: { b: 10 } }; 
source.a.b = 10; 
console.log(source); // { a: { b: 10 } }; 
console.log(target); // { a: { b: 10 } };

使用 object.assign 方法有几点需要注意:

  • 它不会拷贝对象的继承属性;
  • 它不会拷贝对象的不可枚举的属性;
  • 可以拷贝 Symbol 类型的属性。
const source = { a: { b: 1 }, sym: Symbol(1) }
Object.defineProperty(source, 'innumerable', {
  value: '不可枚举属性',
  enumerable: false
})
const target = {}
Object.assign(target, source)
console.log(source)
target.a.b = 2
console.log(source, target)

image-20230901090758400.png

1.2 扩展运算符方式

利用 JS 的扩展运算符,在构造对象的同时完成浅拷贝的功能

const source = { a: 1, b: 2 }
const target = { ...source }
console.log(target) // { a: 1, b: 2 }
const arr = [1, 2, 3, 4, 5, 6]
const copyArr = [...arr]
console.log(copyArr) // [1, 2, 3, 4, 5, 6]

1.3 concat 拷贝数组

concat 只能用于数组的浅拷贝,使用场景比较局限

let arrSource = [1, 2, 3, 4, 5, 6]
const copyArr = arrSource.concat()
console.log(arrSource, copyArr) // [1, 2, 3, 4, 5, 6]

1.4 slice 拷贝数组

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

slice 的语法为:arr.slice(begin, end);

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

1.5 手工实现一个浅拷贝

利用类型判断,针对引用类型的对象进行 for 循环遍历对象属性赋值给目标对象的属性

    const shallowclone = (target) => {
      if (typeof target === 'object' && target !== null) {
        const cloneTarget = Array.isArray(target) ? [] : {}
        for (const prop in target) {
          if (target.hasOwnProperty(prop)) {
            cloneTarget[prop] = target[prop]
          }
        }
        return cloneTarget
      } else {
        return target
      }
    }
// --------------------验证-----------------------------------
const arr = [1, 2, 3, 4, 5, 6]
console.log(shallowclone(arr)) // [1, 2, 3, 4, 5, 6]
const source = { a: { b: 1 }, sym: Symbol(1) }
Object.defineProperty(source, 'innumerable', {
	value: '不可枚举属性',
      enumerable: false
    })
console.log(shallowclone(source)) // { a: { b: 1 }, sym: Symbol(1) }

2. 深拷贝的原理和实现

浅拷贝只是创建了一个新的对象,复制了原有对象的基本类型的值,而引用数据类型只拷贝了一层属性,再深层的还是无法进行拷贝。深拷贝则不同,对于复杂引用数据类型,其在堆内存中完全开辟了一块内存地址,并将原有的对象完全复制过来存放。

这两个对象是相互独立、不受影响的,彻底实现了内存上的分离。总的来说,深拷贝的原理可以总结如下:

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

2.1 乞丐版(JSON.stringify)

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

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

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

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

2.2 基础版(手写递归实现)

function deepClone(target) {
  let cloneTarget = Array.isArray(target) ? [] : {}
  for (const prop in target) {
    if (typeof target[prop] === 'object') {
      cloneTarget[prop] = deepClone(target[prop])
    } else {
      cloneTarget[prop] = target[prop]
    }
  }
  return cloneTarget
}
//---------------------------------------------
let obj1 = { a: { b: 1 } }
let obj2 = deepClone(obj1);
obj1.a.b = 2;
console.log(obj1, obj2);   //  {a:{b:1}}
const arr = [1, 2, 3, 4, 5, 6]
console.log(deepClone(arr)) // [1, 2, 3, 4, 5, 6]

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

  1. 这个深拷贝函数并不能复制不可枚举的属性以及 Symbol 类型;
  2. 这种方法只是针对普通的引用类型的值做递归复制,而对于 Array、Date、RegExp、Error、Function 这样的引用类型并不能正确地拷贝;
  3. 对象的属性里面成环,即循环引用没有解决。

2.3 改进版(改进后递归实现)

  1. 针对能够遍历对象的不可枚举属性以及 Symbol 类型,我们可以使用 Reflect.ownKeys 方法;
  2. 当参数为 Date、RegExp 类型,则直接生成一个新的实例返回;
  3. 利用 Object 的 getOwnPropertyDescriptors 方法可以获得对象的所有属性,以及对应的特性,顺便结合 Object 的 create 方法创建一个新对象,并继承传入原对象的原型链;
  4. 利用 WeakMap 类型作为 Hash 表,因为 WeakMap 是弱引用类型,可以有效防止内存泄漏,作为检测循环引用很有帮助,如果存在循环,则引用直接返回 WeakMap 存储的值。
const isComplexDataType = obj => (typeof obj === 'object' || typeof obj === 'function') && (obj !== null)
    const deepClone = function (obj, hash = new WeakMap()) {
      if (obj.constructor === Date)
        return new Date(obj)       // 日期对象直接返回一个新的日期对象
      if (obj.constructor === RegExp)
        return new RegExp(obj)     //正则对象直接返回一个新的正则对象
      //如果循环引用了就用 weakMap 来解决
      if (hash.has(obj)) return hash.get(obj)
      let allDesc = Object.getOwnPropertyDescriptors(obj)
      //遍历传入参数所有键的特性
      let cloneObj = Object.create(Object.getPrototypeOf(obj), allDesc)
      //继承原型链
      hash.set(obj, cloneObj)
      for (let key of Reflect.ownKeys(obj)) {
        cloneObj[key] = (isComplexDataType(obj[key]) && typeof obj[key] !== 'function') ?                           deepClone(obj[key], hash) : obj[key]
      }
      return cloneObj
    }
// ---------------------------------------------------------------------------------
// 下面是验证代码

let obj = {
  num: 0,
  str: '',
  boolean: true,
  unf: undefined,
  nul: null,
  obj: { name: '我是一个对象', id: 1 },
  arr: [0, 1, 2],
  func: function () { console.log('我是一个函数') },
  date: new Date(0),
  reg: new RegExp('/我是一个正则/ig'),
  [Symbol('1')]: 1,
};

Object.defineProperty(obj, 'innumerable', {
  enumerable: false, value: '不可枚举属性'
});

obj = Object.create(obj, Object.getOwnPropertyDescriptors(obj))
obj.loop = obj    // 设置loop成循环引用的属性
let cloneObj = deepClone(obj)
cloneObj.arr.push(4)
console.log('obj', obj)
console.log('cloneObj', cloneObj)

3. 补充知识点:

3.1 使用 WeakMap 和 WeakSet:WeakMap 和 WeakSet 是 JavaScript 中新增的数据类型,它们存储的引用是弱引用,不会影响到对象的存活状态。

WeakMap 是一种特殊的 Map,它的键是弱引用,不会影响到对象的存活状态。这意味着,如果一个对象在 WeakMap 中作为键被引用,但是没有其它地方引用它,那么这个对象就会被垃圾回收机制回收。

WeakSet 与 WeakMap 类似,它存储的元素也是弱引用,不会影响到对象的存活状态。

例如:

let obj1 = { name: 'obj1' }; let obj2 = { name: 'obj2' };

let map = new WeakMap(); map.set(obj1, 'value1'); map.set(obj2, 'value2');

// obj1 和 obj2 引用被删除,它们就会被回收 obj1 = null; obj2 = null; 另外, 使用 WeakMap 和 WeakSet 可以更灵活地管理对象之间的关系,而不是直接持有对象的引用,这样就可以避免循环引用导致的内存泄漏问题。例如:

let obj1 = { name: 'obj1' };
let obj2 = { name: 'obj2' };

let map = new WeakMap();
map.set(obj1, obj2);
map.set(obj2, obj1);

// obj1 和 obj2 引用被删除,它们就会被回收
obj1 = null;
obj2 = null;
这样的话, obj1 和 obj2 就不会被回收,因为它们之间还有相互的引用

由于 WeakMap 和 WeakSet 使用的是弱引用,它们不会影响到对象的存活状态,因此不能直接在 WeakMap 和 WeakSet 中访问到对象,需要使用 has() 方法来检查对象是否存在。

let obj1 = { name: 'obj1' };
let obj2 = { name: 'obj2' };
 
let map = new WeakMap();
map.set(obj1, obj2);
map.set(obj2, obj1);
 
console.log(map.has(obj1)); // true
console.log(map.has(obj2)); // true
 
// obj1 和 obj2 引用被删除,它们就会被回收
obj1 = null;
obj2 = null;
 
console.log(map.has(obj1)); // false
console.log(map.has(obj2)); // false

使用 WeakMap 和 WeakSet 可以有效地解决循环引用问题,不会导致内存泄漏,但是需要注意的是, WeakMap 和 WeakSet 只能存储对象而不能存储原始值,并且在老版本的浏览器中不支持。

3.2 hasOwnProperty()

方法用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自 Object 的,会在属性存在于调用它的对象实例上时返回 true

function Person() {} 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function() { 
 console.log(this.name); 
}; 
let person1 = new Person(); 
let person2 = new Person(); 
console.log(person1.hasOwnProperty("name")); // false 
person1.name = "Greg"; 
console.log(person1.name); // "Greg",来自实例
console.log(person1.hasOwnProperty("name")); // true 
console.log(person2.name); // "Nicholas",来自原型
console.log(person2.hasOwnProperty("name")); // false 
delete person1.name; 
console.log(person1.name); // "Nicholas",来自原型
console.log(person1.hasOwnProperty("name")); // false 

3.3 defineProperty

方法这个方法接收 3 个参数: 要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包 含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。根据要修改 的特性,可以设置其中一个或多个值。

let person = {}; 
Object.defineProperty(person, "name", { 
 writable: false, 
 value: "Nicholas" 
}); 
console.log(person.name); // "Nicholas" 
person.name = "Greg"; 
console.log(person.name); // "Nicholas"

3.4 Reflect.ownKeys(obj)

Reflect.ownKeys 返回一个数组,包含对象自身的所有属性,不管是属性名是 Symbol或字符串,也不管是否可枚举。

Reflect.ownKeys({ [Symbol()]:0, b:0, 10:0, 2:0, a:0 })
// ['2', '10', 'b', 'a', Symbol()]

上面代码中, Reflect.ownKeys 方法返回一个数组,包含了参数对象的所有属 性。这个数组的属性次序是这样的,首先是数值属性 2 和 10 ,其次是字符串属 性 b 和 a ,最后是Symbol属性。

3.5 Object.getOwnPropertyDescriptors()

ES5有一个 Object.getOwnPropertyDescriptor 方法,返回某个对象属性的描 述对象(descriptor)。

var obj = { p: 'a' };
Object.getOwnPropertyDescriptor(obj, 'p')
// Object { value: "a",
// writable: true,
// enumerable: true,
// configurable: true
// }

ES7有一个提案,提出了 Object.getOwnPropertyDescriptors 方法,返回指定 对象所有自身属性(非继承属性)的描述对象。

const obj = {
foo: 123,
get bar() { return 'abc' }
};
Object.getOwnPropertyDescriptors(obj)
// { foo:
// { value: 123,
// writable: true,
// enumerable: true,
// configurable: true },
// bar:
// { get: [Function: bar],
// set: undefined,
// enumerable: true,
// configurable: true } }

Object.getOwnPropertyDescriptors 方法返回一个对象,所有原对象的属性名 都是该对象的属性名,对应的属性值就是该属性的描述对象。

3.6 WeakMap

WeakMap 结构与 Map 结构基本类似,唯一的区别是它只接受对象作为键名 ( null 除外),不接受其他类型的值作为键名,而且键名所指向的对象,不计入 垃圾回收机制。

var map = new WeakMap()
map.set(1, 2)
// TypeError: 1 is not an object!
map.set(Symbol(), 2)
// TypeError: Invalid value used as weak map key

WeakMap 的设计目的在于,键名是对象的弱引用(垃圾回收机制不将该引用考虑 在内),所以其所对应的对象可能会被自动回收。当对象被回收后, WeakMap 自 动移除对应的键值对。典型应用是,一个对应DOM元素的 WeakMap 结构,当某个 DOM元素被清除,其所对应的 WeakMap 记录就会自动被移除。基本 上, WeakMap 的专用场合就是,它的键所对应的对象,可能会在将来消 失。 WeakMap 结构有助于防止内存泄漏。

3.7 Object.getPrototypeOf()

类型有一个方法叫 Object.getPrototypeOf(),返回参数的内部特性 [[Prototype]]的值。

console.log(Object.getPrototypeOf(person1) == Person.prototype); // true 
console.log(Object.getPrototypeOf(person1).name); // "Nicholas" 

3.8 Object.create

方法规范化了原型式继承。这个方法接收两个参数:一 个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象。在传入一个参数的情况下, Object.create()与 object()方法的行为相同

3.9 Object.defineProperties()

利用这个方法可以通过描述符一次定义多个属性。这个方法接收两个对象参数:第一 个对象是要添加和修改其属性的对象,第二个对象的属性与第一个对象中要添加或修改的属性一一对 应。

var book = {};
Object.defineProperties(book, {
  _year: {
    value: 2004
  },

  edition: {
    value: 1
  },
  year: {
    get: function () {
      return this._year;
    },
    set: function (newValue) {
      if (newValue > 2004) {
        this._year = newValue;
        this.edition += newValue - 2004;
      }
    }
  }
});

参考文献