细说深浅拷贝

本文最后更新于:2021年1月19日 晚上

JavaScript 中数据类型有基本数据类型引用数据类型,我们在开发中经常需要进行数据复制的操作,而深拷贝浅拷贝就是围绕着数据的不同类型来展开的。

浅拷贝的原理与实现

原理

浅拷贝的大致定义:

创建一个新的对象来接收要复制的值。根据对象属性的类型来区分,如果是基本数据类型,就是复制值给新对象;如果是引用数据类型,复制的就是内存中的地址。如果其中一个对象改变了内存中的地址的内容,另外一个对象也会受到影响。

常见的浅拷贝方法

Object.assign

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象分配到目标对象。它将返回目标对象。语法 Object.assign(target, …sources)

—— MDN

简单地理解, ES5Object.assign 方法可以合并多个对象为新对象。这个操作就是一个浅拷贝。

来一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
let target = {}
let source = { a: { aa: 1 } }
Object.assign(target, source)
console.log({
target: target
})
// 再修改一下source.a.aa的值
source.a.aa = 111
console.log({
target: target,
source: source
})

看下输出结果:

从上面的输出结果可以看到,通过 Object.assign 成功地实现了浅拷贝。

不过,根据该方法的定义,我们需要注意几个点:

  • 不会拷贝对象的不可枚举属性
  • 不会拷贝对象的继承属性
  • 可以拷贝 Symbol 类型的属性。

看下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let source = {
a: { aa: 1 },
sbl: Symbol(1)
}
Object.defineProperty(source, 'innumerable' ,{
value: '不可枚举的属性',
enumerable: false
})
let target = {}
Object.assign(target, source)
source.a.aa = 2
console.log({
source: source,
target: target
})

结果:

扩展运算符

还可以利用 ES6 提供的扩展运算符,在构造对象的同时完成浅拷贝。

展开语法(Spread syntax), 可以在函数调用/数组构造时, 将数组表达式或者 string 在语法层面展开;还可以在构造字面量对象时, 将对象表达式按 key-value 的方式展开。

—— MDN

多种场景下的语法:

1
2
3
4
5
6
7
8
// 构造字面量对象
let objClone = { ...obj }

// 字面量数组构造或字符串
[...iterableObj, '4', ...'hello', 6]

// 函数调用
myFunction(...iterableObj)

简单的示例:

1
2
3
4
5
6
7
8
9
10
let obj1 = {
a: 1,
b: {
c: 1
}
}
let obj2 = { ...obj1 }

let arr = [1, 2, 3]
let newArr = [ ...arr ]

扩展运算符和 Object.assign 实现的功能基本一样,同时也存在一样的缺陷。但是很明显用扩展运算符会比较简单便捷。

下面再介绍两种只适用于数组的浅拷贝方法:

Array.prototype.concat()

concat() 方法用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。

语法 var new_array = old_array.concat(value1[, value2[, …[, valueN]]])

—— MDN

简单示例:

1
2
3
4
5
6
let arr = [1, 2, { val: 3 }]
let newArr = arr.concat()
newArr[0] = 111
newArr[2].val = 333
console.log(arr) // [1, 2, { val: 333 }]
console.log(newArr) // [111, 2, { val: 333 }]
Array.prototype.slice()

slice() 方法返回一个新的数组对象,这一对象是一个由 beginend 决定的原数组的浅拷贝(包括 begin,不包括end)。原始数组不会被改变。

语法 arr.slice([begin[, end]])

—— MDN

简单示例:

1
2
3
4
5
6
let arr = [1, 2, { val: 3 }]
let newArr = arr.slice()
newArr[0] = 111
newArr[2].val = 333
console.log(arr) // [1, 2, { val: 333 }]
console.log(newArr) // [111, 2, { val: 333 }]

实现浅拷贝

根据浅拷贝的定义和几个示例的分析,现在可以来实现一个属于自己的浅拷贝:

1
2
3
4
5
6
7
8
9
10
11
12
13
const shadowClone = (source) => {
if (typeof source === 'object' && source !== null) {
const target = Array.isArray(source) ? [] : {}
for (let prop in source) {
if (source.hasOwnProperty(prop)) {
target[prop] = source[prop]
}
}
return target
} else {
return source
}
}

上面的实现思路:先进行数据类型判断,如果不是对象,直接返回;否则遍历 source 对象的属性并赋值给目标对象 target 的属性。

