带你由浅入深理解Js的拷贝

342 阅读18分钟

前言

在现代Web开发中,JavaScript是不可或缺的一部分,而数据的深拷贝和浅拷贝是我们经常遇到的一个问题。随着应用程序的复杂度日益增加,数据结构变得越来越复杂,因此,如何在JavaScript中高效且准确地进行数据拷贝变得尤为重要,下面我们来由浅入深了解一下js中的浅拷贝和深拷贝。

1. 拷贝

在很多的编程语言中都拥有拷贝这个概念,例如python。同样,JavaScript中同样也拥有拷贝这个功能,顾名思义,拷贝就是将数据复制过来为我们所有,而拷贝可以分为浅拷贝深拷贝。下面我们来看一段代码看看什么是拷贝:

let a = 1
let b = a
a = 2
console.log(b);

// 输出:
// 1

上面代码我们用b进行了对a的拷贝说白了就是复制嘛,将a的值赋予了b,下面我们对a改动的时候,b并不会随着a一起改变,而这种方式我们将其称为深拷贝。下面我们再来看看另一种拷贝方式——浅拷贝:

let obj = {
  a: 1
}
let obj2 = obj
obj.a = 2
console.log(obj2);

// 输出:
// { a: 2 }

我们可以看到当我们将obj赋值给obj2的时候,其实是将obj的引用地址赋予了obj2,这样我们修改obj的时候,obj2同样会进行修改(不理解的可以看看:js内存机制),这个呢其实叫赋值,并不能算是拷贝。但是从这段代码我们可以理解浅拷贝的意思,就是对原来的数据进行修改时,拷贝的数据也会随之修改。

拷贝

  1. 只针对引用类型,因为原始类型都是深拷贝
  2. 基于原对象,通过拷贝得到一个新对象

2. 浅拷贝

当我们通过上文初步认识了拷贝这个意思之后,下面我们先来看看浅拷贝,毕竟咱学东西得由浅入深嘛。通过上文我们可以知道浅拷贝是什么:

浅拷贝:新对象会受原对象的影响(只拷贝了对象的第一层,而里面的子对象拷贝的还是引用地址)

在js中我们可以使用很多种方式来进行浅拷贝,下面我们来跟随着代码一起看看是如何实现浅拷贝的。

Object.create(x)

在之前的文章速通JavaScript的原型和原型链中,我们最后提出了一个问题是不是所有的对象都有隐式原型?答案是否定的,用Object.create(null)所创建出来的对象是没有的,而这个方法还可以运用在我们的拷贝中,并且它是一个仁者见仁智者见智的方法 —— 可以说是浅拷贝也可以说不是。下面我们来看一段代码:

let obj = {
  a: 1
}
let newObj = Object.create(obj)// 创建一个空对象,并设置它的隐式原型为obj,然后返回给newObj
obj.a = 2
console.log(newObj)
console.log(newObj.a);

// 输出:
// {}
// 2

我们可以看到Object.create这个方法创建了一个空对象给newObj,并且让它的隐式原型指向了obj。所以当我们对obj的值进行修改时newObj也会随着修改,根据这一特征刚好符合我们之前对浅拷贝的定义。

那我们之前为什么说它又不是浅拷贝呢?因为我们发现在使用这个方法的时候会返回一个空对象,其中并没有obj的属性,而是通过隐式原型进行查找,但是我们的浅拷贝说要得到原对象的属性和方法,要把它放到自己的身上,而这一点又与浅拷贝的定义不同。所以说它又可以说不是浅拷贝。

那这时候就有人会问了,那如果以后问到的话说它是什么拷贝呢?这个就看问你的那个人是哪个党派的了,仁者见仁智者见智嘛。

Object.assign(x)

在对象上我们除了使用create()方法可以进行浅拷贝,还能使用另外一个assign()方法进行拷贝,下面我们来看一下如何使用这种方法进行浅拷贝,下面我们来看一段代码:

