Featured image of post JS: 深拷贝与浅拷贝

JS: 深拷贝与浅拷贝

在 JavaScript 中,对象是引用类型,而基本类型(numberstringbooleannullundefinedsymbol)是值类型。值类型的特点是在赋值的时候会在内存中创建一个新的空间 ( 通常是在栈当中 ),而引用类型的特点是在赋值的时候会将内存中的地址赋值给变量,所以两个变量指向的是同一个地址,改变其中一个变量的值,另一个变量的值也会改变。

所以对于基本类型的赋值,是将值复制给变量,而对于引用类型的赋值,是将地址复制给变量,它们都是属于浅拷贝。

而深拷贝就是在拷贝的时候,会在内存中创建一个新的空间,然后将原对象的各个属性逐个复制到新对象的空间中去,这样就实现了两个对象的完全隔离。

浅拷贝

Object.assign()

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。

1
2
3
4
5
const obj1 = { a: 1, b: 2 }
const obj2 = Object.assign({}, obj1)

console.log(obj2) // { a: 1, b: 2 }
console.log(obj1 === obj2) // false

从上面的例子可以看出,Object.assign() 方法可以实现对象的浅拷贝,但是如果源对象中的属性值是一个对象的话,那么目标对象中的该属性值也会是一个对象,也就是说源对象和目标对象中的该属性值指向的是同一个地址,所以改变其中一个对象的该属性值,另一个对象的该属性值也会改变。

1
2
3
4
5
const obj1 = { a: 1, b: { c: 1 } }
const obj2 = Object.assign({}, obj1)

obj2.b.c = 2
console.log(obj1.b.c) // 2

扩展运算符

扩展运算符(...)可以将一个数组转为用逗号分隔的参数序列,所以可以使用扩展运算符来实现数组的浅拷贝。

1
2
3
4
const arr1 = [1, 2, 3]
const arr2 = [...arr1]

console.log(arr2) // [1, 2, 3]

同样的,如果数组中的元素是一个对象的话,那么目标数组中的该元素也会是一个对象,也就是说源数组和目标数组中的该元素指向的是同一个地址,所以改变其中一个数组的该元素,另一个数组的该元素也会改变。

1
2
3
4
5
const arr1 = [{ a: 1 }, { b: 2 }]
const arr2 = [...arr1]

arr2[0].a = 2
console.log(arr1[0].a) // 2

Array.prototype.slice()

slice() 方法返回一个新的数组对象,这一对象是一个由 beginend 决定的原数组的浅拷贝(包括 begin,不包括 end)。原始数组不会被改变。当然,这个方法同样遵循上面的浅拷贝的规则。

1
2
3
4
5
const arr1 = [1, 2, { a: 1 }]
const arr2 = arr1.slice() // arr1.slice(0)

arr2[2].a = 2
console.log(arr1[2].a) // 2

Array.prototype.concat()

concat() 方法用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。这个方法与 slice() 方法的效果是类似的。

1
2
3
4
5
const arr1 = [1, 2, { a: 1 }]
const arr2 = arr1.concat()

arr2[2].a = 2
console.log(arr1[2].a) // 2

循环

除了上面所提到的方法以外,我们还可以自己手动实现一个浅拷贝。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function shalldowCopy(obj) {
  if (obj == null || typeof obj !== 'object')
    throw new Error('obj must be an object')
  let newObj = new obj.constructor()
  for (let k in obj) {
    if (obj.hasOwnProperty(k)) {
      newObj[k] = obj[k]
    }
  }
  return newObj
}

obj 不为对象时我们就不用考虑浅拷贝了,基本类型都是值复制。我们先定义了一个 newObj 的新对象,这个新对象是由 obj 的构造函数来构造出来的,这样我们就不用考虑 obj 究竟是属于哪个类了。然后我们通过使用 for in 循环对对象上面的属性一个一个地进行拷贝,这里我们需要考虑 obj 身上是否 “存在” k 属性,因为 obj 身上的 k 属性可能是来自于原型链上的,对于原型链上的属性我们不需要考虑。

浅拷贝还有许多方法可以实现,但是它们都有一个共同的问题,就是源对象中的属性如果也包含了对象的话,最终实现的还是浅拷贝。

深拷贝

JSON.parse(JSON.stringify())

JSON.parse() 方法用来解析 JSON 字符串,JSON.stringify() 方法用来将 JavaScript 对象转换为 JSON 字符串。我们可以先将对象转换为 JSON 字符串,然后再将 JSON 字符串转换为对象,这样就实现了深拷贝。

1
2
3
4
5
const obj1 = { a: 1, b: { c: 1 } }
const obj2 = JSON.parse(JSON.stringify(obj1))

