前言:js如何實現一個深拷貝javascript
這是一個老生常談的問題,也是在求職過程當中的高頻面試題,考察的知識點十分豐富,本文將對淺拷貝和深拷貝的區別、實現等作一個由淺入深的梳理java
在js中,變量類型分爲基本類型和引用類型。對變量直接進行賦值拷貝:node
直接拷貝引用類型變量,只是複製了變量的指針地址,兩者指向的是同一個引用類型數據,對其中一個執行操做都會引發另外一個的改變。git
關於淺拷貝和深拷貝:github
所以,淺拷貝與深拷貝根本上的區別是 是否共享內存空間 。簡單來說,深拷貝就是對原數據遞歸進行淺拷貝。面試
三者的簡單比較以下:正則表達式
是否指向原數據 | 子數據爲基本類型 | 子數據包含引用類型 | |
---|---|---|---|
賦值 | 是 | 改變時原數據改變 | 改變時原數據改變 |
淺拷貝 | 否 | 改變時原數據 不改變 | 改變時原數據改變 |
深拷貝 | 否 | 改變時原數據 不改變 | 改變時原數據 不改變 |
數組和對象中常見的淺拷貝方法有如下幾種:api
使用下面的 用例 1.test.js 進行測試:數組
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'
複製代碼
結果以下:markdown
能夠看到通過淺拷貝之後,咱們去修改原對象或數組中的基本類型數據,拷貝後的相應數據未發生改變;而修改原對象或數組中的引用類型數據,拷貝後的數據會發生相應變化,它們共享同一內存空間
這裏咱們列舉常見的深拷貝方法並嘗試本身手動實現,最後對它們作一個總結、比較
使用 JSON.parse(JSON.stringify(data))
來實現深拷貝,這種方法基本能夠涵蓋90%的使用場景,但它也有其不足之處,涉及到下面這幾種狀況下時則須要考慮使用其餘方法來實現深拷貝:
JSON.parse
只能序列化可以被處理爲JSON格式的數據,所以沒法處理如下數據
undefined
、 NaN
、 Infinity
等JSON.parse
只能序列化對象可枚舉的自身屬性,所以會丟棄構造函數的 constructor
使用下面的 用例 2.test.js 來對基本類型進行驗證:
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
在序列化的過程當中被轉化爲了 null
,而 undefined
則丟失了:
再使用 用例 3.test.js 對引用類型進行測試:
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))
複製代碼
對於引用類型數據,在序列化與反序列化過程當中,只有數組和對象被正常拷貝,其中時間對象被轉化爲了字符串,函數會丟失,其餘的都被轉化爲了空對象:
利用 用例 4.test.js 對構造函數進行驗證:
function Person(name) {
// 構造函數實例屬性name
this.name = name
// 構造函數實例方法getName
this.getName = function () {
return this.name
}
}
// 構造函數原型屬性age
Person.prototype.age = 18
const person = new Person('xxx')
const personCopy = JSON.parse(JSON.stringify(person))
複製代碼
在拷貝過程當中只會序列化對象可枚舉的自身屬性,所以沒法拷貝 Person
上的原型屬性 age
;因爲序列化的過程當中構造函數會丟失,因此 personCopy
的 constructor
會指向頂層的原生構造函數 Object
而不是自定義構造函數Person
咱們先來實現一個簡單版的深拷貝,思路是,判斷data類型,若不是引用類型,直接返回;若是是引用類型,而後判斷data是數組仍是對象,並對data進行遞歸遍歷,以下:
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
}
複製代碼
執行 用例 clone1.test.js :
const data = {
str: 'test',
obj: {
foo: 'test'
},
arr: ['test', {foo: 'test'}]
}
const dataCopy = cloneDeep(data)
複製代碼
能夠看到對於對象和數組可以實現正確的拷貝
首先是隻考慮了對象和數組這兩種類型,其餘引用類型數據依然與原數據共享同一內存空間,有待完善;其次,對於自定義的構造函數而言,在拷貝的過程當中會丟失實例對象的 constructor
,所以其構造函數會變爲默認的 Object
在上一步咱們實現的簡單深拷貝,只考慮了對象和數組這兩種引用類型數據,接下來將對其餘經常使用數據結構進行相應的處理
咱們首先定義一個方法來正確獲取數據的類型,這裏利用了 Object
原型對象上的 toString
方法,它返回的值爲 [object type]
,咱們截取其中的type便可。而後定義了數據類型集合的常量,以下:
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',
}
複製代碼
接着咱們完善對於其餘類型的處理,根據不一樣的 data 類型,對 data 進行不一樣的初始化操做,而後進行相應的遞歸遍歷,以下:
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,拷貝過程當中lastIndex屬性會丟失
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 進行驗證:
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)
複製代碼
能夠看到對於不一樣類型的引用數據都可以實現正確拷貝,結果以下:
函數的拷貝我這裏沒有實現,兩個對象中的函數使用同一個內存空間並無什麼問題。實際上,查看了 lodash/cloneDeep
的相關實現後,對於函數它是直接返回的:
到這一步,咱們的深拷貝方法已經初具雛形,實際上須要特殊處理的數據類型遠不止這些,還有 Error
、 Buffer
、 Element
等,有興趣的小夥伴能夠繼續探索實現一下~
目前爲止深拷貝可以處理絕大部分經常使用的數據結構,可是當數據中出現了循環引用時它就一籌莫展了
const a = {}
a.a = a
cloneDeep(a)
複製代碼
能夠看到,對於循環引用,在進行遞歸調用的時候會變成死循環而致使棧溢出:
那麼如何破解呢?
拋開循環引用不談,咱們先來看看基本的 引用 問題,前文所實現的深拷貝方法以及 JSON
序列化拷貝都會解除原引用類型對於其餘數據的引用,來看下面這個例子:
const temp = {}
const data = {
a: temp,
b: temp,
}
const dataJson = JSON.parse(JSON.stringify(data))
const dataClone = cloneDeep(data)
複製代碼
驗證一下引用關係:
若是解除這種引用關係是你想要的,那徹底ok。若是你想保持數據之間的引用關係,那麼該如何去實現呢?
一種作法是能夠用一個數據結構將已經拷貝過的內容存儲起來,而後在每次拷貝以前進行查詢,若是發現已經拷貝過了,直接返回存儲的拷貝值便可保持原有的引用關係。
由於可以被正確拷貝的數據均爲引用類型,因此咱們須要一個 key-value
且 key
能夠是引用類型的數據結構,天然想到能夠利用 Map/WeakMap
來實現。
這裏咱們利用一個 WeakMap
的數據結構來保存已經拷貝過的結構, WeakMap
與 Map
最大的不一樣,就是它的鍵是弱引用的,它對於值的引用不計入垃圾回收機制,也就是說,當其餘引用都解除時,垃圾回收機制會釋放該對象的內存;假如使用強引用的 Map
,除非手動解除引用,不然這部份內存不會獲得釋放,容易形成內存泄漏。
具體的實現以下:
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,拷貝過程當中lastIndex屬性會丟失
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
}
複製代碼
通過改造後的深拷貝函數可以保留原數據的引用關係,也能夠正確處理不一樣引用類型的循環引用,利用下面的用例 clone3.test.js 來進行驗證:
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)
複製代碼
結果以下:
在前面的深拷貝實現方法中,均是經過遞歸的方式來進行遍歷,當遞歸的層級過深時,也會出現棧溢出的狀況,咱們使用下面的 create
方法建立深度爲10000,廣度爲100的示例數據:
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)
複製代碼
結果以下:
那麼假如不使用遞歸,咱們應該如何實現呢?
以對象爲例,存在下面這樣一個數據結構:
const data = {
left: 1,
right: {
left: 1,
right: 2,
}
}
複製代碼
那麼換個角度看,其實它就是一個類樹形結構:
咱們對該對象進行遍歷實際上至關於模擬對樹的遍歷。樹的遍歷主要分爲深度優先遍歷和廣度優先遍歷,前者通常藉助棧來實現,後者通常藉助隊列來實現。
這裏模擬了樹的深度優先遍歷,僅考慮對象和非對象,利用棧來實現一個不使用遞歸的簡單深拷貝方法:
function cloneDeep(data) {
const retVal = {}
const stack = [{
target: retVal,
source: data,
}]
// 循環整個stack
while(stack.length > 0) {
// 棧頂節點出棧
const node = stack.pop()
const { target, source } = node
// 遍歷當前節點
for(let item in source) {
if (source.hasOwnProperty(item)) {
if (Object.prototype.toString.call(source[item]) === '[object Object]') {
target[item] = {}
// 子節點若是是對象,將該節點入棧
stack.push({
target: target[item],
source: source[item],
})
} else {
// 子節點若是不是對象,直接拷貝
target[item] = source[item]
}
}
}
}
return retVal
}
複製代碼
關於完整的深拷貝非遞歸實現,能夠參考 clone4.js ,對應的測試用例爲 用例 clone4.test.js ,這裏就不給出了
這裏列舉了常見的幾種深拷貝方法,並進行簡單比較
關於耗時比較,採用前文的 create
方法建立了一個廣度、深度均爲1000的數據,在 node v10.14.2
環境下循環執行如下方法各10000次,這裏的耗時取值爲運行十次測試用例的平均值,以下:
基本類型 | 數組、對象 | 特殊引用類型 | 循環引用 | 耗時 | |
---|---|---|---|---|---|
JSON | 沒法處理 NaN 、 Infinity 、 Undefined |
丟失對象原型 | ❌ | ❌ | 7280.6ms |
$.extend | 沒法處理 Undefined |
丟失對象原型、拷貝原型屬性 | ❌ (使用同一引用) |
❌ | 5550.6ms |
cloneDeep | ✔️ | ✔️ | ✔️(待完善) | ✔️ | 5035.3ms |
_.cloneDeep | ✔️ | ✔️ | ✔️ | ✔️ | 5854.5ms |
在平常的使用過程當中,若是你肯定你的數據中只有數組、對象等常見類型,你大能夠放心使用JSON序列化的方式來進行深拷貝,其它狀況下仍是推薦引入 loadsh/cloneDeep
來實現
深拷貝的水很「深」,淺拷貝也不「淺」,小小的深拷貝里面蘊含的知識點十分豐富:
我相信,要是面試官願意挖掘的話,能考查的知識點遠不止這麼多,這個時候就要考驗你本身的基本功以及知識面的深廣度了,而這些都離不開平時的積累。千里之行,積於跬步,萬里之船,成於羅盤
本文若有錯誤,還請各位批評指正~