面试官:请你谈谈浅拷贝与深拷贝

239 阅读10分钟

在前端开发中,经常会遇到复制对象或数组的情况,也就会涉及到深拷贝与浅拷贝的概念,深拷贝与浅拷贝也是面试中经常会被问到的问题。今天就让我们来详细介绍一下深拷贝与浅拷贝以及手敲一个浅拷贝和深拷贝的源码。

一. 拷贝的概念

首先来了解一下拷贝的概念,拷贝分为浅拷贝与深拷贝,先通过下面一段代码简单认识一下浅拷贝和深拷贝。

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


let obj = {
  a: 1
}
let obj2 = obj
obj.a = 2

console.log(obj2);  //输出 { a: 2 }

第一种情况,当已经将a的值赋值给b后,后面再修改a的值对b的值没有影响,这个就是深拷贝;反之如第二种情况对象的赋值,将obj赋值给obj1时,后面再修改obj里面的值对obj有影响,这就是浅拷贝。这是因为原始类型的赋值是值的复制,而引用类型的赋值是引用地址的复制。所以可以说原始类型的赋值是深拷贝,引用类型的赋值为浅拷贝。

因为原始类型一定是深拷贝,所以一般只在引用类型上探究拷贝,对于拷贝,有以下俩个概念;

  • 只针对引用类型。
  • 基于原对象,拷贝得到一个新对象。

二. 浅拷贝

我们来看看JS中有哪些方法可以实现浅拷贝的效果。

2.1 Object.create(x)

let obj = {
  a: 1
}

let newObj = Object.create(obj)
console.log(newObj.a);  // 1
console.log(newObj);   //{}

Object.create(x)可以凭空创造一个新对象,然后接收一个对象x作为参数,将新对象中的隐式原型设置为x,所以会显示上面情况,输出不出来但是可以访问到,通过浏览器查看如下。

image.png

但是是不是浅拷贝呢?我们再用一段代码来验证。

let obj = {
  a: 1
}

let newObj = Object.create(obj)
obj.a = 2
console.log(newObj.a);  // 2

最后拷贝的值会受原对象的影响,所以可以归为浅拷贝。但是新对象还是一个空对象,只是设置新对象的隐式原型指向 x 对象,不算是一种传统的拷贝,但是它又符合浅拷贝的特征,所以可以说它是一种特殊的"浅拷贝"。

2.2 Object.assign({},obj)

Object.assign(obj,obj2)可以实现对象的拼接,可以接收多个参数,最后一个一层一层加入进前一个对象当中。

image.png

然后看下面代码及其输出值;

let obj = {
  a: 1
}

let obj2 = {
  b: 2
}

let obj3 = Object.assign(obj, obj2)

console.log(obj);   // { a: 1, b: 2 }
console.log(obj3);   // { a: 1, b: 2 }

发现拼接完会影响obj,也就是将obj2加入到obj再将obj返回。将这个写成let obj3 = Object.assign({}, obj)不就完成了对obj的拷贝吗?

那么它是浅拷贝还是深拷贝呢?为了验证正确,我们在obj中的一个key中再加入一个对象,如下;

let obj = {
  a: 1,
  b: {
    n: 2
  }
}
let obj3 = Object.assign({}, obj)
obj.a = 10
obj.b.n = 20

console.log(obj3);  // { a: 1, b: { n: 20 } }

发现如果对象里面的属性值是一个原始类型时,后面再改值就不会影响新对象里的值,如果属性值是一个引用类型时,那么后面改值就会影响,因为原始类型是值的复制,而对象是引用地址的复制。俩个对象中的b引用的是同一个地址,改该地址上的值新对象上的值当然会受到影响。所以说这个方法实现的也是浅拷贝。

image.png

2.3 [].concat(arr)

[].concat(arr)则用来数组拼接,先来看下用法。

let arr = [1, 2]
let arr2 = [3, 4]
let arr3 = arr.concat(arr2)

console.log(arr);   //[ 1, 2 ]
console.log(arr3);   //[ 1, 2, 3, 4 ]

发现不仅可以拼接,还不会改变原数组的值(而前面提到的assign则会改变原对象的值)。

那么直接将后面的数组拼接到空数组上不就可以实现拷贝了吗?代码如下。

let arr = [1, 2, 3, 4]
let arr2 = [].concat(arr)
console.log(arr);   //[ 1, 2, 3, 4 ]

接下来就是验证其是浅拷贝还是深拷贝,同上,也在数组中加入一个对象。