let obj = {
  a: 1
}
let obj2 = {
  b: 2
}
let obj3 = Object.assign(obj, obj2)
Object.assign()方法用于对象的合并,将后面对象的内容拼接到前面的对象中,可以接收多个参数,会得到一个新对象并返回
console.log(obj3);
console.log(obj);
console.log(obj2);

image.png

我们可以看到如果使用assign()这个方法的话会将objobj2中的属性全部都加在obj3中,这样我们就实现了浅拷贝。

那么Object.assign()这个方法是如何运行的呢?它如果接受了多个参数的话会将后面的对象与前面的对象合并,然后再将合并后的对象与前一个对象继续合并就跟套娃一样,最后会得到一个新对象并且返回。

在了解了Object.assign()这个方法的运行机制后,我们就可以理解了为什么使用这个方法之后,obj中也有了obj2的属性,而这种拷贝方式同样也是浅拷贝

下面我们来看看它为什么是浅拷贝:

let obj = {
  a: 1,
  b: {
    n: 2
  }
}

let obj3 = Object.assign({}, obj)
console.log(obj3);
obj.a = 2
obj.b.n = 3
console.log(obj3);

image.png

通过输出我们可以看到如果修改obj中的属性obj3同样也会随之修改,所以用这种方法实现的是浅拷贝

[].concat(x)

咱总不能老是逮着一只羊薅羊毛吧,这样早晚得被薅秃的。ok啊,下面我们来换只🐏接着薅羊毛,接下来我们来看看数组中是如何实现浅拷贝的,因为数组也是一种引用类型同样也是对象嘛。接下来我们看看数组中的concat()这个方法:

let arr = [1, 2]
let arr2 = [3, 4]

let arr3 = arr.concat(arr2)
//concat()可以用来合并数组,不会影响调用的数组,因为它会开一个新的数组来储存,concat()返回一个新数组。
console.log(arr3);
console.log(arr, arr2);

image.png

大家可以看到如果使用了concat这个方法的话,会将调用这个方法的数组与传入参数的那个数组进行合并,然后存在一个新数组中并且进行返回。

这时候有同学就会问了我将arr中的数值进行修改但是arr3中的并没有修改它为什么会被称为浅拷贝呢?这里我们得想到除了原始类型我们还有个引用类型,下面我们如果在数组中加入引用类型的话结果如何:

let arr = [{a: 1}]
let arr2 = [2]
let arr3 = arr.concat(arr2)
console.log(arr3)
arr[0].a = 2
console.log(arr3)

image.png

我们根据上面结果可以发现如果数组中存放的是引用类型的话,我们对原数组中的数据进行修改的时候,这个合并后的新数组中的数据也会随之修改,而这一点符合我们浅拷贝的规则,所以数组中的concat()这个方法也是浅拷贝。

数组解构

根据浅拷贝的定义我们知道,只需要把一个变量的值直接赋予给另一个变量即可,那么在数组中我们的展开运算符也能做到这一点,将数组中的值一个个展开然后赋值,下面我们来看一段代码:

let arr = [1, {a: 1}]
let arr2 = [...arr]
console.log(arr2)
arr[1].a = 2
console.log(arr2)

// 输出:
// [ 1, { a: 1 } ]
// [ 1, { a: 2 } ]

由上面结果我们可以看到当我们对arr中的arr[1]进行修改时,arr2中的arr2[1]中的a属性也会随之修改,所以数组解构这一个方法同样也是浅拷贝。

arr.slice()

接下来我们再来看一个数组中可以实现浅拷贝的方法,这时候有同学可能会问了,为什么还讲数组?没办法,它的羊毛比较多,咱们就多薅一点。

当我们看到slice()这个方法时,了解过数组的同学可能会想到数组中的splice()这个方法,在这里我们就要注意区分了如果用splice()这个方法会对原数组进行修改,而slice()并不会,下面我们来看看:

let arr = [1, 2, 3]
let newArr = arr.slice(1)
console.log(newArr);
console.log(arr)

console.log('--------------------------')