obj2.b.c = 2
console.log(obj1.b.c) // 1

从上面的例子可以看出,JSON.parse(JSON.stringify()) 方法可以实现对象的深拷贝,当我们修改了 obj2 中的 b 属性的值时,obj1 中的 b 属性的值并没有改变。

但是使用这种方法对对象进行深拷贝时会遇到一些问题:

  • 会忽略 undefined, symbol
  • 不能序列化函数和 BigInt 类型的值
  • 不能解决循环引用的对象
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const obj = {
  a: undefined,
  b: Symbol(1),
  c: function () {},
  d: {
    n: 100,
  },
}

const copied_obj = JSON.parse(JSON.stringify(obj)) 

console.log(copied_obj) // { d: { n: 100 } }

从上面的例子可以看出,JSON.parse(JSON.stringify()) 方法会忽略 undefinedsymbol。同时此方法也不能序列化函数和 BigInt 类型的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const obj = {
  a: undefined,
  b: Symbol(1),
  c: function () {},
  d: {
    n: 100,
  },
  e: 123n,
  f: null,
}

const copied_obj = JSON.parse(JSON.stringify(obj)) // TypeError: Do not know how to serialize a BigInt

并且此方法也不能解决循环引用的对象。

1
2
3
4
5
6
7
const obj = {
  a: 1,
}

obj.b = obj

const copied_obj = JSON.parse(JSON.stringify(obj)) // TypeError: Converting circular structure to JSON

递归

既然 JSON.parse(JSON.stringify()) 方法存在如此多的问题,那么我们可以自己手动来实现一个深拷贝,我们可以改造一下浅拷贝的手动实现方式,加上一层递归就能够很简单的实现一个深拷贝。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
let obj = {
  a: undefined,
  b: Symbol(1),
  c: function () {},
  d: {
    n: 100,
  },
  e: 123n,
  f: null,
}

function deepCopy(obj) {
  if (obj == null || typeof obj !== 'object')
    throw new Error('obj must be an object')
  let newObj = new obj.constructor()
  for (let k in obj) {
    if (obj.hasOwnProperty(k)) {
      newObj[k] = typeof obj[k] === 'object' && obj[k] != null ? deepCopy(obj[k]) : obj[k]
    }
  }
  return newObj
}

let newObj = deepCopy(obj)

obj.d.n = 200
console.log(newObj)

打印结果如下:

1
2
3
4
5
6
7
8
{
  a: undefined,
  b: Symbol(1),
  c: [Function: c],
  d: { n: 100 },
  e: 123n,
  f: null
}

和源对象一模一样,并且对源对象的修改也不会影响到新对象,并且这个方法对于任何对象的深拷贝都不会丢失源对象的类型。但是这个方法无法解决循环引用的对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
let obj = {
  a: 1,
}

obj.b = obj

function deepCopy(obj) {
  if (obj == null || typeof obj !== 'object')
    throw new Error('obj must be an object')
  let newObj = new obj.constructor()
  for (let k in obj) {
    if (obj.hasOwnProperty(k)) {
      newObj[k] = typeof obj[k] === 'object' && obj[k] != null ? deepCopy(obj[k]) : obj[k]
    }
  }
  return newObj
}

let newObj = deepCopy(obj) // RangeError: Maximum call stack size exceeded

如果我们直接使用此方法会导致无限循环最后发生栈溢出(爆栈),所以我们需要对此方法进行改造,我们可以使用一个数组来存储已经拷贝过的对象,当我们要拷贝的对象已经存在于数组中时,我们就直接返回该对象,这样就能够解决循环引用的问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
let obj = {
  a: 1,
}

obj.b = obj

function deepCopy(obj, cache = []) {
  if (obj == null || typeof obj !== 'object')
    throw new Error('obj must be an object')
  let hit = cache.filter((c) => c.original === obj)[0]
  if (hit) {
    return hit.copy
  }
  let newObj = new obj.constructor()
  cache.push({
    original: obj,
    copy: newObj,
  })
  for (let k in obj) {
    if (obj.hasOwnProperty(k)) {
      newObj[k] = typeof obj[k] === 'object' && obj[k] != null ? deepCopy(obj[k], cache) : obj[k]
    }
  }
  return newObj
}

let newObj = deepCopy(obj)

console.log(newObj) // <ref *1> { a: 1, b: [Circular *1] }

总结

对于最后一个实现深拷贝的方法其实我们还可以改进一下,对于用户来说,他肯定不会接触到 cache 这个参数,我们可以用闭包将其隐藏起来,这样用户就不用关心这个参数了。

Licensed under CC BY-NC-SA 4.0