let arr = [1, 2, { a: 1 }]

let arr2 = [].concat(arr)

arr[0] = 0
arr[2].a = 5

console.log(arr2);   //[ 1, 2, { a: 5 } ]

同拷贝对象中的assign一样,原始类型不会改变,而引用类型拷贝的是引用地址,所以会发生改变,即Object.assign({},obj)也是一种浅拷贝。

2.4 数组解构

使用newArr=[...arr]也可以实现一个拷贝效果。

let arr = [1, 2, { n: 3 }]
let newArr = [...arr]

arr[0] = 0
arr[2].n = 5
console.log(newArr);  //[ 1, 2, { n: 5 } ]

所以数组解构也可以实现一个浅拷贝效果。

2.5 arr.slice()

arr.slice()是数组切割的方法,可以截取数组上的一段。

let arr = [1, 2, 3, 4, 5, 6]

// slice 从下标2 开始切到下标3,左闭右开
console.log(arr.slice(2, 4));   // [ 3, 4 ]
console.log(arr);    // [ 1, 2, 3, 4, 5, 6 ]


let arr2 = [1, 2, 3, 4, 5, 6]

// splice 从下标2 开始切4个元素
console.log(arr2.splice(2, 4));  // [ 3, 4, 5, 6 ]
console.log(arr2);   // [ 1, 2 ]

slice切完后原数组不会改变,splice切完后原数组会改变。他俩都可以实现拷贝,但是splice 的主要用途是添加或删除数组元素,所以一般用slice来实现拷贝,也符合其设计初衷。

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

所以说arr.slice()也是一种浅拷贝。

2.6 arr.toReversed().reverse()

arr.toReversed().reverse()就是将一个数组反转再反转,但是使用reverse(),会改变原数组,所以我们可以使用es6新增的一个方法toReversed,它能反转原数组赋值到一个新数组上,并且不会改变原数组。

let arr = [1, 2, 3, 4, 5]
console.log(arr.toReversed());  // [ 5, 4, 3, 2, 1 ]
console.log(arr);  // [ 1, 2, 3, 4, 5 ]

然后我们将arr.toReversed()再用reverse()反转即可完成拷贝。

let arr = [1, 2, 3, 4, 5]
let newArr = arr.toReversed().reverse()
console.log(newArr);  //[ 1, 2, 3, 4, 5 ]

同上,可以在数组里面添加对象,拷贝完再修改来验证,它也是一种浅拷贝。

2.7 手敲一个浅拷贝源码

给你一个对象person,实现浅拷贝效果。我们只需要创建一个新对象,然后遍历原对象,将原对象每一个key值,及其对应的属性值加入到新对象中,再return出新对象即可。

敲源码之前,我们需要掌握以下; 可以用for(let key in obj)遍历对象中的key值,for语句中的 key只是一个参数,可以写成其它,但是为了符合语义化一般写成key。但是for in同时会将对象上的隐式原型也遍历出来。

Object.prototype.b = 2

let obj = {
  a: 1
}

for (let key in obj) {
  console.log(key);  // 输出 a b
}

但是我们拷贝不会去拷贝对象上的隐式原型,那样没有意义,只需要拷贝对象上显示拥有的属性即可,我们就需要用hasOwnProperty判断对象上是否显示拥有该值。

Object.prototype.b = 2

let obj = {
  a: 1
}

console.log(obj.hasOwnProperty('a'));   //true
console.log(obj.hasOwnProperty('b'));   //false

此时还要提到对象中的一个小语法。也就是访问对象方法obj.aobj[a]的区别。

const obj = {
  a: 1
}

let b = 'hello'
obj.b = 'world'
console.log(obj);  // { a: 1, b: 'world' }



const obj2 = {
  a: 1
}

let c = 'hello'
obj2[c] = 'world'
console.log(obj2);  // { a: 1, hello: 'world' }

第一个例子中使用点符号 . 是添加了一个名为 b 的属性,而第二个例子中使用了方括号 [] 则是将变量 c 的值作为属性名,因此添加了一个名为 'hello' 的属性。

那么知道了这些,我们就可以敲一个浅拷贝的源码了。

Object.prototype.girlfriend = '章若楠'

const obj = {
  name: '阿炜',
  age: 18,
  like: {
    n: 'running'
  }
}

function shallow(obj) {
  let res = {}
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      res[key] = obj[key]
    }
  }
  return res
}

console.log(shallow(obj));   // { name: '阿炜', age: 18, like: { n: 'running' } }

