在 前天的文章 中,我們討論了 JavaScript 的資料型別,其中最特殊的莫過於物件;在這個萬物皆物件的語言中,如何完美的複製物件,也就成了開發過程中頻繁出現的功能需求;相信蠻多讀者多少也聽過深拷貝、淺拷貝這些名詞,接下來就讓我們一起瞧瞧物件拷貝的實作及背後秘辛。
本系列文已經重新編校彙整編輯成冊,並正式出版囉!
《前端三十:從 HTML 到瀏覽器渲染的前端開發者必備心法》好評販售中!
喜歡我文章內容的讀者們,歡迎您 前往購買 支持!
如果我們想要複製資料,而資料型態是 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
改變時,obj2
的 foo
也跟著改變了。為什麼會這樣呢?
這是因為 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.stringify
及 JSON.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
時也會自動忽略,以及無法處理物件循環參考的情況。
如果想要更進一步的實現完美的深拷貝,就必須由開發者自行撰寫(或著用其他開發者寫好的套件)囉。
那麼我們今天就來實作個深拷貝吧;首先來思考需要注意的規格:
symbol
或值為 undefined
的屬性考慮到第一點,撰寫時需要使用 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
半路出家網站工程師;半生熟的前端加上一點點的後端。
喜歡音樂,喜歡學習、分享,也喜歡當個遊戲宅。相信一切安排都是最好的路。
推詳細!! 只會用 JSON.parse(JSON.stringify(obj))
從來沒想過背後原理跟限制
有踩過一次雷就會想深入理解了XD
感謝支持~
想請教說,obj.constructor()意思是麼呢? 該直接使用new有差異嗎?
const copy = new obj.constructor()
const copy = new obj()
另外也想知道"return obj.constructor(obj)"的意思,謝謝