深拷贝的原理与实现

原理

从浅拷贝的定义我们知道浅拷贝对于基本数据类型就是直接复制值,而对于引用数据类型则只是复制了内存地址,从存储内容上来讲并没有独立复制,使其互不影响。那么深拷贝的大致定义就清晰了:

将被拷贝的对象从内存中完整地拷贝一份出来,并在堆内存中开辟新的空间存储拷贝对象,使被拷贝对象与拷贝对象之间互不影响,完全独立。

常用的深拷贝方法

JSON.stringify

JSON.stringify() 方法将一个 JavaScript 对象或值转换为 JSON 字符串,如果指定了一个 replacer 函数,则可以选择性地替换值,或者指定的 replacer 是数组,则可选择性地仅包含数组指定的属性。

—— MDN

简而言之,就是把一个对象序列化成为 JSON 的字符串,并将对象里面的内容转换成字符串,最后用 JSON.parse() 的方法将 JSON 字符串反序列化生成一个新的对象,从而达到深拷贝的效果。

看下示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const obj = {
name: 'Ysom',
age: 18,
hobbies: {
tea: 100,
music: 90,
ball: 80
},
birthday: new Date('1995-01-01'),
job: undefined,
money: null,
sbl: Symbol(18),
getAge () {
return this.age
}
}

const obj2 = JSON.parse(JSON.stringify(obj))
obj.hobbies.ball = 85
console.log({
obj: obj,
obj2: obj2
})

运行结果:

从运行结果可以看到,改变 objhobbies 属性, obj2 这个对象并不受影响。不过从运行结果中,细心的人可能会发现几处异常:

  • 拷贝 Date 引用类型会变成字符串;
  • 拷贝的值中如果有 undefinedSymbol、函数等类型,通过序列化后,这个键值会消失;

那除了上述两种情况,JSON.stringify还有以下几个缺陷:

  • 无法拷贝不可枚举属性
  • 无法拷贝对象的原型链
  • 无法拷贝对象的循环引用,即对象成环;
  • 拷贝 RegExpError 引用类型会变成空对象;
  • 对象中含有 NaNInfinity 以及 -Infinity,序列化的结果会变成 null

对于上述的缺陷,现实开发中并没有很常遇到,JSON.stringify 基本足以应付日常开发所用,所以它算是开发中最常见最便捷的深拷贝方式。

递归实现

除了常见的 JSON.stringify ,在开发中还会经常自己使用递归来进行深拷贝,看示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const obj = {
a: {
b: {
c: 111
}
}
}
function deepClone(obj) {
let cloneObj = {}
for(let key in obj) {
if(typeof obj[key] ==='object') {
cloneObj[key] = deepClone(obj[key])
} else {
cloneObj[key] = obj[key]
}
}
return cloneObj
}

上述例子的思路就是遍历对象的属性,如果属性是对象,那么继续调用 deepClone 方法递归拷贝,如果是基本类型的值,则直接复制。

虽然该方法可以实现简单的深拷贝,也能满足大部分开发场景的需求,但是还是存在一些缺陷:

  • 不能复制不可枚举属性以及 Symbol 类型;
  • 对于 ArrayDateRegExpErrorFunction 等引用类型不能正确拷贝;
  • 还是没有解决循环引用的问题

实现深拷贝

那有没有一个比较完美的深拷贝方案呢?下面来仔细分析下上面深拷贝相应的缺陷以及对应的解决方案。

  • 对于不可枚举的属性以及 Symbol 类型,可以通过 Reflect.ownKeys 方法来处理;
  • 对于 DateErrorRegExp 等特殊类型,则直接生成一个新的实例;
  • 对于对象的原型链,可以通过 getOwnPropertyDescriptors 方法获得对象的所有属性和对应的特性,再结合 Object 的 create 方法创建一个新对象,并继承传入原对象的原型链;
  • 对于循环引用的问题,可以借助 WeakMap 来处理

根据上面的方案来实现一个深拷贝:

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
// 判断数据类型是否是引用类型
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)
}

// 循环引用的处理
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
}

通过上面的处理,基本就实现了一个完善的深拷贝。虽然在实际开发中我们很少遇到需要深拷贝特殊类型的情况,而且业界也有一些成熟的工具库像 loadsh ,但是自己试着去探索一个知识点的原理,并手动去实现该功能,对于知识点的理解巩固和提升编程能力是有很大的帮助的。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!