轻松拿下 JS 浅拷贝、深拷贝
Offer 驾到,掘友接招!我正在参与2022春招打卡活动,点击查看活动详情。
本文将由浅入深地讲解浅拷贝和深拷贝,知识图谱如下:
深拷贝和浅拷贝的区别?
答:
浅拷贝和深拷贝都是创建一份数据的拷贝。
JS 分为原始类型和引用类型,对于原始类型的拷贝,并没有深浅拷贝的区别,我们讨论的深浅拷贝都只针对引用类型。
-
浅拷贝和深拷贝都复制了值和地址,都是为了解决引用类型赋值后互相影响的问题。
-
但是浅拷贝只进行一层复制,深层次的引用类型还是共享内存地址,原对象和拷贝对象还是会互相影响。
-
深拷贝就是无限层级拷贝,深拷贝后的原对象不会和拷贝对象互相影响。
网络上的很多文章觉得引用类型赋值就是浅拷贝,误导了很多人,但 lodash 中的浅拷贝和深拷贝总不会错吧,这么多项目都在用。
为了验证上述理论的正确性,我们就用 lodash 来测试一下,lodash 中浅拷贝方法为 clone,深拷贝方法为 cloneDeep。
前置知识
两个对象指向同一地址, 用 ==
运算符作比较会返回 true。
两个对象指向不同地址, 用 ==
运算符作比较会返回 false。
const obj = {}
const newObj = obj
console.log(obj == newObj) // true
复制代码
const obj = {}
const newObj = {}
console.log(obj == newObj) // false
复制代码
引用类型互相赋值
直接赋值,两个对象指向同一地址,就会造成引用类型之间互相影响的问题:
const obj = {
name: 'lin'
}
const newObj = obj
obj.name = 'xxx' // 改变原来的对象
console.log('原来的对象', obj)
console.log('新的对象', newObj)
console.log('两者指向同一地址', obj == newObj)
复制代码
使用浅拷贝
使用 lodash 浅拷贝 clone 方法,让他们俩指向不同地址,即可解决这个问题:
import { clone } from 'lodash'
const obj = {
name: 'lin'
}
const newObj = clone(obj)
obj.name = 'xxx' // 改变原来的对象
console.log('原来的对象', obj)
console.log('新的对象', newObj)
console.log('两者指向同一地址', obj == newObj)
复制代码
但是浅拷贝只能解决一层,更深层的对象还是会指向同一地址,互相影响:
import { clone } from 'lodash'
const obj = {
person: {
name: 'lin'
}
}
const newObj = clone(obj)
obj.person.name = 'xxx' // 改变原来的对象
console.log('原来的对象', obj)
console.log('新的对象', newObj)
console.log('更深层的指向同一地址', obj.person == newObj.person)
复制代码
使用深拷贝
这个时候,就需要使用深拷贝来解决:
import { cloneDeep } from 'lodash'
const obj = {
person: {
name: 'lin'
}
}
const newObj = cloneDeep(obj)
obj.person.name = 'xxx' // 改变原来的对象
console.log('原来的对象', obj)
console.log('新的对象', newObj)
console.log('更深层的对象指向同一地址', obj.person == newObj.person)
复制代码
理论验证完了,接下来我们就来实现浅拷贝和深拷贝。
实现浅拷贝
Object.assign
const obj = {
name: 'lin'
}
const newObj = Object.assign({}, obj)
obj.name = 'xxx' // 改变原来的对象
console.log(newObj) // { name: 'lin' } 新对象不变
console.log(obj == newObj) // false 两者指向不同地址
复制代码
数组的 slice 和 concat 方法
const arr = ['lin', 'is', 'handsome']
const newArr = arr.slice(0)
arr[2] = 'rich' // 改变原来的数组
console.log(newArr) // ['lin', 'is', 'handsome']
console.log(arr == newArr) // false 两者指向不同地址
复制代码
const arr = ['lin', 'is', 'handsome']
const newArr = [].concat(arr)
arr[2] = 'rich' // 改变原来的数组
console.log(newArr) // ['lin', 'is', 'handsome'] // 新数组不变
console.log(arr == newArr) // false 两者指向不同地址
复制代码
数组静态方法 Array.from
const arr = ['lin', 'is', 'handsome']
const newArr = Array.from(arr)
arr[2] = 'rich' // 改变原来的数组
console.log(newArr) // ['lin', 'is', 'handsome']
console.log(arr == newArr) // false 两者指向不同地址
复制代码
扩展运算符
const arr = ['lin', 'is', 'handsome']
const newArr = [...arr]
arr[2] = 'rich' // 改变原来的数组
console.log(newArr) // ['lin', 'is', 'handsome'] // 新数组不变
console.log(arr == newArr) // false 两者指向不同地址
复制代码
const obj = {
name: 'lin'
}
const newObj = { ...obj }
obj.name = 'xxx' // 改变原来的对象
console.log(newObj) // { name: 'lin' } // 新对象不变
console.log(obj == newObj) // false 两者指向不同地址
复制代码
实现深拷贝
要求:
- 支持对象、数组、日期、正则的拷贝。
- 处理原始类型(原始类型直接返回,只有引用类型才有深拷贝这个概念)。
- 处理 Symbol 作为键名的情况。
- 处理函数(函数直接返回,拷贝函数没有意义,两个对象使用内存中同一个地址的函数,问题不大)。
- 处理 DOM 元素(DOM 元素直接返回,拷贝 DOM 元素没有意义,都是指向页面中同一个)。
- 额外开辟一个储存空间 WeakMap,解决循环引用递归爆栈问题(引入 WeakMap 的另一个意义,配合垃圾回收机制,防止内存泄漏)。
先把答案贴出来:
function deepClone (target, hash = new WeakMap()) { // 额外开辟一个存储空间WeakMap来存储当前对象
if (target === null) return target // 如果是 null 就不进行拷贝操作
if (target instanceof Date) return new Date(target) // 处理日期
if (target instanceof RegExp) return new RegExp(target) // 处理正则
if (target instanceof HTMLElement) return target // 处理 DOM元素
if (typeof target !== 'object') return target // 处理原始类型和函数 不需要深拷贝,直接返回
// 是引用类型的话就要进行深拷贝
if (hash.get(target)) return hash.get(target) // 当需要拷贝当前对象时,先去存储空间中找,如果有的话直接返回
const cloneTarget = new target.constructor() // 创建一个新的克隆对象或克隆数组
hash.set(target, cloneTarget) // 如果存储空间中没有就存进 hash 里
Reflect.ownKeys(target).forEach(key => { // 引入 Reflect.ownKeys,处理 Symbol 作为键名的情况
cloneTarget[key] = deepClone(target[key], hash) // 递归拷贝每一层
})
return cloneTarget // 返回克隆的对象
}
复制代码
测试一下:
const obj = {
a: true,
b: 100,
c: 'str',
d: undefined,
e: null,
f: Symbol('f'),
g: {
g1: {} // 深层对象
},
h: [], // 数组
i: new Date(), // Date
j: /abc/, // 正则
k: function () {}, // 函数
l: [document.getElementById('foo')] // 引入 WeakMap 的意义,处理可能被清除的 DOM 元素
}
obj.obj = obj // 循环引用
const name = Symbol('name')
obj[name] = 'lin' // Symbol 作为键
const newObj = deepClone(obj)
console.log(newObj)
复制代码
接下来,我们一步一步拆解,如何写出这个深拷贝。
前置知识
要手写出一个还算不错的深拷贝,下面这些知识都可以用到。
觉得概念多,没关系,阿林会带你一步一步慢慢熟悉的。
一行代码版本
首先是一行代码版本:
JSON.parse(JSON.stringify(obj))
复制代码
const obj = {
person: {
name: 'lin'
}
}
const newObj = JSON.parse(JSON.stringify(obj))
obj.person.name = 'xxx' // 改变原来的深层对象
console.log(newObj) // { person: { name: 'lin' } } 新的深层对象不变
复制代码
但是这种方式存在弊端,会忽略undefined
、symbol
和函数
:
const obj = {
a: undefined,
b: Symbol('b'),
c: function () {}
}
const newObj = JSON.parse(JSON.stringify(obj))
console.log(newObj) // {}
复制代码
NaN
、Infinity
、-Infinity
会被序列化为 null
:
const obj = {
a: NaN,
b: Infinity,
c: -Infinity
}
const newObj = JSON.parse(JSON.stringify(obj))
console.log(newObj)
复制代码
而且还不能解决循环引用的问题:
const obj = {
a: 1
}
obj.obj = obj
const newObj = JSON.parse(JSON.stringify(obj)) // 报错
复制代码
这种一行代码版本适用于日常开发中深拷贝一些简单的对象,接下来,我们试着一步步深入手写一个深拷贝,处理各种边界问题。
先实现一个浅拷贝
function clone (obj) {
const cloneObj = {} // 创建一个新的对象
for (const key in obj) { // 遍历需克隆的对象
cloneObj[key] = obj[key] // 将需要克隆对象的属性依次添加到新对象上
}
return cloneObj
}
复制代码
浅拷贝之后,原对象和克隆对象更深层的对象指向同一地址,会互相影响。
测试一下,
const obj = {
person: {
name: 'lin'
}
}
const newObj = clone(obj)
obj.person.name = 'xxx' // 改变原来的对象
console.log('原来的对象', obj)
console.log('新的对象', newObj)
console.log('更深层的指向同一地址', obj.person == newObj.person)
复制代码
简单版本
现在用递归来实现深拷贝,让原对象和克隆对象不互相影响。
function deepClone (target) {
if (typeof target !== 'object') { // 如果是原始类型,无需继续拷贝,直接返回
return target
}
// 如果是引用类型,递归实现每一层的拷贝
const cloneTarget = {} // 定义一个克隆对象
for (const key in target) { // 遍历原对象
cloneTarget[key] = deepClone(target[key]) // 递归拷贝每一层
}
return cloneTarget // 返回克隆对象
}
复制代码
测试一下:
const obj = {
person: {
name: 'lin'
}
}
const newObj = deepClone(obj)
obj.person.name = 'xxx' // 改变原来的对象
console.log('原来的对象', obj)
console.log('新的对象', newObj)
console.log('更深层的指向同一地址', obj.person == newObj.person)
复制代码
处理数组、日期、正则、null
上文的方法实现了最简单版本的深拷贝,但是没有处理 null 这种原始类型,也没有处理数组、日期和正则这种比较常用的引用类型。
测试一下,
const obj = {
a: [],
b: new Date(),
c: /abc/,
d: null
}
const newObj = deepClone(obj)
console.log('原来的对象', obj)
console.log('新的对象', newObj)
复制代码
现在来处理一下:
function deepClone (target) {
if (target === null) return target // 处理 null
if (target instanceof Date) return new Date(target) // 处理日期
if (target instanceof RegExp) return new RegExp(target) // 处理正则
if (typeof target !== 'object') return target // 处理原始类型
// 处理对象和数组
const cloneTarget = new target.constructor() // 创建一个新的克隆对象或克隆数组
for (const key in target) { // 递归拷贝每一层
cloneTarget[key] = deepClone(target[key])
}
return cloneTarget
}
复制代码
测试一下:
const obj = {
a: [1, 2, 3],
b: new Date(),
c: /abc/,
d: null
}
const newObj = deepClone(obj)
console.log('原来的对象', obj)
console.log('新的对象', newObj)
复制代码
new 实例.constructor()
你可能注意到了这样一行代码,它是怎样处理数组的呢?
const cloneTarget = new target.constructor() // 创建一个新的克隆对象或克隆数组
复制代码
实例的 constructor 其实就是构造函数,
class Person {}
const p1 = new Person()
console.log(p1.constructor === Person) // true
console.log([].constructor === Array) // true
console.log({}.constructor === Object) // true
复制代码
console.log(new {}.constructor()) // {}
等价于
console.log(new Object()) // {}
复制代码
console.log(new [].constructor()) // {}
等价于
console.log(new Array()) // []
复制代码
运用在我们的深拷贝函数里,就不用在拷贝时去判断数组类型了,原对象是对象,就创建一个新的克隆对象,原对象是数组,就创建一个新的克隆数组。
处理 Symbol
上面的方法无法处理 Symbol
作为键,测试一下。
const obj = {}
const name = Symbol('name')
obj[name] = 'lin' // Symbol 作为键
const newObj = deepClone(obj)
console.log(newObj) // {}
复制代码
可以把 for in
换成 Reflect.ownKeys
来解决
Reflect.ownKeys
方法返回一个由目标对象自身的属性键组成的数组。它的返回值等同于Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target))。
继续改造克隆函数,
function deepClone (target) {
if (target === null) return target
if (target instanceof Date) return new Date(target)
if (target instanceof RegExp) return new RegExp(target)
if (typeof target !== 'object') return target
const cloneTarget = new target.constructor()
// 换成 Reflect.ownKeys
Reflect.ownKeys(target).forEach(key => {
cloneTarget[key] = deepClone(target[key]) // 递归拷贝每一层
})
return cloneTarget
}
复制代码
测试一下,
const obj = {}
const name = Symbol('name')
obj[name] = 'lin'
const newObj = deepClone(obj)
console.log(newObj)
复制代码
处理循环引用
上面的方法无法处理循环引用,测试一下。
const obj = {
a: 1
}
obj.obj = obj
const newObj = deepClone(obj)
复制代码
原因是对象存在循环引用的情况,递归进入死循环导致栈内存溢出了。
解决循环引用问题,可以额外开辟一个存储空间来存储当前对象和拷贝对象的对应关系。
当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,就不会一直递归导致栈内存溢出了。
function deepClone (target, hash = {}) { // 额外开辟一个存储空间来存储当前对象和拷贝对象的对应关系
if (target === null) return target
if (target instanceof Date) return new Date(target)
if (target instanceof RegExp) return new RegExp(target)
if (typeof target !== 'object') return target
if (hash[target]) return hash[target] // 当需要拷贝当前对象时,先去存储空间中找,如果有的话直接返回
const cloneTarget = new target.constructor()
hash[target] = cloneTarget // 如果存储空间中没有就存进存储空间 hash 里
Reflect.ownKeys(target).forEach(key => {
cloneTarget[key] = deepClone(target[key], hash) // 递归拷贝每一层
})
return cloneTarget
}
复制代码
测试一下,
const obj = {
a: 1
}
obj.obj = obj
const newObj = deepClone(obj)
console.log('原来的对象', obj)
console.log('新的对象', newObj)
复制代码
上面的方法我们使用的是对象来创建的存储空间,这个存储空间还可以用 Map
和 WeakMap
,这里优化一下,使用 WeakMap
,配合垃圾回收机制,防止内存泄漏。
WeakMap
WeakMap
结构与Map
结构类似,用于生成键值对的集合,除了以下两点和 Map
不同,其他都和 Map
相同
WeakMap
只接受对象作为键名(null
除外),不接受其他类型的值作为键名。
const map = new WeakMap()
map.set(1, 2) // TypeError: 1 is not an object!
map.set(Symbol(), 2) // TypeError: Invalid value used as weak map key
map.set(null, 2) // TypeError: Invalid value used as weak map key
复制代码
WeakMap
的键名所指向的对象,不计入垃圾回收机制。
WeakMap
的设计目的在于,有时我们想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用。请看下面的例子。
const e1 = document.getElementById('foo')
const e2 = document.getElementById('bar')
const arr = [
[e1, 'foo 元素'],
[e2, 'bar 元素']
]
复制代码
上面代码中,e1
和e2
是两个对象,我们通过arr
数组对这两个对象添加一些文字说明。这就形成了arr
对e1
和e2
的引用。
一旦不再需要这两个对象,我们就必须手动删除这个引用,否则垃圾回收机制就不会释放e1
和e2
占用的内存。
// 不需要 e1 和 e2 的时候
// 必须手动删除引用
arr[0] = null
arr[1] = null
复制代码
上面这样的写法显然很不方便。一旦忘了写,就会造成内存泄露。
WeakMap
就是为了解决这个问题而诞生的,它的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap
里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。
基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap
。一个典型应用场景是,在网页的 DOM
元素上添加数据,就可以使用WeakMap
结构。当该 DOM
元素被清除,其所对应的WeakMap
记录就会自动被移除。
const wm = new WeakMap()
const element = document.getElementById('example')
wm.set(element, 'some information')
wm.get(element) // "some information"
复制代码
上面代码中,先新建一个 WeakMap
实例。然后,将一个 DOM
节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap
里面。这时,WeakMap
里面对element
的引用就是弱引用,不会被计入垃圾回收机制。
也就是说,上面的 DOM
节点对象的引用计数是1
,而不是2
。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。WeakMap
保存的这个键值对,也会自动消失。
总之,WeakMap
的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap
结构有助于防止内存泄漏。
引入 WeakMap
了解了 WeakMap
的作用,我们来继续优化深拷贝函数,
存储空间把对象改成 WeakMap,WeakMap 主要是为了处理经常被删除的 DOM 元素,在深拷贝函数里也加入对 DOM 元素的处理。
如果拷贝对象是 DOM 元素就直接返回,拷贝 DOM 元素没有意义,都是指向页面中同一个。
function deepClone (target, hash = new WeakMap()) { // 额外开辟一个存储空间WeakMap来存储当前对象
if (target === null) return target
if (target instanceof Date) return new Date(target)
if (target instanceof RegExp) return new RegExp(target)
if (target instanceof HTMLElement) return target // 处理 DOM元素
if (typeof target !== 'object') return target
if (hash.get(target)) return hash.get(target) // 当需要拷贝当前对象时,先去存储空间中找,如果有的话直接返回
const cloneTarget = new target.constructor()
hash.set(target, cloneTarget) // 如果存储空间中没有就存进 hash 里
Reflect.ownKeys(target).forEach(key => {
cloneTarget[key] = deepClone(target[key], hash) // 递归拷贝每一层
})
return cloneTarget
}
复制代码
测试一下,
const obj = {
domArr: [document.getElementById('foo')]
}
const newObj = deepClone(obj)
console.log(newObj)
复制代码
至此,在面试场景中手写一个深拷贝,差不多到天花板了,毕竟面试的环境10分钟写一段代码,能把上面的功能写出来,已经很厉害了。
至于其他边界情况,就靠嘴皮子去说吧。
更多边界情况
其实上面的深拷贝方法还有很多缺陷,有很多类型对象都没有实现拷贝,毕竟 JS 的标准内置对象实在太多了,要考虑所有的边界情况,就会让深拷贝函数变得特别复杂。
我们花了 14 行实现了一个还算不错的深拷贝,但lodash
里的拷贝函数光是定义数据类型就超过 14 行了。
/** `Object#toString` result references. */
const argsTag = '[object Arguments]'
const arrayTag = '[object Array]'
const boolTag = '[object Boolean]'
const dateTag = '[object Date]'
const errorTag = '[object Error]'
const mapTag = '[object Map]'
const numberTag = '[object Number]'
const objectTag = '[object Object]'
const regexpTag = '[object RegExp]'
const setTag = '[object Set]'
const stringTag = '[object String]'
const symbolTag = '[object Symbol]'
const weakMapTag = '[object WeakMap]'
const arrayBufferTag = '[object ArrayBuffer]'
const dataViewTag = '[object DataView]'
const float32Tag = '[object Float32Array]'
const float64Tag = '[object Float64Array]'
const int8Tag = '[object Int8Array]'
const int16Tag = '[object Int16Array]'
const int32Tag = '[object Int32Array]'
const uint8Tag = '[object Uint8Array]'
const uint8ClampedTag = '[object Uint8ClampedArray]'
const uint16Tag = '[object Uint16Array]'
const uint32Tag = '[object Uint32Array]'
复制代码
工具函数方法,就封装了二十多个,一个深拷贝的函数代码加起来可能有接近千行了。
import Stack from './Stack.js'
import arrayEach from './arrayEach.js'
import assignValue from './assignValue.js'
import cloneBuffer from './cloneBuffer.js'
import copyArray from './copyArray.js'
import copyObject from './copyObject.js'
import cloneArrayBuffer from './cloneArrayBuffer.js'
import cloneDataView from './cloneDataView.js'
import cloneRegExp from './cloneRegExp.js'
import cloneSymbol from './cloneSymbol.js'
import cloneTypedArray from './cloneTypedArray.js'
import copySymbols from './copySymbols.js'
import copySymbolsIn from './copySymbolsIn.js'
import getAllKeys from './getAllKeys.js'
import getAllKeysIn from './getAllKeysIn.js'
import getTag from './getTag.js'
import initCloneObject from './initCloneObject.js'
import isBuffer from '../isBuffer.js'
import isObject from '../isObject.js'
import isTypedArray from '../isTypedArray.js'
import keys from '../keys.js'
import keysIn from '../keysIn.js'
复制代码
甚至为了提高性能,lodash内部不用 for in
,也不用 Reflect.ownKeys
来遍历,还专门重写了遍历的方法。
// arrayEach.js
function arrayEach(array, iteratee) {
let index = -1
const length = array.length
while (++index < length) {
if (iteratee(array[index], index, array) === false) {
break
}
}
return array
}
复制代码
阿林也只是看得懂,但讲不好,就贴一些大佬们的文章吧。
日常开发中,如果要使用深拷贝,为了兼容各种边界情况,一般是使用三方库,推荐两个:
未来的深拷贝
其实,浏览器自己实现了深拷贝函数,想不到吧。
这个 Web API 名称叫 structuredClone()
,详情可访问 MDN 和最新的 HTML5 规范
我们来尝尝鲜,试用一下:
const obj = {
person: {
name: 'lin'
}
}
const newObj = structuredClone(obj) //
obj.person.name = 'xxx' // 改变原来的对象
console.log('原来的对象', obj)
console.log('新的对象', newObj)
console.log('更深层的对象指向同一地址', obj.person == newObj.person)
复制代码
深拷贝生效了,那是不是说以后再也不用 lodash 的 cloneDeep 了呢?
很显然,不能,毕竟这是一个新的 API,从兼容性来考虑,很多浏览器应该都不会支持。
去 caniuse 一查,果然如此。
另外, MDN 也介绍了实现这个 API 用到的算法,阿林粗略地看了一下,似乎实现得没有 lodash 全面,链接贴在下面,感兴趣的可以去看看。
可能再过一段时间大家就都使用这个深拷贝了吧,谁知道呢?就跟几年前还在兼容 ie 浏览器,现在没有特殊需求,傻子才去做兼容🙈
总结
关于浅拷贝和深拷贝的使用选择,保险的做法是所有的拷贝都用深拷贝,而且一般是直接引三方库,毕竟自己写深拷贝,各种边界情况有时候考虑不到。
像手写深拷贝这种卷王行为也只会出现在面试场景中了😅,面试场景中能把本文的深拷贝手写出来,并且能说出 lodash 源码的实现思路,也差不多了。
其实,如果只是拷贝一层对象,只要能解决引用类型赋值后相互影响的问题,用浅拷贝又怎么了?
另外,如果 JSON.parse(JSON.stringify(object))
能实现你的功能,你却非要去引入 lodash
的 cloneDeep
方法,那不就徒增了项目的打包体积了吗?自己平时写着玩的项目,用 JSON.parse(JSON.stringify(object))
又怎么了?
当然,如果团队有规范,为了代码风格统一或者说为了避免潜在的风险,统一全部用三方库的方法,增加一些打包的体积也没关系,毕竟企业级的项目还是要严谨一点。
黑猫白猫,能抓到耗子就是好猫,各取所长就行。
如果我的文章对你有帮助,你的👍就是对我的最大支持^_^
我是阿林,输出洞见技术,再会!