iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 14
1

前天的文章 中,我們討論了 JavaScript 的資料型別,其中最特殊的莫過於物件;在這個萬物皆物件的語言中,如何完美的複製物件,也就成了開發過程中頻繁出現的功能需求;相信蠻多讀者多少也聽過深拷貝、淺拷貝這些名詞,接下來就讓我們一起瞧瞧物件拷貝的實作及背後秘辛。

資料複製

如果我們想要複製資料,而資料型態是 JavaScript 的基本型別,那麼我們可以直接這樣寫:

let num1 = 123
let num2 = num1
num1 = 456
console.log(num2) // 123

上面的範例中,變數 num2 複製自變數 num1,從複製之後,num1 的值便再也不會和 num2 的數值有所關聯。

那如果複製的資料是物件呢?

let obj1 = {
  foo: 'bar'
}
let obj2 = obj1
obj1.foo = 'changed'
console.log(obj2.foo) // changed

這次我們宣告了變數 obj2,複製自 obj1,但當 obj1 內的屬性 foo 改變時,obj2foo 也跟著改變了。為什麼會這樣呢?

Call by value

這是因為 JavaScript 中,物件變數所儲存的值,實際上存的是物件所在的記憶體位置:

圖片來自 Huli - 深入探討 JavaScript 中的參數傳遞:call by value 還是 reference?

當我們將 obj2 賦值為 obj1 時,實際上複製的只有那個記憶體位置,但記憶體位置所儲存的物件,還是同一個,也因此當物件改變時,兩個變數都會被影響。

這樣的情況該如何解決呢?這就是今天我們要來討論的問題了。

物件拷貝

最直觀的方法,就是建立一個新物件,將原本資料全部的屬性都複製進去,這樣就可以了吧?例如我們可以透過 Object.assign,將物件全部的屬性倒過去:

let obj1 = {
  foo: 'bar'
}
let obj2 = Object.assign({}, obj1)
obj1.foo = 'changed'
console.log(obj2.foo) // bar

或著使用 ES9 的物件展開運算子,快速簡短的寫成這樣:

let obj1 = {
  foo: 'bar'
}
let obj2 = { ...obj1 }
obj1.foo = 'changed'
console.log(obj2.foo) // bar

看起來很美好吧?但如果物件中屬性的值也是物件,這樣的方法就只能複製到第一層物件的屬性,而無法複製到屬性值的物件;因此我們將這樣的複製方法稱為「淺拷貝」。

深拷貝

有淺拷貝,自然就會有與之對應的「深拷貝」;實務上最快最簡單的方法,就是將物件依序經過 JSON.stringifyJSON.parse 兩個方法,建立全新的物件:

let obj1 = {
  foo: 'bar',
  arr: [0, 1]
}
let obj2 = { ...obj1 }
let obj3 = JSON.parse(JSON.stringify(obj1))
obj1.foo = 'changed'
obj1.arr[0] = 99

console.log(obj2.foo) // bar
console.log(obj2.arr) // [99, 1]

console.log(obj3.foo) // bar
console.log(obj3.arr) // [0, 1]

如上面的範例,透過 JSON.parse(JSON.stringify(obj)) 的處理,物件屬性值的物件,甚至更深層的物件,都可以正確的被複製出來。

由於 JSON.stringify 的先天限制,有少部分特定情況是無法這樣處理的;雖然這不是一個最完美的解決方案,但通常這樣就足以應付絕大多數的使用情境了。

JSON.stringify 在處理物件時,處理結果可以被物件內的 toJSON 函式覆寫,另外預設的處理會自動過濾掉屬性 key 為 symbol 的值、當值為 undefined 時也會自動忽略,以及無法處理物件循環參考的情況。

如果想要更進一步的實現完美的深拷貝,就必須由開發者自行撰寫(或著用其他開發者寫好的套件)囉。

實作

那麼我們今天就來實作個深拷貝吧;首先來思考需要注意的規格:

  1. 完全複製整個物件,包含 key 為 symbol 或值為 undefined 的屬性
  2. 考慮物件循環引用的可能性

考慮到第一點,撰寫時需要使用 typeof 判斷物件型別,並只對結果為 object 的屬性做額外處理;第二點的話,則需要儲存一個物件的記憶體位置快取,將複製過的物件都快取起來。另外,也由於物件屬性層層相依的關係,感覺用遞迴寫好像還不錯。

別忘了要特別考慮值為 null 的情況,typeof null 會得到 object

function deepCopy(obj, cache = new WeakMap()) {
  // 基本型別 & function
  if (obj === null || typeof obj !== 'object') return obj
  // Date 及 RegExp
  if (obj instanceof Date || obj instanceof RegExp) return obj.constructor(obj)
  // 檢查快取
  if (cache.has(obj)) return cache.get(obj)

  // 使用原物件的 constructor
  const copy = new obj.constructor()

  // 先放入 cache 中
  cache.set(obj, copy)
  // 取出所有一般屬性 & 所有 key 為 symbol 的屬性
  ;[...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)].forEach(key => {
    copy[key] = deepCopy(obj[key], cache)
  })

  return copy
}

這樣就完成了深拷貝實作囉,先使用 typeof 過濾掉基本型別,接著判斷是否為 JavaScript 的原生物件,若都不是,再藉由 cache 暫存物件的參考,並將所有可能有的屬性過一次這個函式,遞迴的反覆進行,完成複製整個物件的過程。大家也可以自行測試、實作看看喔,其實只要能理解這樣的思考,實作並不難!

結語

今天我們從討論物件的複製開始,逐步理解深、淺拷貝的差異及用法,並在最後嘗試實作了深拷貝的函式,希望藉由這樣的過程有幫助讀者您理解複製物件這回事。

那麼今天的文章就到這啦,我們大家明天見~

參考資料

筆者

Gary

半路出家網站工程師;半生熟的前端加上一點點的後端。
喜歡音樂,喜歡學習、分享,也喜歡當個遊戲宅。

相信一切安排都是最好的路。


上一篇
13. [JS] 為什麼判斷相等時不能用雙等號?
下一篇
15. [JS] 什麼是原型鏈?
系列文
前端三十 - 成為更好的前端工程師31

2 則留言

1
tsuifei
iT邦新手 5 級 ‧ 2019-10-01 08:38:57

這篇寫得好清楚、好細心,感謝!

Gary iT邦新手 5 級‧ 2019-10-01 10:11:38 檢舉

/images/emoticon/emoticon41.gif

1
hannahpun
iT邦新手 5 級 ‧ 2019-10-11 04:35:04

推詳細!! 只會用 JSON.parse(JSON.stringify(obj))
從來沒想過背後原理跟限制

Gary iT邦新手 5 級‧ 2019-10-13 12:55:25 檢舉

有踩過一次雷就會想深入理解了XD

感謝支持~ /images/emoticon/emoticon41.gif

我要留言

立即登入留言