首先在函数中创建一个新对象,然后遍历原对象,判断是否为该对象的显示原型,如果是,则就可以用res[key] = obj[key]将原对象的每个key及其属性值添加到新对象中,然后返回一个新对象,经验证,该代码可以成功实现浅拷贝的效果。

三. 深拷贝

了解完了浅拷贝,接下来就来聊聊深拷贝。

3.1 JSON.parse(JSON.stringify(obj))

JSON.stringify(obj)可以将对象转换成字符串,而JSON.parse(str)又可以将字符串转成对象,但是其不能拷贝undefined Symbol function NaN Infinity等类型的值,如下。

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))
console.log(res);

拷贝结果如下:

image.png

不过当有bigint类型或有循环语句时会报错,如下。

let obj = {
  name: '钟总',
  age: 18,
  like: {
    n: '金铲铲'
  },
  a: 123n
}

let res = JSON.parse(JSON.stringify(obj))
console.log(res);

image.png

let obj = {
  name: '钟总',
  age: 18,
  like: {
    n: '金铲铲'
  }
}

obj.c = obj.like
obj.like.n = obj.c

let res = JSON.parse(JSON.stringify(obj))
console.log(res);

image.png 所以对于JSON.parse(JSON.stringify(obj)):

  1. 不能识别 bigint
  2. 不能拷贝 undefined Symbol function NaN Infinity
  3. 无法处理循环引用
let obj = {
  name: '钟总',
  age: 18,
  like: {
    n: '金铲铲'
  },
}

let res = JSON.parse(JSON.stringify(obj))
obj.age = 19
obj.like.n = '王者'

console.log(res);  //{ name: '钟总', age: 18, like: { n: '金铲铲' } }

发现新对象不会受原对象影响,所以它可以用来实现深拷贝的效果。

3.2 structuredClone(obj)

在前几年,官方打造了一个真正可以实现深拷贝的方法,也就是structuredClone()。让我们来体验一下;

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

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

console.log(newUser)  //{ name: '朱总', like: { n: '泡脚', m: '吃鸡' } }

发现朱总喝茶不如泡脚,所以它可以真正实现深拷贝。

3.3 手敲一个深拷贝源码

这也是面试官比较容易问到的问题,那就让我们手动实现一个深拷贝效果。

拷贝的是一个对象的话,就要拷贝对象中的每一个属性值,如果属性值是一个对象的话就要接着拷贝这个对象中的属性值,如果又是对象,接着拷贝,重复调用函数拷贝,直到拷贝到是原始类型不是对象为止,这就要用到递归的思想了。

具体代码实现如下;

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

function deepClone(obj) {
  //不可以写成 obj instanceof Object ? {} : [],因为数组也是对象
  //如果是数组的话数组也是对象,他还是会变成对象而不是数组

  // 如果是数组就创建一个数组,如果是对象就创建一个对象,方便递归
  let res = obj instanceof Array ? [] : {}

  for (let key in obj) {
    //判断 key 是不是 obj 显式拥有
    if (obj.hasOwnProperty(key)) {
      //当这个属性是对象的时候,由于typeof判断null也是对象,所以要去除这个可能
      if (typeof obj[key] === 'object' && obj[key] !== null) {
        res[key] = deepClone(obj[key])// 引用类型就递归
      } else {
        res[key] = obj[key]// 原始类型直接赋值
      }
    }
  }
  return res
}
let newObj = deepClone(obj)
obj.like.n = '打球'
console.log(newObj);   //{ name: '小陈', age: 18, like: { n: '按摩' } }

输出结果发现小陈打球不如按摩,即该代码深拷贝正确。

四. 总结

  1. 拷贝
  • 只针对引用类型
  • 基于原对象,拷贝得到一个新对象
  1. 浅拷贝:新对象受原对象的影响(只拷贝了对象的第一层,而里面的子对象拷贝的还是引用地址)
  • Object.create(x)

  • Object.assign({}, obj)

  • [].concat(arr)

  • 数组解构

  • arr.slice()

  • arr.toReversed().reverse()

  1. 深拷贝:新对象不受原对象的影响(全部拷贝完)
  • JSON.parse(JSON.stringify(obj))
  1. 不能识别 bigint
  2. 不能拷贝 undefined Symbol function NaN Infinity
  3. 无法处理循环引用
  • structuredClone()
  1. 浅拷贝与深拷贝的源码实现

最后,司道普;

image.png