let arr2 = [1, 2, 3]
let newArr2 = arr2.splice(1)
console.log(newArr2);
console.log(arr2)

image.png

在了解完了slice()splice()的区别之后,我们来聊聊slice()是如何进行浅拷贝的,下面我们来看一段代码:

let arr = [1, 2, {n: 3}]
let newArr = arr.slice()
arr[2].n = 50 
console.log(newArr);

// 输出:
// [ 1, 2, { n: 50 } ]

我们可以看到当我们用slice()方法对原数组进行了拷贝之后,修改原数组中的元素时,新数组同样也跟着进行了修改,所以用这种方法也是浅拷贝

arr.toReversed().reverse()

接下来我们来看看数组身上最后一根羊毛,这个方法中的toReversed()这个方法是es6新增的一种方法,我们可以看到这个方法跟原来的reverse方法就多了个to,那它们有什么区别呢?下面我们来跟随一段代码看看它们的区别:

let arr = ['a', 'b', 'c', 'd', 'e']
console.log(arr.reverse())
console.log(arr);//会修改原始数组

let arr2 = ['a', 'b', 'c', 'd', 'e']
console.log(arr2.toReversed());//不会修改原始数组,会凭空创建新数组
console.log(arr2);

image.png

根据上面的输出结果我们可以发现:

reverse()会对原数组进行修改

toReversed()不会修改原始数组,会凭空创建一个新数组

我们根据这两个特点可以将其联合到一起从而进行数组的浅拷贝,下面我们来看看如何实现:

let arr = ['a', 'b', 'c', 'd', 'e']
console.log(arr.toReversed().reverse());
console.log(arr);

// 输出:
// [ 'a', 'b', 'c', 'd', 'e' ]
// [ 'a', 'b', 'c', 'd', 'e' ]

我们可以看到当我们先用toReversed()对数组进行翻转后会产生一个新的数组从而不会影响到原数组,然后再对新数组进行翻转,这样就完成了对arr的拷贝,而这种方式同样是浅拷贝,大家可以自行尝试如果数组中存放的是引用类型修改后,新数组会不会随之修改。

浅拷贝实现原理

当我们在学习了前面六种实现浅拷贝的方法之后,我们来深入一下看看浅拷贝是如何进行实现的。

浅拷贝实现原理:创建一个新对象,将原对象的所有属性和方法都拷贝到新对象上,在这个过程中我们得通过hasOwnProperty()方法来判断该属性是否是原对象显式拥有的,如果是就将其拷贝到新对象上,如果不是就跳过。

在我们了解完了浅拷贝的原理之后我们来看看如何使用代码自己实现浅拷贝:

let person = {
  name: '牛哥',
  age: 20,
  like: {
    n: 'running'
  }
}

function shallow(obj) {
  let res = {}
  // 对对象进行遍历
  for (let key in obj) {
    //要判断 key 是不是 obj 显式拥有的属性,因为如果key不是obj显式拥有那么for in会去隐式原型上面查找属性,
    // hasOwnProperty()方法会返回一个布尔值,判断该属性是否显式拥有,是就返回true否则返回false
    if (obj.hasOwnProperty(key)) {
      //obj中的key只能是字符串,所以用[]
      res[key] = obj[key]
    }
  }
  return res
}

const newPerson = shallow(person)
person.like.n = 'eat'
console.log(person);
console.log(newPerson);

image.png

在上面的代码中我们主要对引用类型进行浅拷贝,因为原始类型的赋值都是深拷贝。

通过输出结果我们可以知道当我们对原始对象的值进行修改时,新对象的值同样进行了修改,而这种结果符合我们对浅拷贝的定义,下面我们来聊聊上面代码是如何进行的。

在一个对象中有很多种不同的属性,而这些不同的属性中可能也包含了别的对象,当我们想对对象进行拷贝时,需要用for in这个方法对对象进行遍历从而将它的keyvalue赋值到新对象身上。

由于我们进行的是浅拷贝,在遍历的过程中如果碰到了value是原始类型或者引用类型的数据我们直接赋值即可,最后遍历完了之后返回进行了浅拷贝之后的对象即可。

