十分鐘帶你手撕一份"漸進式"JS深拷貝

語言: CN / TW / HK

寫在前邊

作為前端面中老生長談的深拷貝,我相信許多前端開發者對它嗤之以鼻。

"21世紀了還在講這種老掉牙的知識?!"

各位大佬彆著急拔刀😅,文章中站在一個合格的面試官角度來談談一個基本合格的深拷貝需要考慮哪些邊界情況:

  • [x] 拷貝的日期格式處理。
  • [x] 拷貝中的正則物件處理。
  • [x] 拷貝中的迴圈物件引用。
  • [x] 拷貝中的相同引用物件處理。
  • [x] 拷貝中不能丟失原本物件原型。
  • [x] 拷貝中原本物件的屬性修飾符。

一個成熟的深拷貝最基本的實現一定是需要囊括上邊六點,看到這裡各位可以想一想腦海中的深拷貝是不是覆蓋到了所有的點。

畢竟每一件看起來簡單的事情其實背後都藏著很多值得我們反覆思考的地方嘛。

淺拷貝

其實我並不打算對淺拷貝進行過於篇幅介紹的,js中可以所有關於拷貝的api都是在淺拷貝。

  • Object.assign
  • Es6擴充套件運算子
  • Array.prototype.slice.call/concat
  • ...

淺拷貝的方法太多了,這部分api不清楚的同學需要加油補充自己的基礎知識了。

深拷貝

讓我們先忘記文章中開頭提到我們需要實現的點,先從最最最容易的方式我們來一步一步做改進。

JSON.stringify

提到JSON.stringify大家都很熟悉,它可以講javascript值轉成字串從而可以非常簡單的讓我實現深拷貝:

image.png

看上去一切都那麼美好是吧,它的原理也很簡單: 先講物件序列化成為json字串,之後在通過json.parse將字串轉化成為object

JSON.stringify存在的問題

我們使用JSON.stringify來轉化一個稍微複雜一點的物件:

image.png

我們可以發現原始obj物件在經過JSON系列api轉化後,eatkeySymbol['name']這兩個屬性丟失了,同時NaN轉換成為了null,正則的value變成了{}datevalue原本是date型別...這裡變成了string型別。

同時我們注意到原始obj物件上的屬性children1children2引用的是相同的物件,克隆前他們指向同一個引用物件地址。但是克隆後的cloneObjcloneObj.children1 === cloneObj.children2返回值是false,針對相同引用JSON.stringify是無法實現克隆後保持一致的。

看來JSON api用在深拷貝上真的是漏洞百出呀

表現問題

我們來稍微總結一下目前JSON.stringify用在深拷貝上存在的問題:

  • 拷貝後的Date型別會變成字串string
  • 拷貝後的RegExp型別會變成空物件。
  • 拷貝物件中含valueNaN的值會變為null
  • 拷貝後的物件會丟失含有Symbol型別的屬性。
  • 拷貝後的物件會丟失valueundefined的屬性。
  • 拷貝後物件中的相同引用會變成完全兩個不同的引用,只是看上去相同罷了。

其實同時變為null的還會有Infinity-Infinity,平常我們很少用到。有興趣的童鞋可以自己去試一下。

從表現上來說目前JSON.stringify實現深拷貝目前存在的問題我們已經總結了絕大部分。

此時讓我們切入深層次的思考點,所謂深層次就需要你回想一下文章中開頭講到的6點中的內容。

深層問題

本質上JSON.stringify實現的是一個將物件轉化為json字串之後在通過JSON.parse將字串轉化為一個全新的物件。

在這個過程中我們需要思考的是,JSON.stringfiy的過程會存在額外兩個問題:

  • 原始物件的繼承關係不會被繼承
  • 原始物件的屬性描述符丟失

在字串重新轉化物件時,JSON.stringify重新生成的物件會丟失原始物件的繼承關係和屬性描述符,這顯然和我們實現深拷貝時的初衷是相反的。

迴圈引用問題

接下來我們談談所謂的迴圈引用問題,可能有一部分同學在實現深拷貝時很少會考慮到物件的迴圈引用問題。

我們先來用一個簡單的例子來看一下所謂的迴圈引用:

image.png

所謂的迴圈引用簡單來說就是物件中存在某個屬性,這個屬性指向了物件中已經存在的物件。

此時當我們使用Json.stringify來試試克隆這個obj物件會發生什麼:

image.png

針對引用型別的呼叫,JSON.stringify會直接丟擲錯誤,無法轉換一個迴圈引用的物件。

從一個簡易版深拷貝過度

我們先從實現一個簡易版的深拷貝來看看所謂深拷貝的實現思路。

我相信大部分同學對於所謂簡易版的深拷貝實現一定是信手拈來:

```js // 簡易版深拷貝 const isReferenceType = (value) => typeof value === 'object' && value !== null;

function cloneDeep(obj) {
  if (!isReferenceType(obj)) {
    return obj
  }
  const cloneObj = Array.isArray(obj) ? [] : {}
  Object.keys(obj).forEach(key => {
    const value = obj[key]
    // 如果深層依然是物件 遞迴呼叫
    const cloneValue = isReferenceType(value) ? cloneDeep(value) : value
    cloneObj[key] = cloneValue
  })
  return cloneObj
}

const object1 = {
  name: '19Qingfeng',
  yellow: false,
  release: {
    custom: true,
    github: '19Qingfeng'
  }
}

const cloneValue = cloneDeep(object1)
console.log(cloneValue, '克隆後的物件')
console.log(cloneValue === object1)

```

上邊是一個最基礎版本的深拷貝實現,我相信也是大多數人所謂的深拷貝實現。

但是在我們提到了上邊已經成熟深拷貝應該考慮到的問題來出發的話,其實他和JSON.stingify是一樣的簡陋。

利用tyoe of判斷是否是引用型別從而使用Object.keys方法迭代遞迴呼叫進行實現深拷貝。

如果平常你的深拷貝實現和這個方法差距不是很大的話,我希望在這個時候你停下下滑,思考一下咱們上邊提到過需要實現的點。你會發現他仍然無法解決我提到的那些"問題",嘗試一下對於上邊提到的點你是否已經有對於問題的解決方法。

從"問題"出發實現深拷貝

讓我們從問題出發先來一個一個梳理要解決文章最開始提出的問題可以使用哪些方案:

日期/正則格式處理

  • 拷貝的日期格式處理。
  • 拷貝中的正則物件處理。

針對資料格式中的日期和正則物件的處理,我們可以通過額外判斷傳入的value是否是日期/正則型別。

如果是,那麼就直接new一個新的對應型別返回,判斷是否是具體某個正則/日期型別我們可以基於原型物件上的constructor屬性判斷:

image.png

這裡因為我們建立正則/日期物件時都是基於父類去new父類的建構函式,所以我們可以通過js中繼承的關係去父類的原型物件prototype上的建構函式constructor來判斷是否是對應型別。

當然你也可以通過Object.prototype.toString.call的結果來判斷。

丟失原型/屬性修飾符

  • 拷貝中Object.keys無法遍歷keysymbol型別
  • 拷貝中不能丟失原本物件原型。
  • 拷貝中原本物件的屬性修飾符。

針對這兩個問題我們看下這幾個js的基礎api

Reflect.ownKeys()

關於Reflect你可以在這裡檢視他的官方簡介

我們之所以使用Reflect.ownKeys()替代Object.keys()Reflect.ownKeys()相比起來存在以下優點:

  • 它支援遍歷物件上的不可列舉enumerable:false屬性,而Object.keys()不可。
  • 它支援遍歷物件上的Symbol型別,而Object.keys()不可。
  • 同樣他和Object.keys()僅會遍歷自身的屬性,而不會返回原型上的屬性。

Object.getPrototypeOf()

Object.getPrototypeOf() 方法返回指定物件的原型(內部[[Prototype]]屬性的值)。

我們可以通過它獲得物件原本的原型物件,從而結合Object.create方法輕鬆實現對應的,輕鬆實現深拷貝中的繼承關係。

Object.getOwnPropertyDescriptors()

Object.getOwnPropertyDescriptors()  方法返回指定物件上一個自有屬性對應的屬性描述符。(自有屬性指的是直接賦予該物件的屬性,不需要從原型鏈上進行查詢的屬性)。

我們來看看這個api的返回值吧,注意區分他和Object.getOwnPropertyDescriptors()

image.png

Object.create(proto,[propertiesObject])

Object.create支援傳入兩個引數從而返回一個全新的物件。

第一個引數支援傳入一個物件並且將這個物件作為新建立物件的__proto__的指向,也就是新建立物件的原型物件。

第二個引數支援傳入一個物件,這個物件

前邊講到我們已經可以通過:

  • getPrototypeOf()獲取原始物件的原型物件。
  • getOwnPropertyDescriptors()獲取原始物件的所有屬性的屬性描述符。

我們只要通過object.create方法將這兩個方法的返回值就可以實現繼承與屬性符的深拷貝效果了。

解決迴圈引用問題

我們可以在deepClone方法中額外儲存一個變數,他是一個hash表用來儲存我們拷貝過程中遞迴的每一個物件。

從而下次在碰到相同的引用地址物件時,直接從儲存的hash表中取出相同的引用地址進行賦值就可以了而不需要再次遞迴相同的object

這樣就可以避免迴圈引用引發的爆棧,同時也可以解決相同引用的問題。

但是這裡有一個應該注意的小tip,在js中我們通常用於object進行儲存對應的key,value結構。但是這裡我們需要儲存的key需要是舊的引用物件,它是一個物件。

