深拷貝實踐

前言:js如何實現一個深拷貝javascript

這是一個老生常談的問題,也是在求職過程當中的高頻面試題,考察的知識點十分豐富,本文將對淺拷貝和深拷貝的區別、實現等作一個由淺入深的梳理java

賦值、淺拷貝與深拷貝的區別

在js中,變量類型分爲基本類型和引用類型。對變量直接進行賦值拷貝:node

  • 對於基本類型,拷貝的是存儲在中的值
  • 對於引用類型,拷貝的是存儲在棧中的指針,指向中該引用類型數據的真實地址

直接拷貝引用類型變量,只是複製了變量的指針地址,兩者指向的是同一個引用類型數據,對其中一個執行操做都會引發另外一個的改變。git

關於淺拷貝和深拷貝:github

  • 淺拷貝是對於原數據的精確拷貝,若是子數據爲基本類型,則拷貝值;若是爲引用類型,則拷貝地址,兩者共享內存空間,對其中一個修改也會影響另外一個
  • 深拷貝則是開闢新的內存空間,對原數據的徹底複製

所以,淺拷貝與深拷貝根本上的區別是 是否共享內存空間 。簡單來說,深拷貝就是對原數據遞歸進行淺拷貝。面試

三者的簡單比較以下:正則表達式

是否指向原數據 子數據爲基本類型 子數據包含引用類型
賦值 改變時原數據改變 改變時原數據改變
淺拷貝 改變時原數據 不改變  改變時原數據改變
深拷貝 改變時原數據 不改變  改變時原數據 不改變 

原生淺拷貝方法

數組和對象中常見的淺拷貝方法有如下幾種:api

  • Array.prototype.slice
  • Array.prototype.concat
  • Array.from
  • Object.assign
  • ES6解構

使用下面的 用例 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

image.png

能夠看到通過淺拷貝之後,咱們去修改原對象或數組中的基本類型數據,拷貝後的相應數據未發生改變;而修改原對象或數組中的引用類型數據,拷貝後的數據會發生相應變化,它們共享同一內存空間

深拷貝實現

這裏咱們列舉常見的深拷貝方法並嘗試本身手動實現,最後對它們作一個總結、比較

1. JSON序列化快速實現

使用 JSON.parse(JSON.stringify(data)) 來實現深拷貝,這種方法基本能夠涵蓋90%的使用場景,但它也有其不足之處,涉及到下面這幾種狀況下時則須要考慮使用其餘方法來實現深拷貝:

  • JSON.parse 只能序列化可以被處理爲JSON格式的數據,所以沒法處理如下數據
    • 特殊數據例如 undefined 、 NaN 、 Infinity 等
    • 特殊對象如時間對象、正則表達式、函數、Set、Map等
    • 對於循環引用(例如環)等沒法處理,會直接報錯
  • 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 則丟失了:

image.png

再使用 用例 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))
複製代碼

對於引用類型數據,在序列化與反序列化過程當中,只有數組和對象被正常拷貝,其中時間對象被轉化爲了字符串,函數會丟失,其餘的都被轉化爲了空對象:

image.png

利用 用例 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 

image.png

2. 手動實現深拷貝方法

簡單版

咱們先來實現一個簡單版的深拷貝,思路是,判斷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)
複製代碼

能夠看到對於對象和數組可以實現正確的拷貝

image.png

首先是隻考慮了對象和數組這兩種類型,其餘引用類型數據依然與原數據共享同一內存空間,有待完善;其次,對於自定義的構造函數而言,在拷貝的過程當中會丟失實例對象的 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)
複製代碼

能夠看到對於不一樣類型的引用數據都可以實現正確拷貝,結果以下:

image.png

關於函數

函數的拷貝我這裏沒有實現,兩個對象中的函數使用同一個內存空間並無什麼問題。實際上,查看了 lodash/cloneDeep 的相關實現後,對於函數它是直接返回的:

image.png

到這一步,咱們的深拷貝方法已經初具雛形,實際上須要特殊處理的數據類型遠不止這些,還有 Error 、 Buffer 、 Element  等,有興趣的小夥伴能夠繼續探索實現一下~

處理循環引用

目前爲止深拷貝可以處理絕大部分經常使用的數據結構,可是當數據中出現了循環引用時它就一籌莫展了

const a = {}
a.a = a

cloneDeep(a)
複製代碼

能夠看到,對於循環引用,在進行遞歸調用的時候會變成死循環而致使棧溢出:

image.png

那麼如何破解呢?

拋開循環引用不談,咱們先來看看基本的 引用 問題,前文所實現的深拷貝方法以及 JSON 序列化拷貝都會解除原引用類型對於其餘數據的引用,來看下面這個例子

const temp = {}
const data = {
	a: temp,
  b: temp,
}
const dataJson = JSON.parse(JSON.stringify(data))
const dataClone = cloneDeep(data)
複製代碼

驗證一下引用關係:

image.png

若是解除這種引用關係是你想要的,那徹底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)
複製代碼

結果以下:

image.png

思考:使用非遞歸

在前面的深拷貝實現方法中,均是經過遞歸的方式來進行遍歷,當遞歸的層級過深時,也會出現棧溢出的狀況,咱們使用下面的 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)
複製代碼

結果以下:

image.png

那麼假如不使用遞歸,咱們應該如何實現呢?

以對象爲例,存在下面這樣一個數據結構:

const data = {
  left: 1,
  right: {
  	left: 1,
    right: 2,
  }
}
複製代碼

那麼換個角度看,其實它就是一個類樹形結構:

image.png

咱們對該對象進行遍歷實際上至關於模擬對樹的遍歷。樹的遍歷主要分爲深度優先遍歷和廣度優先遍歷,前者通常藉助來實現,後者通常藉助隊列來實現。

這裏模擬了樹的深度優先遍歷,僅考慮對象和非對象,利用棧來實現一個不使用遞歸的簡單深拷貝方法:

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 ,這裏就不給出了

3. 深拷貝方法比較

這裏列舉了常見的幾種深拷貝方法,並進行簡單比較

  • JSON.parse(JSON.stringify(data))
  • jQuery中的$.extend
  • 咱們這裏本身實現的clone3.js中的cloneDeep
  • loadsh中的_.cloneDeep

關於耗時比較,採用前文的 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 來實現

小結

深拷貝的水很「深」,淺拷貝也不「淺」,小小的深拷貝里面蘊含的知識點十分豐富:

  • 考慮問題是否全面、嚴謹
  • 基礎知識、api熟練程度
  • 對深拷貝、淺拷貝的認識
  • 對數據類型的理解
  • 遞歸/非遞歸(循環)
  • Set、Map/WeakMap等

我相信,要是面試官願意挖掘的話,能考查的知識點遠不止這麼多,這個時候就要考驗你本身的基本功以及知識面的深廣度了,而這些都離不開平時的積累。千里之行,積於跬步,萬里之船,成於羅盤

本文若有錯誤,還請各位批評指正~

參考

相關文章
相關標籤/搜索