关于深拷贝的思考点

515 阅读6分钟

一、简单数据类型和复杂数据类型的区别

在 JavaScript 的数据类型中,分为简单数据类型和复杂数据类型,简单数据类型包括 string、number、boolean等,复杂数据类型就包括 object、function 等,当我们要实现复制操作时,对于简单类型而言是很容易的,直接将原有值重新赋值给一个新的变量即可,但对于复杂类型来说就比较麻烦了,因为我们的变量只存储着对应复杂类型的引用,即该复杂类型值在内存中的地址,当我们重新赋值给一个新的变量时,我们也只是复制了一份地址而已,如果我们修改了这个复杂数据中的内容,那么将会影响所有存储了该数据地址的变量,如下所示:

let a = {
  name: 'john',
  age: 18
}
let b = a
b.name = 'mary'
console.log(a) //{name:'mary',age:18}  修改了 ba 同样改变了

二、浅拷贝和深拷贝

浅拷贝的意思只是单纯地重新赋值,如果是简单数据类型,那么就将值直接赋给一个新的变量,如果是复杂数据类型,那么就将复杂数据的引用赋给一个新的变量,很明显这会使得当改变原有值时新的变量也会发生改变。

深拷贝的意思则是如果拷贝复杂类型的数据,那么就重新在内存中开辟一个空间,生成一个新的数据,数据中的内容和原有内容保持一致,这样数据之间就不会相互影响了,而对于简单数据类型就和浅拷贝一样。

三、深拷贝中需要注意的问题

1.使用递归

深拷贝一个复杂数据类型的时候,如果该数据中又包含一个复杂数据类型,例如一个对象中的某个属性值又是一个对象,那么就需要再次深拷贝,这就很明显要用到递归的思想了,而递归的“出口”就是如果是一个简单数据类型,那么直接返回该值。

2.检查数据类型

深拷贝的时候,检查数据类型是非常必要的,不仅要检查数据是否是复杂数据类型,而且还要检查是哪种数据类型,对于不同的复杂数据类型也需要有不同的操作,因为并不是所有复杂数据类型都是对象,对于正则表达式、数组、函数等,他们不仅仅是一个对象,还需要有对应的一些特性方法,例如一个正则表达式,我们不能仅仅复制该正则表达式的值,必须要通过 new RegExp(原有值)来生成一个正则表达式对象。除此之外,对于 null 这个特殊值,当我们使用 typeof 进行判断的时候,返回值是 "object",因此我们也需要单独判断是否为 null 值,如果是就直接返回 null。

可参考:原生js实现深拷贝(该篇文章的代码有很多问题,但是可借鉴其中的思考)

3.循环引用问题

首先什么是循环引用,如下代码:

let obj = {
  a: 1
}
obj.A = obj
console.log(obj)

结果:

设想一下我们在递归深拷贝的时候,不断循环拷贝,永远找不到 return 的条件,那么就必然会出现调用栈溢出的情况,怎么避免这个问题呢?

我们都知道定义对象的时候不能使用对象作为属性,但是 Map 对象可以存储属性为对象的键值对,利用这个特点,我们就能巧妙地解决循环引用的问题,下面假设在忽略其他复杂数据类型只考虑对象或数组的情况下实现深拷贝,那我们可以这样来设计代码:

function deepClone(obj, map = new Map()) {
  if (typeof obj === 'object') {
    let res = Array.isArray(obj) ? [] : {}
    if (map.get(obj)) {
      return map.get(obj)
    }
    map.set(obj, res)
    for (let key in obj) {
      res[key] = deepClone(obj[key], map)
    }
    return map.get(obj)
  } else {
    return obj
  }
}

其实简单点说,使用 object.key = object 这样的形式就会造成循环引用,那我们在做深拷贝的过程中也运用这样的形式去赋值,上面的代码中 res[key] = deepClone(obj[key], map),由于 deepClone 返回的是 map.get(obj),也就是 res,所以就实现了将 res 的某个属性再次赋值给了 res,于是巧妙地实现了循环引用的深拷贝。

可参考:深拷贝-循环引用的处理

四、使用 JSON.parse(JSON.stringify(obj)) 的拷贝方式存在的问题

最后谈谈关于使用 JSON.parse(JSON.stringify(obj)) 来实现深拷贝的时候应该注意哪些问题。

不得不说使用序列化和反序列化的方式来实现深拷贝是一种最简单的方式了,但是该方法却有很多局限性:

(1)如果复制一个日期对象,那么复制后得到的值就只是一个字符串形式的数据,而不再是一个日期对象了:

function copy(obj){
  return JSON.parse(JSON.stringify(obj))
}
const date = new Date();
console.dir(date);
console.dir(copy(date))

结果:

(2)如果复制一个正则表达式,那么复制后的结果只是一个空对象。

function copy(obj) {
  return JSON.parse(JSON.stringify(obj))
}
const regexp = /[\s\w]*/
console.log(regexp);
console.log(copy(regexp))

结果:

(3)如果一个对象的某个属性是函数或者是undefined,会导致该属性丢失。

function copy(obj) {
  return JSON.parse(JSON.stringify(obj))
}
const obj = {
  a: 1,
  b: function() {
    return '一个函数'
  },
  c: undefined
}
console.log(obj);
console.log(copy(obj))

结果:

(4)如果只为 NaN、Infinity、-Infinity,得到的结果都为 null。

function copy(obj) {
  return JSON.parse(JSON.stringify(obj))
}
console.log(copy(NaN))
console.log(copy(Infinity))
console.log(copy(-Infinity))

结果:

(5)如果一个对象是由构造函数生成,那么复制后的结果会丢弃原有的 contructor 等属性,只会包含可枚举的自由属性。

function copy(obj) {
  return JSON.parse(JSON.stringify(obj))
}
class Person {
  constructor(name, age) {
    this.name = name
    this.age = age
  }
}
const person = new Person('mary', 18)
console.dir(person)
console.dir(copy(person))

结果:

(6)如果是循环引用的话,那么会直接报错。

小小总结一下:

使用 JSON.parse(JSON.stringify(obj)) 的时候虽然用法简单,但是有很多问题,因为在序列化 JavaScript 对象时,所有函数和原型成员都会被有意忽略。

通俗点说,JSON.parse(JSON.stringfy(X)),其中 X 只能是Number, String, Boolean, Array, 扁平对象,即那些能够被 JSON 直接表示的数据结构。

贯通全文来看,要实现一个毫无缺点的深拷贝还是比较复杂的,鉴于小编尚处在初级阶段,所认识到的问题也就这些了,欢迎各位批评指正。