不難想到ES6中支援Map結構是我們最佳的選擇,可是此時需要考慮的一個問題就是針對Map的引用型別其實是會造成引用計數的。我們想要的效果是這個hash物件中最好不要造成引用計算影響垃圾回收機制,當我們把儲存物件消除時hash中的引用的值也會被清除掉。

此時結合來看,這個hash物件最佳的選擇一定是使用一個WeakMap物件進行儲存。

關於WeakMap你可以在這裡檢視到它的介紹

相比之下,原生的 WeakMap 持有的是每個鍵物件的“弱引用”,這意味著在沒有其他引用存在時垃圾回收能正確進行。原生 WeakMap 的結構是特殊且有效的,其用於對映的 key 只有在其沒有被回收時才是有效的。

如果單純使用map在拷貝大量資料的迴圈/相同引用下非常容易出現記憶體洩露導致不必要的效能丟失。

講了那麼多原理,我們來看看最終版的的實現吧:

最終版深拷貝

js /* 實現深拷貝 1。 判斷迴圈引用 2. 判斷正則物件 3. 判斷日期物件 4. 屬性物件直接進行遞迴拷貝 5. 考慮拷貝時不能丟失原本物件的原型繼承關係 6. 考慮拷貝時的屬性修飾符 */ function cloneDeep(value, map = new WeakMap()) { if (value.constructor === Date) { return new RegExp(value) } if (value.constructor === RegExp) { return new RegExp(value) } // 如果value是普通型別 直接返回 if (typeof value !== 'object' || value === null) { return value } // 考慮物件的原型 獲得原本物件的原型 建立一個新的物件繼承這個物件的原型 const prototype = Object.getPrototypeOf(value) // 考慮拷貝時不能 丟失對原有物件的屬性描述符 const description = Object.getOwnPropertyDescriptors(value) // 建立新的空物件 同時繼承原有物件原型 同時擁有對應的描述符 const object = Object.create(prototype, description) // 遍歷物件的屬性 進行拷貝 Reflect.ownKeys 遍歷獲取自身的不可列舉以及key為Symbol的屬性 map.set(value, object) Reflect.ownKeys(value).forEach(key => { // key是普通型別 if (typeof key !== object || key === null) { // 直接覆蓋 object[key] = value[key] } else { // 解決迴圈引用的關鍵是 每一個物件都給他存放在weakMap中 因為WeakMap是一個弱引用 // 每次如果進入是物件 那麼就把這個物件 優先存放在weakmap中 之後如果還有引用這個物件的地方 直接從weakmap中拿出來 而不需要再進行遍歷造成爆棧 // 同理,如果使用相同引用為了保證同一份引用地址的話 可以使用weakMap中直接拿出保證同一份引用 // 這裡判斷之前是否存在相同的引用 如果存在相同的引用直接返回引用即可 const mapValue = map.get(value) mapValue ? (object[key] = map.get(value)) : (object[key] = cloneDeep(value[key])) } }) return object } 程式碼中存在了每一個步驟的詳細註釋,其實實現一個深拷貝本身並不是很難,只是有很多邊界情況需要我們注意。

接下來讓我們寫一段Demo來驗證下方法的結果吧:

```js let obj = { age: 23, name: '19Qingfeng', boolean: true, empty: undefined, nul: null, customObj: { name: '19Qingfeng', github:'https://github.com/19Qingfeng' }, customArr: [0, 1, 2], customFn: () => console.log('19Qingfeng'), date: new Date(100), reg: new RegExp('/19Qingfeng/ig'), [Symbol('hello')]: 'Welcome follow my github!', };

// 定義不可列舉屬性 Object.defineProperty(obj, 'innumerable', { enumerable: false, value: '不可列舉屬性' } ); obj = Object.create(obj, Object.getOwnPropertyDescriptors(obj)) obj.loop = obj // 設定loop成迴圈引用的屬性 let cloneObj = cloneDeep(obj) cloneObj.customArr.push(4)

console.log(cloneDeep(obj))

console.log(cloneDeep(obj) === obj) // 注意不可以列舉屬性是無法被列印顯示出來的 我們可以通過Reflect.ownKeys進行驗證 Reflect.ownKeys(cloneDeep(obj)).forEach(key => console.log(key)) ```

image.png

大功告成,此時一個基礎的深拷貝已經完整實現了!

寫在最後

其實實現深拷貝的寫法有很多種,但是原理上是大同小異的。

這裡只是提供給大家思路,真正的深拷貝要相容太多邊界情況。比如我們如果拷貝的Map/Set型別文章中的程式碼就沒有相容到,但是針對學習來說我認為最終版的深拷貝已經足夠應付面試官想考察你的知識體系了思考廣度了。

大家有興趣可以私下完善這份程式碼,不要再被問及深拷貝只是告訴別人你僅僅知道lodash中的cloneDeep方法了~