轻松拿下 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)) 又怎么了?
当然,如果团队有规范,为了代码风格统一或者说为了避免潜在的风险,统一全部用三方库的方法,增加一些打包的体积也没关系,毕竟企业级的项目还是要严谨一点。
黑猫白猫,能抓到耗子就是好猫,各取所长就行。
如果我的文章对你有帮助,你的👍就是对我的最大支持^_^
我是阿林,输出洞见技术,再会!
浙公网安备 33010602011771号