前言
物件是 JS 中基本型別之一,而且和原型鏈、陣列等知識息息相關。不管是面試中,還是實際開發中我們都會碰見深拷貝物件的問題。
顧名思義,深拷貝就是完完整整的將一個物件從記憶體中拷貝一份出來。所以無論用什麼辦法,必然繞不開開闢一塊新的記憶體空間。
通常有下面兩種方法實現深拷貝:
- 迭代遞迴法
- 序列化反序列化法
我們會基於一個測試用例對常用的實現方法進行測試並對比優劣:
let test = {
num: 0,
str: '',
boolean: true,
unf: undefined,
nul: null,
obj: {
name: '我是一個物件',
id: 1
},
arr: [0, 1, 2],
func: function() {
console.log('我是一個函式')
},
date: new Date(0),
reg: new RegExp('/我是一個正則/ig'),
err: new Error('我是一個錯誤')
}
let result = deepClone(test)
console.log(result)
for (let key in result) {
if (isObject(result[key]))
console.log(`${key}相同嗎? `, result[key] === test[key])
}
// 判斷是否為物件
function isObject(o) {
return (typeof o === 'object' || typeof o === 'function') && o !== null
}
複製程式碼
1. 迭代遞迴法
這是最常規的方法,思想很簡單:就是對物件進行迭代操作,對它的每個值進行遞迴深拷貝。
-
for...in 法
// 迭代遞迴法:深拷貝物件與陣列
function deepClone(obj) {
if (!isObject(obj)) {
throw new Error('obj 不是一個物件!')
}
let isArray = Array.isArray(obj)
let cloneObj = isArray ? [] : {}
for (let key in obj) {
cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
}
return cloneObj
}
複製程式碼
結果:
複製程式碼
我們發現,arr 和 obj 都深拷貝成功了,它們的記憶體引用已經不同了,但 func、date、reg 和 err 並沒有複製成功,因為它們有特殊的建構函式。
複製程式碼
-
Reflect 法
// 代理法
function deepClone(obj) {
if (!isObject(obj)) {
throw new Error('obj 不是一個物件!')
}
let isArray = Array.isArray(obj)
let cloneObj = isArray ? [...obj] : { ...obj }
Reflect.ownKeys(cloneObj).forEach(key => {
cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
})
return cloneObj
}
複製程式碼
結果:
複製程式碼
我們發現,結果和使用 for...in 一樣。那麼它有什麼優點呢?讀者可以先猜一猜,答案我們會在下文揭曉。
複製程式碼
-
lodash 中的深拷貝
著名的 lodash 中的 cloneDeep 方法同樣是使用這種方法實現的,只不過它支援的物件種類更多,具體的實現過程讀者可以參考 lodash 的 baseClone 方法。
我們把測試用例用到的深拷貝函式換成 lodash 的:
let result = _.cloneDeep(test) 複製程式碼
結果:
我們發現,arr、obj、date、reg深拷貝成功了,但 func 和 err 記憶體引用仍然不變。
為什麼不變呢?這個問題留給讀者自己去探尋,嘿嘿~不過可以提示下,這跟 lodash 中的 cloneableTags 有關。
由於前端中的物件種類太多了,所以 lodash 也給使用者準備了自定義深拷貝的方法 cloneDeepWith,比如自定義深拷貝 DOM 物件:
function customizer(value) { if (_.isElement(value)) { return value.cloneNode(true); } } var el = _.cloneDeepWith(document.body, customizer); console.log(el === document.body); // => false console.log(el.nodeName); // => 'BODY' console.log(el.childNodes.length); // => 20 複製程式碼
2.序列化反序列化法
這個方法非常有趣,它先把程式碼序列化成資料,再反序列化回物件:
// 序列化反序列化法
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj))
}
複製程式碼
結果:
我們發現,它也只能深拷貝物件和陣列,對於其他種類的物件,會失真。這種方法比較適合平常開發中使用,因為通常不需要考慮物件和陣列之外的型別。深入深入再深入
-
物件成環怎麼辦? 我們給 test 加一個 loopObj 鍵,值指向自身:
test.loopObj = test 複製程式碼
這時我們使用第一種方法中的 for..in 實現和 Reflect 實現都會棧溢位:
而使用第二種方法也會報錯:
但 lodash 卻可以得到正確結果:
為什麼呢?我們去 lodash 原始碼看看:
因為 lodash 使用的是棧把物件儲存起來了,如果有環物件,就會從棧裡檢測到,從而直接返回結果,懸崖勒馬。這種演算法思想來源於 HTML5 規範定義的結構化克隆演算法,它同時也解釋了為什麼 lodash 不對 Error 和 Function 型別進行拷貝。
當然,設定一個雜湊表儲存已拷貝過的物件同樣可以達到同樣的目的:
function deepClone(obj, hash = new WeakMap()) { if (!isObject(obj)) { return obj } // 查表 if (hash.has(obj)) return hash.get(obj) let isArray = Array.isArray(obj) let cloneObj = isArray ? [] : {} // 雜湊表設值 hash.set(obj, cloneObj) let result = Object.keys(obj).map(key => { return { [key]: deepClone(obj[key], hash) } }) return Object.assign(cloneObj, ...result) } 複製程式碼
這裡我們使用 WeakMap 作為雜湊表,因為它的鍵是弱引用的,而我們這個場景裡鍵恰好是物件,需要弱引用。
-
鍵不是字串而是 Symbol
我們修改一下測試用例:
var test = {} let sym = Symbol('我是一個Symbol') test[sym] = 'symbol' let result = deepClone(test) console.log(result) console.log(result[sym] === test[sym]) 複製程式碼
執行 for...in 實現的深拷貝我們會發現:
拷貝失敗了,為什麼?
因為 Symbol 是一種特殊的資料型別,它最大的特點便是獨一無二,所以它的深拷貝就是淺拷貝。
但如果這時我們使用 Reflect 實現的版本:
成功了,因為 for...in 無法獲得 Symbol 型別的鍵,而 Reflect 是可以獲取的。
當然,我們改造一下 for...in 實現也可以:
function deepClone(obj) { if (!isObject(obj)) { throw new Error('obj 不是一個物件!') } let isArray = Array.isArray(obj) let cloneObj = isArray ? [] : {} let symKeys = Object.getOwnPropertySymbols(obj) // console.log(symKey) if (symKeys.length > 0) { symKeys.forEach(symKey => { cloneObj[symKey] = isObject(obj[symKey]) ? deepClone(obj[symKey]) : obj[symKey] }) } for (let key in obj) { cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key] } return cloneObj } 複製程式碼
-
拷貝原型上的屬性
眾所周知,JS 物件是基於原型鏈設計的,所以當一個物件的屬性查詢不到時會沿著它的原型鏈向上查詢,也就是一個非建構函式物件的 __proto__ 屬性。
我們建立一個 childTest 變數,讓 result 為它的深拷貝結果,其他不變:
let childTest = Object.create(test) let result = deepClone(childTest) 複製程式碼
這時,我們最初提供的四種實現只有 for...in 的實現能正確拷貝,為什麼呢?原因還是在結構化克隆演算法裡:原形鏈上的屬性也不會被追蹤以及複製。
落在具體實現上就是:for...in 會追蹤原型鏈上的屬性,而其它三種方法(Object.keys、Reflect.ownKeys 和 JSON 方法)都不會追蹤原型鏈上的屬性:
-
需要拷貝不可列舉的屬性
第四種情況,就是我們需要拷貝類似屬性描述符,setters 以及 getters 這樣不可列舉的屬性,一般來說,這就需要一個額外的不可列舉的屬性集合來儲存它們。類似在第二種情況使用 for...in 拷貝 Symbol 型別鍵時: 我們給 test 變數裡的 obj 和 arr 屬性定義一下屬性描述符:
Object.defineProperties(test, { 'obj': { writable: false, enumerable: false, configurable: false }, 'arr': { get() { console.log('呼叫了get') return [1,2,3] }, set(val) { console.log('呼叫了set') } } }) 複製程式碼
然後實現我們的拷貝不可列舉屬性的版本:
function deepClone(obj, hash = new WeakMap()) { if (!isObject(obj)) { return obj } // 查表,防止迴圈拷貝 if (hash.has(obj)) return hash.get(obj) let isArray = Array.isArray(obj) // 初始化拷貝物件 let cloneObj = isArray ? [] : {} // 雜湊表設值 hash.set(obj, cloneObj) // 獲取源物件所有屬性描述符 let allDesc = Object.getOwnPropertyDescriptors(obj) // 獲取源物件所有的 Symbol 型別鍵 let symKeys = Object.getOwnPropertySymbols(obj) // 拷貝 Symbol 型別鍵對應的屬性 if (symKeys.length > 0) { symKeys.forEach(symKey => { cloneObj[symKey] = isObject(obj[symKey]) ? deepClone(obj[symKey], hash) : obj[symKey] }) } // 拷貝不可列舉屬性,因為 allDesc 的 value 是淺拷貝,所以要放在前面 cloneObj = Object.create( Object.getPrototypeOf(cloneObj), allDesc ) // 拷貝可列舉屬性(包括原型鏈上的) for (let key in obj) { cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key], hash) : obj[key]; } return cloneObj } 複製程式碼
結果:
結語
- 日常深拷貝,建議序列化反序列化方法。
- 面試時遇見面試官搞事情,寫一個能拷貝自身可列舉、自身不可列舉、自身 Symbol 型別鍵、原型上可列舉、原型上不可列舉、原型上的 Symol 型別鍵,迴圈引用也可以拷的深拷貝函式:
// 將之前寫的 deepClone 函式封裝一下
function cloneDeep(obj) {
let family = {}
let parent = Object.getPrototypeOf(obj)
while (parent != null) {
family = completeAssign(deepClone(family), parent)
parent = Object.getPrototypeOf(parent)
}
// 下面這個函式會拷貝所有自有屬性的屬性描述符,來自於 MDN
// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
function completeAssign(target, ...sources) {
sources.forEach(source => {
let descriptors = Object.keys(source).reduce((descriptors, key) => {
descriptors[key] = Object.getOwnPropertyDescriptor(source, key)
return descriptors
}, {})
// Object.assign 預設也會拷貝可列舉的Symbols
Object.getOwnPropertySymbols(source).forEach(sym => {
let descriptor = Object.getOwnPropertyDescriptor(source, sym)
if (descriptor.enumerable) {
descriptors[sym] = descriptor
}
})
Object.defineProperties(target, descriptors)
})
return target
}
return completeAssign(deepClone(obj), family)
}
複製程式碼
-
有特殊需求的深拷貝,建議使用 lodash 的 copyDeep 或 copyDeepWith 方法。
最後感謝一下知乎上關於這個問題的啟發,無論做什麼,儘量不要把簡單的事情複雜化,深拷貝能不用就不用,它面對的問題往往可以用更優雅的方式解決,比如使用一個函式來得到物件,當然面試的時候裝個逼是可以的。