前言:js如何實(shí)現(xiàn)一個(gè)深拷貝
這是一個(gè)老生常談的問題理张,也是在求職過程中的高頻面試題剩彬,考察的知識點(diǎn)十分豐富浮庐,本文將對淺拷貝和深拷貝的區(qū)別唁奢、實(shí)現(xiàn)等做一個(gè)由淺入深的梳理
賦值霎挟、淺拷貝與深拷貝的區(qū)別
在js中,變量類型分為基本類型和引用類型麻掸。對變量直接進(jìn)行賦值拷貝:
- 對于基本類型酥夭,拷貝的是存儲在棧中的值
- 對于引用類型,拷貝的是存儲在棧中的指針脊奋,指向堆中該引用類型數(shù)據(jù)的真實(shí)地址
直接拷貝引用類型變量熬北,只是復(fù)制了變量的指針地址,二者指向的是同一個(gè)引用類型數(shù)據(jù)诚隙,對其中一個(gè)執(zhí)行操作都會引起另一個(gè)的改變讶隐。
關(guān)于淺拷貝和深拷貝:
- 淺拷貝是對于原數(shù)據(jù)的精確拷貝,如果子數(shù)據(jù)為基本類型久又,則拷貝值巫延;如果為引用類型,則拷貝地址地消,二者共享內(nèi)存空間炉峰,對其中一個(gè)修改也會影響另一個(gè)
- 深拷貝則是開辟新的內(nèi)存空間,對原數(shù)據(jù)的完全復(fù)制
因此脉执,淺拷貝與深拷貝根本上的區(qū)別是 是否共享內(nèi)存空間 疼阔。簡單來講,深拷貝就是對原數(shù)據(jù)遞歸進(jìn)行淺拷貝。
三者的簡單比較如下:
是否指向原數(shù)據(jù) | 子數(shù)據(jù)為基本類型 | 子數(shù)據(jù)包含引用類型 | |
---|---|---|---|
賦值 | 是 | 改變時(shí)原數(shù)據(jù)改變 | 改變時(shí)原數(shù)據(jù)改變 |
淺拷貝 | 否 | 改變時(shí)原數(shù)據(jù) 不改變 | 改變時(shí)原數(shù)據(jù)改變 |
深拷貝 | 否 | 改變時(shí)原數(shù)據(jù) 不改變 | 改變時(shí)原數(shù)據(jù) 不改變 |
原生淺拷貝方法
數(shù)組和對象中常見的淺拷貝方法有以下幾種:
- Array.prototype.slice
- Array.prototype.concat
- Array.from<br />
- Object.assign
- ES6解構(gòu)
使用下面的 用例 1.test.js 進(jìn)行測試:
const arr = ['test', { foo: 'test' }]
const obj = {
str: 'test',
obj: {
foo: 'test'
}
}
const arr1 = arr.slice()
const arr2 = arr.concat()
const arr3 = Array.from(arr)
const arr4 = [...arr]
const obj1 = Object.assign({}, obj)
const obj2 = {...obj}
//修改arr
arr[0] = 'test1'
arr[1].foo = 'test1'
// 修改obj
obj.str = 'test1'
obj.obj.foo = 'test1'
結(jié)果如下:
可以看到經(jīng)過淺拷貝以后婆廊,我們?nèi)バ薷脑瓕ο蠡驍?shù)組中的基本類型數(shù)據(jù)迅细,拷貝后的相應(yīng)數(shù)據(jù)未發(fā)生改變;而修改原對象或數(shù)組中的引用類型數(shù)據(jù)否彩,拷貝后的數(shù)據(jù)會發(fā)生相應(yīng)變化疯攒,它們共享同一內(nèi)存空間
深拷貝實(shí)現(xiàn)
這里我們列舉常見的深拷貝方法并嘗試自己手動(dòng)實(shí)現(xiàn),最后對它們做一個(gè)總結(jié)列荔、比較
1. JSON序列化快速實(shí)現(xiàn)
使用 JSON.parse(JSON.stringify(data))
來實(shí)現(xiàn)深拷貝敬尺,這種方法基本可以涵蓋90%的使用場景,但它也有其不足之處贴浙,涉及到下面這幾種情況下時(shí)則需要考慮使用其他方法來實(shí)現(xiàn)深拷貝:
-
JSON.parse
只能序列化能夠被處理為JSON格式的數(shù)據(jù)砂吞,因此無法處理以下數(shù)據(jù)- 特殊數(shù)據(jù)例如
undefined
、NaN
崎溃、Infinity
等 - 特殊對象如時(shí)間對象蜻直、正則表達(dá)式、函數(shù)袁串、Set概而、Map等
- 對于循環(huán)引用(例如環(huán))等無法處理,會直接報(bào)錯(cuò)
- 特殊數(shù)據(jù)例如
-
JSON.parse
只能序列化對象可枚舉的自身屬性囱修,因此會丟棄構(gòu)造函數(shù)的constructor
使用下面的 用例 2.test.js 來對基本類型進(jìn)行驗(yàn)證:
const data = {
a: 1,
b: 'str',
c: true,
d: null,
e: undefined,
f: NaN,
g: Infinity,
}
const dataCopy = JSON.parse(JSON.stringify(data))
可以看到 NaN
赎瑰、 Infinity
在序列化的過程中被轉(zhuǎn)化為了 null
,而 undefined
則丟失了:
再使用 用例 3.test.js 對引用類型進(jìn)行測試:
const data = {
a: [1, 2, 3],
b: {foo: 'obj'},
c: new Date('2019-08-28'),
d: /^abc$/g,
e: function() {},
f: new Set([1, 2, 3]),
g: new Map([['foo', 'map']]),
}
const dataCopy = JSON.parse(JSON.stringify(data))
對于引用類型數(shù)據(jù)破镰,在序列化與反序列化過程中餐曼,只有數(shù)組和對象被正常拷貝鲜漩,其中時(shí)間對象被轉(zhuǎn)化為了字符串源譬,函數(shù)會丟失,其他的都被轉(zhuǎn)化為了空對象:
利用 用例 4.test.js 對構(gòu)造函數(shù)進(jìn)行驗(yàn)證:
function Person(name) {
// 構(gòu)造函數(shù)實(shí)例屬性name
this.name = name
// 構(gòu)造函數(shù)實(shí)例方法getName
this.getName = function () {
return this.name
}
}
// 構(gòu)造函數(shù)原型屬性age
Person.prototype.age = 18
const person = new Person('xxx')
const personCopy = JSON.parse(JSON.stringify(person))
在拷貝過程中只會序列化對象可枚舉的自身屬性孕似,因此無法拷貝 Person
上的原型屬性 age
踩娘;由于序列化的過程中構(gòu)造函數(shù)會丟失,所以 personCopy
的 constructor
會指向頂層的原生構(gòu)造函數(shù) Object
而不是自定義構(gòu)造函數(shù)Person
2. 手動(dòng)實(shí)現(xiàn)深拷貝方法
簡單版
我們先來實(shí)現(xiàn)一個(gè)簡單版的深拷貝喉祭,思路是养渴,判斷data類型,若不是引用類型臂拓,直接返回;如果是引用類型习寸,然后判斷data是數(shù)組還是對象胶惰,并對data進(jìn)行遞歸遍歷,如下:
function cloneDeep(data) {
if(!data || typeof data !== 'object') return data
const retVal = Array.isArray(data) ? [] : {}
for(let key in data) {
retVal[key] = cloneDeep(data[key])
}
return retVal
}
執(zhí)行 用例 clone1.test.js :
const data = {
str: 'test',
obj: {
foo: 'test'
},
arr: ['test', {foo: 'test'}]
}
const dataCopy = cloneDeep(data)
可以看到對于對象和數(shù)組能夠?qū)崿F(xiàn)正確的拷貝
首先是只考慮了對象和數(shù)組這兩種類型霞溪,其他引用類型數(shù)據(jù)依然與原數(shù)據(jù)共享同一內(nèi)存空間孵滞,有待完善中捆;其次,對于自定義的構(gòu)造函數(shù)而言坊饶,在拷貝的過程中會丟失實(shí)例對象的 constructor
泄伪,因此其構(gòu)造函數(shù)會變?yōu)槟J(rèn)的 Object
處理其他數(shù)據(jù)類型
在上一步我們實(shí)現(xiàn)的簡單深拷貝,只考慮了對象和數(shù)組這兩種引用類型數(shù)據(jù)匿级,接下來將對其他常用數(shù)據(jù)結(jié)構(gòu)進(jìn)行相應(yīng)的處理
定義通用方法
我們首先定義一個(gè)方法來正確獲取數(shù)據(jù)的類型蟋滴,這里利用了 Object
原型對象上的 toString
方法,它返回的值為 [object type]
痘绎,我們截取其中的type即可津函。然后定義了數(shù)據(jù)類型集合的常量,如下:
const getType = (data) => {
return Object.prototype.toString.call(data).slice(8, -1)
}
const TYPE = {
Object: 'Object',
Array: 'Array',
Date: 'Date',
RegExp: 'RegExp',
Set: 'Set',
Map: 'Map',
}
主函數(shù)實(shí)現(xiàn)
接著我們完善對于其他類型的處理孤页,根據(jù)不同的 data 類型尔苦,對 data 進(jìn)行不同的初始化操作,然后進(jìn)行相應(yīng)的遞歸遍歷行施,如下:
const cloneDeep = (data) => {
if (!data || typeof data !== 'object') return data
let cloneData = data
const Constructor = data.constructor;
const dataType = getType(data)
// data 初始化
if (dataType === TYPE.Array) {
cloneData = []
} else if (dataType === TYPE.Object) {
// 獲取原對象的原型
cloneData = Object.create(Object.getPrototypeOf(data))
} else if (dataType === TYPE.Date) {
cloneData = new Constructor(data.getTime())
} else if (dataType === TYPE.RegExp) {
const reFlags = /\w*$/
// 特殊處理regexp允坚,拷貝過程中l(wèi)astIndex屬性會丟失
cloneData = new Constructor(data.source, reFlags.exec(data))
cloneData.lastIndex = data.lastIndex
} else if (dataType === TYPE.Set || dataType === TYPE.Map) {
cloneData = new Constructor()
}
// 遍歷 data
if (dataType === TYPE.Set) {
for (let value of data) {
cloneData.add(cloneDeep(value))
}
} else if (dataType === TYPE.Map) {
for (let [mapKey, mapValue] of data) {
// Map的鍵、值都可以是引用類型蛾号,因此都需要拷貝
cloneData.set(cloneDeep(mapKey), cloneDeep(mapValue))
}
} else {
for (let key in data) {
// 不考慮繼承的屬性
if (data.hasOwnProperty(key)) {
cloneData[key] = cloneDeep(data[key])
}
}
}
return cloneData
}
上面的代碼完整版可以參考 clone2.js 稠项,接下來使用 用例 clone2.test.js 進(jìn)行驗(yàn)證:
const data = {
obj: {},
arr: [],
reg: /reg/g,
date: new Date('2019'),
person: new Person('lixx'),
set: new Set([{test: 'set'}]),
map: new Map([[{key: 'map'}, {value: 'map'}]])
}
function Person(name) {
this.name = name
}
const dataClone = cloneDeep(data)
可以看到對于不同類型的引用數(shù)據(jù)都能夠?qū)崿F(xiàn)正確拷貝,結(jié)果如下:
關(guān)于函數(shù)
函數(shù)的拷貝我這里沒有實(shí)現(xiàn)须教,兩個(gè)對象中的函數(shù)使用同一個(gè)內(nèi)存空間并沒有什么問題皿渗。實(shí)際上,查看了 lodash/cloneDeep
的相關(guān)實(shí)現(xiàn)后轻腺,對于函數(shù)它是直接返回的:
到這一步乐疆,我們的深拷貝方法已經(jīng)初具雛形,實(shí)際上需要特殊處理的數(shù)據(jù)類型遠(yuǎn)不止這些贬养,還有 Error
挤土、 Buffer
、 Element
等误算,有興趣的小伙伴可以繼續(xù)探索實(shí)現(xiàn)一下~
處理循環(huán)引用
目前為止深拷貝能夠處理絕大部分常用的數(shù)據(jù)結(jié)構(gòu)仰美,但是當(dāng)數(shù)據(jù)中出現(xiàn)了循環(huán)引用時(shí)它就束手無策了
const a = {}
a.a = a
cloneDeep(a)
可以看到,對于循環(huán)引用儿礼,在進(jìn)行遞歸調(diào)用的時(shí)候會變成死循環(huán)而導(dǎo)致棧溢出:
那么如何破解呢咖杂?
拋開循環(huán)引用不談,我們先來看看基本的 引用 問題蚊夫,前文所實(shí)現(xiàn)的深拷貝方法以及 JSON
序列化拷貝都會解除原引用類型對于其他數(shù)據(jù)的引用诉字,來看下面這個(gè)例子:
const temp = {}
const data = {
a: temp,
b: temp,
}
const dataJson = JSON.parse(JSON.stringify(data))
const dataClone = cloneDeep(data)
驗(yàn)證一下引用關(guān)系:
如果解除這種引用關(guān)系是你想要的,那完全ok。如果你想保持?jǐn)?shù)據(jù)之間的引用關(guān)系壤圃,那么該如何去實(shí)現(xiàn)呢陵霉?
一種做法是可以用一個(gè)數(shù)據(jù)結(jié)構(gòu)將已經(jīng)拷貝過的內(nèi)容存儲起來,然后在每次拷貝之前進(jìn)行查詢伍绳,如果發(fā)現(xiàn)已經(jīng)拷貝過了踊挠,直接返回存儲的拷貝值即可保持原有的引用關(guān)系。
因?yàn)槟軌虮徽_拷貝的數(shù)據(jù)均為引用類型冲杀,所以我們需要一個(gè) key-value
且 key
可以是引用類型的數(shù)據(jù)結(jié)構(gòu)效床,自然想到可以利用 Map/WeakMap
來實(shí)現(xiàn)。
這里我們利用一個(gè) WeakMap
的數(shù)據(jù)結(jié)構(gòu)來保存已經(jīng)拷貝過的結(jié)構(gòu)漠趁, WeakMap
與 Map
最大的不同扁凛,就是它的鍵是弱引用的,它對于值的引用不計(jì)入垃圾回收機(jī)制闯传,也就是說谨朝,當(dāng)其他引用都解除時(shí),垃圾回收機(jī)制會釋放該對象的內(nèi)存甥绿;假如使用強(qiáng)引用的 Map
字币,除非手動(dòng)解除引用,否則這部分內(nèi)存不會得到釋放共缕,容易造成內(nèi)存泄漏洗出。
具體的實(shí)現(xiàn)如下:
const cloneDeep = (data, hash = new WeakMap()) => {
if (!data || typeof data !== 'object') return data
// 查詢是否已拷貝
if(hash.has(data)) return hash.get(data)
let cloneData = data
const Constructor = data.constructor;
const dataType = getType(data)
// data 初始化
if (dataType === TYPE.Array) {
cloneData = []
} else if (dataType === TYPE.Object) {
// 獲取原對象的原型
cloneData = Object.create(Object.getPrototypeOf(data))
} else if (dataType === TYPE.Date) {
cloneData = new Constructor(data.getTime())
} else if (dataType === TYPE.RegExp) {
const reFlags = /\w*$/
// 特殊處理regexp,拷貝過程中l(wèi)astIndex屬性會丟失
cloneData = new Constructor(data.source, reFlags.exec(data))
cloneData.lastIndex = data.lastIndex
} else if (dataType === TYPE.Set || dataType === TYPE.Map) {
cloneData = new Constructor()
}
// 寫入 hash
hash.set(data, cloneData)
// 遍歷 data
if (dataType === TYPE.Set) {
for (let value of data) {
cloneData.add(cloneDeep(value, hash))
}
} else if (dataType === TYPE.Map) {
for (let [mapKey, mapValue] of data) {
// Map的鍵图谷、值都可以是引用類型翩活,因此都需要拷貝
cloneData.set(cloneDeep(mapKey, hash), cloneDeep(mapValue, hash))
}
} else {
for (let key in data) {
// 不考慮繼承的屬性
if (data.hasOwnProperty(key)) {
cloneData[key] = cloneDeep(data[key], hash)
}
}
}
return cloneData
}
經(jīng)過改造后的深拷貝函數(shù)能夠保留原數(shù)據(jù)的引用關(guān)系,也可以正確處理不同引用類型的循環(huán)引用,利用下面的用例 clone3.test.js 來進(jìn)行驗(yàn)證:
const temp = {}
const data = {
a: temp,
b: temp,
}
const dataClone = cloneDeep(data)
const obj = {}
obj.obj = obj
const arr = []
arr[0] = arr
const set = new Set()
set.add(set)
const map = new Map()
map.set(map, map)
結(jié)果如下:
思考:使用非遞歸
在前面的深拷貝實(shí)現(xiàn)方法中,均是通過遞歸的方式來進(jìn)行遍歷谣沸,當(dāng)遞歸的層級過深時(shí)妄壶,也會出現(xiàn)棧溢出的情況落恼,我們使用下面的 create
方法創(chuàng)建深度為10000,廣度為100的示例數(shù)據(jù):
function create(depth, breadth) {
const data = {}
let temp = data
let i = j = 0
while(i < depth) {
temp = temp['data'] = {}
while(j < breadth) {
temp[j] = j
j++
}
i++
}
return data
}
const data = create(10000, 100)
cloneDeep(data)
結(jié)果如下:
那么假如不使用遞歸,我們應(yīng)該如何實(shí)現(xiàn)呢?
以對象為例隘梨,存在下面這樣一個(gè)數(shù)據(jù)結(jié)構(gòu):
const data = {
left: 1,
right: {
left: 1,
right: 2,
}
}
那么換個(gè)角度看,其實(shí)它就是一個(gè)類樹形結(jié)構(gòu):
我們對該對象進(jìn)行遍歷實(shí)際上相當(dāng)于模擬對樹的遍歷舷嗡。樹的遍歷主要分為深度優(yōu)先遍歷和廣度優(yōu)先遍歷轴猎,前者一般借助棧來實(shí)現(xiàn),后者一般借助隊(duì)列來實(shí)現(xiàn)进萄。
這里模擬了樹的深度優(yōu)先遍歷捻脖,僅考慮對象和非對象烦秩,利用棧來實(shí)現(xiàn)一個(gè)不使用遞歸的簡單深拷貝方法:
function cloneDeep(data) {
const retVal = {}
const stack = [{
target: retVal,
source: data,
}]
// 循環(huán)整個(gè)stack
while(stack.length > 0) {
// 棧頂節(jié)點(diǎn)出棧
const node = stack.pop()
const { target, source } = node
// 遍歷當(dāng)前節(jié)點(diǎn)
for(let item in source) {
if (source.hasOwnProperty(item)) {
if (Object.prototype.toString.call(source[item]) === '[object Object]') {
target[item] = {}
// 子節(jié)點(diǎn)如果是對象,將該節(jié)點(diǎn)入棧
stack.push({
target: target[item],
source: source[item],
})
} else {
// 子節(jié)點(diǎn)如果不是對象郎仆,直接拷貝
target[item] = source[item]
}
}
}
}
return retVal
}
關(guān)于完整的深拷貝非遞歸實(shí)現(xiàn),可以參考 clone4.js 兜蠕,對應(yīng)的測試用例為 用例 clone4.test.js 扰肌,這里就不給出了
3. 深拷貝方法比較
這里列舉了常見的幾種深拷貝方法,并進(jìn)行簡單比較
- JSON.parse(JSON.stringify(data))
- jQuery中的$.extend
- 我們這里自己實(shí)現(xiàn)的clone3.js中的cloneDeep
- loadsh中的_.cloneDeep
關(guān)于耗時(shí)比較熊杨,采用前文的 create
方法創(chuàng)建了一個(gè)廣度曙旭、深度均為1000的數(shù)據(jù),在 node v10.14.2
環(huán)境下循環(huán)執(zhí)行以下方法各10000次晶府,這里的耗時(shí)取值為運(yùn)行十次測試用例的平均值桂躏,如下:
基本類型 | 數(shù)組、對象 | 特殊引用類型 | 循環(huán)引用 | 耗時(shí) | |
---|---|---|---|---|---|
JSON | 無法處理 NaN 川陆、 Infinity 剂习、 Undefined
|
丟失對象原型 | ? | ? | 7280.6ms |
$.extend | 無法處理 Undefined
|
丟失對象原型、拷貝原型屬性 | ?<br />(使用同一引用) | ? | 5550.6ms |
cloneDeep | ?? | ?? | ??(待完善) | ?? | 5035.3ms |
_.cloneDeep | ?? | ?? | ?? | ?? | 5854.5ms |
在日常的使用過程中较沪,如果你確定你的數(shù)據(jù)中只有數(shù)組鳞绕、對象等常見類型,你大可以放心使用JSON序列化的方式來進(jìn)行深拷貝尸曼,其它情況下還是推薦引入 loadsh/cloneDeep
來實(shí)現(xiàn)
小結(jié)
深拷貝的水很“深”们何,淺拷貝也不“淺”,小小的深拷貝里面蘊(yùn)含的知識點(diǎn)十分豐富:
- 考慮問題是否全面控轿、嚴(yán)謹(jǐn)
- 基礎(chǔ)知識冤竹、api熟練程度
- 對深拷貝、淺拷貝的認(rèn)識
- 對數(shù)據(jù)類型的理解
- 遞歸/非遞歸(循環(huán))
- Set茬射、Map/WeakMap等
我相信鹦蠕,要是面試官愿意挖掘的話,能考查的知識點(diǎn)遠(yuǎn)不止這么多躲株,這個(gè)時(shí)候就要考驗(yàn)?zāi)阕约旱幕竟σ约爸R面的深廣度了片部,而這些都離不開平時(shí)的積累。千里之行霜定,積于跬步档悠,萬里之船,成于羅盤
本文如有錯(cuò)誤望浩,還請各位批評指正~
參考
- MDN - WeakMap
- loadsh/cloneDeep
- 完整的示例和測試代碼:github/lvqq/cloneDeep
原文鏈接:深拷貝實(shí)踐