深拷貝可以說是前端面試中非常高頻的問題,也是一道基礎題。所謂的基礎不是說深拷貝本身是一個非常簡單、非常基礎的問題,而是面試官要通過深拷貝來考察候選人的JavaScript基礎,甚至是程式設計能力。
為什麼需要深拷貝?
第一個問題,也是最淺顯的問題,為什麼 JavaScript 中需要深拷貝?或者說如果不使用深拷貝複製物件會帶來哪些問題?
我們知道在 JavaScript 中存在“引用型別“和“值型別“的概念。因為“引用型別“的特殊性,導致我們複製物件不能通過簡單的clone = target
,所以需要把原物件的屬性值一一賦給新物件。
而物件的屬性其值也可能是另一個物件,所以我們需要遞迴。
如何獲取原物件的屬性?
通過for...in
能夠遍歷物件上的屬性;也可以通過Object.keys(target)
獲取到物件上的屬性陣列後再進行遍歷。
這裡選用for...in
因為相比Object.keys(target)
它還會遍歷物件原型鏈上的屬性。
ES6 Symbol 型別也可以作為物件的 key ,如何獲取它們?
如何判斷物件的型別?
可以使用typeof
判斷目標是否為引用型別,這裡有一處需要注意:typeof null
也是object
:
function deepClone(target) {
const targetType = typeof target;
if (targetType === 'object' || targetType === 'function') {
let clone = Array.isArray(target)?[]:{}
for (const key in target) {
clone[key] = deepClone(target[key])
}
return clone;
}
return target;
}
上述程式碼就完成了一個非常基礎的深拷貝。但是對於引用型別的處理,它仍然是不完善的:
它沒法處理Date或者正則這樣的物件。為什麼?
“回字的四樣寫法“--具體型別的識別
獲取一個物件具體型別有哪些方式?
常用的方式有target.constructor.name
、Object.prototype.toString.call(target)
和instanceOf
。
instacneOf
可以用來判斷物件型別,但是Date
的例項同時也是Object
的例項,此處用於判斷是不準確的;target.constructor.name
得到的是構造器名稱,而構造器是可以被修改的;Object.prototype.toString.call(target)
返回的是類名,而在ES5
中只有內建型別物件才有類名。
所以此處我們最合適的選擇是Object.prototype.toString.call(target)
。
Object.prototype.toString.call(target)
也存在一些問題,你知道嗎?
稍微改進一下程式碼,做一些簡單的型別判斷:
function deepClone(target) {
const targetType = typeof target;
if (targetType === 'object' || targetType === 'function') {
let clone = Array.isArray(target)?[]:{};
if(Object.prototype.toString.call(target) === '[object Date]'){
clone = new Date(target)
}
if(Object.prototype.toString.call(target) === '[object Object]'
||Object.prototype.toString.call(target) === '[object Array]'){
for (const key in target) {
clone[key] = deepClone(target[key])
}
}
return clone;
}
return target;
}
怎麼能夠更優雅的做型別判斷?
你聽說過“迴圈引用“嗎?
假如目標物件的屬性間接或直接的引用了自身,就會形成迴圈引用,導致在遞迴的時候爆棧。
所以我們的程式碼需要迴圈檢測,設定一個Map
用於儲存已拷貝過的物件,當檢測到物件已存在於Map
中時,取出該值並返回即可避免爆棧。
function deepClone(target, map = new Map()) {
const targetType = typeof target;
if (targetType === 'object' || targetType === 'function') {
let clone = Array.isArray(target)?[]:{};
if (map.get(target)) {
return map.get(target);
}
map.set(target, clone);
if(Object.prototype.toString.call(target) === '[object Date]'){
clone = new Date(target)
}
if(Object.prototype.toString.call(target) === '[object Object]'
||Object.prototype.toString.call(target) === '[object Array]'){
for (const key in target) {
clone[key] = deepClone(target[key],map)
}
}
return clone;
}
return target;
}
好多教程使用 WeakMap 做儲存,相比Map,WeakMap好在哪兒?
通往優秀的階梯
以上我們就完成了一個基礎的深拷貝。但是它僅僅是及格而已,想要做到優秀,還要處理一下之前留下的幾個問題。
獲取Symbol屬性
ES6Symbol
型別也可以作為物件的 key ,但是for...in
和Object.keys(target)
都拿不到 Symbol
型別的屬性名。
好在我們可以通過Object.getOwnPropertySymbols(target)
獲取物件上所有的Symbol
屬性,再結合for...in
、Object.keys()
就能夠拿到全部的 key。不過這種方式有些麻煩,有沒有更好用的方法?
有!Reflect.ownKeys(target)
正是這樣一個集優雅與強大與一身的方法。但是正如同人無完人,這個方法也不完美:顧名思義,ownKeys
是拿不到原型鏈上的屬性的。所以需要結合具體場景來組合使用上述方法。
特殊的內建型別
Date
、Error
等特殊的內建型別雖然是物件,但是並不能遍歷屬性,所以針對這些型別需要重新呼叫對應的構造器進行初始化。JavaScript 內建了許多類似的特殊型別,然而我們並不是無情的 API 機器,面試中能夠回答上述要點也就足夠了。
上述內建型別我們都可以通過Object.prototype.toString.call(target)
的方式拿到,所以這裡可以封裝一個型別判斷的方法用於判斷target
是否能夠繼續遍歷,以便於及後續的處理。
然而 ES6 新增了Symbol.toStringTag
方法,可以用來自定義類名,這就導致 Object.prototype.toString.call(target)
拿到的型別名也可能不夠準確:
class ValidatorClass {
get [Symbol.toStringTag]() {
return "Validator";
}
}
Object.prototype.toString.call(new ValidatorClass());
// "[object Validator]"
使用WeakMap做迴圈檢測,比使用Map好在哪兒?
原生的WeakMap
持有的是每個鍵物件的“弱引用”,這意味著在沒有其他引用存在時垃圾回收能正確進行。如果 target 非常龐大,那麼使用Map
後如果沒有進行手動釋放,這塊記憶體就會持續的被佔用。而WeakMap
則不需要擔心這個問題。
後記
如果上面幾個問題都得到了妥善的處理,那麼這樣的深拷貝就可以說是一個足夠打動面試官的深拷貝了。當然這個深拷貝還不夠優秀,有很多待完善的地方,相信善於思考的你已經有了自己的思路。
但本文的重點並不單單是實現一個深拷貝,更多的是希望它能夠幫助你更好的理解面試官的思路,從而更好的發揮自身的能力。
參考資料