在这个过程中我们有一个需要特别注意的点:由于for in非常有实力,当key不是obj身上显式拥有的属性的话,for in会根据原型链向上进行查找,但是这时候找到的key并不是obj中的我们不需要。根据这一情况我们需要特别进行判断一下用对象身上自带的hasOwnProperty()这个方法来判断key是不是obj身上显式拥有的,如果是的话就直接赋值,反之就跳到下一个key上面继续上面的流程。

3. 深拷贝

通过前面的学习我们已经了解认识了浅拷贝是什么,简单来说就是非常肤浅,只拷贝了表层的东西。接下来我们就深入一下拷贝来学学深拷贝。

首先我们来看看深拷贝的定义:

深拷贝:新对象不受原对象的影响

这句话可能有些抽象,我们可以这样来理解:浅拷贝就相当于是我们站在太阳底下那个照射出来的那个影子,只有我们的外形,当我们移动的时候它也会跟着我们移动。而深拷贝呢就相当于是根据我们打造出了一个跟我们一模一样的克隆人,克隆人虽然外表和里面的身体结构是一样的,但是他的思想跟我们并不一样,我们可能会工作累了想休息,而他不受我们的影响还会继续干手里的活。

上面这个理解呢在我们中国有个词语:有形无神,这就是浅拷贝,而有形也有神那就是深拷贝。在简单认识了这两种拷贝的区别之后,下面我们来看看实现深拷贝的几种方法:

JSON.parse(JSON.stringify(obj))

在js这门语言中官方在之前并没有特意为我们打造一个能进行深拷贝的方法,这两个方法组合在一起是碰巧能实现深拷贝的。

JSON.stringify():将对象转化为字符串

JSON.parse():将字符串转化为对象

为什么这样说呢?下面我们来看看用它实现的深拷贝跟原对象有什么区别:

let obj = {
  name: '钟总',
  age: 18,
  like: {
    n: '金铲铲'
  },
  a: true,
  b: null,
  c: undefined,
  d: Infinity,
  e: -Infinity,
  f: NaN,
  g: Symbol(1),
  i: function() {}
}

let res = JSON.parse(JSON.stringify(obj))//JSON.parse()可以将字符串变为对象
console.log(res)
console.log('--------------------------');
obj.age = 19
obj.like.n = 'cs'
console.log(res.age);
console.log(res.like.n);

image.png

我们先看分割线下面输出的两行代码,如果是浅拷贝,那么当我们修改obj.like.n的时候,用这个方法生成的新对象res中的like.n也会随之修改,但是我们可以看到res中的agelike.n并没有随之修改,还是原来的key和value,这样就实现了深拷贝

下面我们来看看分割线分割线上面的代码,我们将新得到的res进行了输出,但是我们拿res与原对象obj一对比就会发现怎么有些属性不见了,还有些属性怎么给我改成了null呢?而这就是使用JSON.parse(JSON.stringify(obj))这种方法的弊端了,下面我们来看看使用这种方法我们得注意什么:

  • JSON.parse(JSON.stringify(obj))

  1. 不能识别 bigint
  2. 不能拷贝 undefined、symbol、函数、NaN、Infinity(前三种是类型,后两种是值且都属于Number)
  3. 无法处理循环引用

在上面的几个注意事项中前面两个大家都能理解,下面这个循环引用是什么可能有些困惑下面我们来看看什么是循环引用:

// obj 还是前面的obj
obj.c = obj.like
obj.like.n = obj.c

根据上面代码我们可以看到我们让obj中的c的值为like的值,然后又让like.n的值等于c的值这样不就将两头连接起来形成了一个循环吗,而这个就叫做循环引用

structuredClone()

在经过了这么多年无法实现深拷贝的折磨之后,咱们也算是苦尽甘来,官方终于是扛不住天天被戳脊梁骨的压力了,为我们新增了一个structuredClone()方法让我们可以实现深拷贝,下面我们下面我们来看看如何使用:

const user = {
  name: '朱总',
  like: {
    n: '泡脚',
    m: '吃鸡'
  }
}

const newUser = structuredClone(user)
user.like.n = '喝茶'
console.log(user);
console.log(newUser);

image.png

当使用这个方法时我们直接把需要被拷贝的对象传入这个函数的参数中即可,然后它会返回一个深拷贝过后的对象给我们,我们可以看到当修改原对象user中的值时,新对象中的newUser并没有随之修改,所以这个方法实现的是深拷贝

4. 面试经典:手动实现深拷贝

在我们学习完了之前浅拷贝的实现原理之后,可能会觉得这个实现过于简单,只需要无脑遍历就是了,那么当我们面试的时候,面试官可不会这么无脑。关于拷贝肯定会深入一点,正所谓自己动手,丰衣足食,下面我们来聊聊自己实现深拷贝的一个思路:

首先我们想要拷贝一个对象的话,那肯定要拷贝对象中的每一个属性以及它所对应的那个属性值,在这个过程中,同样有实现浅拷贝时查找key的那个问题,所以我们也需要进行特判一下看看key是不是原对象身上显式拥有的。

由于我们做的是深拷贝,当有属性值为引用类型的时候,我们就不能按照浅拷贝的那种方式来实现,必须得自己创建一个新的对象用来储存当前这个对象中的属性值,不然的话我们修改原对象中那个对象的属性值时,这个新对象也会随之修改。

那就有同学会问了,如果我是对象里面有几个对象嵌套在一起的话该怎么办呢?大家可以想想上面我们进行对对象中的对象进行拷贝的步骤,不就是将上面的步骤再执行一遍吗,这时候我们只需要调用自身即可,下面来看代码实现:

let obj = {
  name: '小陈',
  age: 18,
  like: {
    n: '按摩'
  }
}


function deepClone(obj) {
  //不可以写成 obj instanceof Object ? {} : [],因为数组也是对象,
  //如果是数组的话数组也是对象,他还是会变成对象而不是数组
  
  // 如果是数组就创建一个数组,如果是对象就创建一个对象,方便递归
  let clone = obj instanceof Array ? [] : {}
  
  for (let key in obj) {
    //判断 key 是不是 obj 显式拥有
    if (obj.hasOwnProperty(key)) {
      //当这个属性是对象的时候,由于typeof判断null也是对象,所以要去除这个可能
      if (typeof obj[key] === 'object' && obj[key] !== null) {
        clone[key] = deepClone(obj[key])// 引用类型就递归
      } else {
        clone[key] = obj[key]// 原始类型直接赋值
      }
    }
  }
  return clone
}
let newObj = deepClone(obj)
obj.like.n = '打球'
console.log(newObj);

image.png

我们可以看到通过上面代码我们实现了深拷贝,而这个源代码是需要我们所掌握的。

结语

以上呢就是我们关于拷贝的全部内容,大家可以看看下面的总结

拷贝

  • 只针对引用类型,因为原始类型都是深拷贝
  • 基于原对象,通过拷贝得到一个新对象

浅拷贝:新对象会受原对象的影响(只拷贝了对象的第一层,而里面的子对象拷贝的还是引用地址)

  • Object.create(x)

  • Object.assign(x)

  • [].concat(x)

  • 数组解构

  • arr.slice()

  • arr.toReversed().reverse()

  • 浅拷贝实现原理:创建一个新对象,将原对象的所有属性和方法都拷贝到新对象上,在这个过程中我们得通过hasOwnProperty()方法来判断该属性是否是原对象显式拥有的,如果是就将其拷贝到新对象上,如果不是就跳过。

深拷贝:新对象不受原对象的影响

  • JSON.parse(JSON.stringify(obj))

  1. 不能识别 bigint

  2. 不能拷贝 undefined、symbol、函数、NaN、Infinity(前三种是类型,后两种是值且都属于Number)

  3. 无法处理循环引用

  • structuredClone()

1732338928918.jpg