前言
對(duì)象是 JS 中基本類型之一,而且和原型鏈够庙、數(shù)組等知識(shí)息息相關(guān)晶衷。不管是面試中蓝纲,還是實(shí)際開發(fā)中我們都會(huì)碰見深拷貝對(duì)象的問題。
顧名思義晌纫,深拷貝就是完完整整的將一個(gè)對(duì)象從內(nèi)存中拷貝一份出來税迷。所以無論用什么辦法,必然繞不開開辟一塊新的內(nèi)存空間锹漱。
通常有下面兩種方法實(shí)現(xiàn)深拷貝:
- 迭代遞歸法
- 序列化反序列化法
我們會(huì)基于一個(gè)測(cè)試用例對(duì)常用的實(shí)現(xiàn)方法進(jìn)行測(cè)試并對(duì)比優(yōu)劣:
let test = {
num: 0,
str: '',
boolean: true,
unf: undefined,
nul: null,
obj: {
name: '我是一個(gè)對(duì)象',
id: 1
},
arr: [0, 1, 2],
func: function() {
console.log('我是一個(gè)函數(shù)')
},
date: new Date(0),
reg: new RegExp('/我是一個(gè)正則/ig'),
err: new Error('我是一個(gè)錯(cuò)誤')
}
let result = deepClone(test)
console.log(result)
for (let key in result) {
if (isObject(result[key]))
console.log(`${key}相同嗎箭养? `, result[key] === test[key])
}
// 判斷是否為對(duì)象
function isObject(o) {
return (typeof o === 'object' || typeof o === 'function') && o !== null
}
迭代遞歸法
這是最常規(guī)的方法,思想很簡單:就是對(duì)對(duì)象進(jìn)行迭代操作哥牍,對(duì)它的每個(gè)值進(jìn)行遞歸深拷貝毕泌。
for...in 法
// 迭代遞歸法:深拷貝對(duì)象與數(shù)組
function deepClone(obj) {
if (!isObject(obj)) {
throw new Error('obj 不是一個(gè)對(duì)象!')
}
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
}
結(jié)果:
我們發(fā)現(xiàn)嗅辣,arr 和 obj 都深拷貝成功了懈词,它們的內(nèi)存引用已經(jīng)不同了,但 func辩诞、date、reg 和 err 并沒有復(fù)制成功纺涤,因?yàn)樗鼈冇刑厥獾臉?gòu)造函數(shù)译暂。
Reflect 法
// 代理法
function deepClone(obj) {
if (!isObject(obj)) {
throw new Error('obj 不是一個(gè)對(duì)象抠忘!')
}
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
}
結(jié)果:
我們發(fā)現(xiàn),結(jié)果和使用 for...in 一樣外永。那么它有什么優(yōu)點(diǎn)呢崎脉?讀者可以先猜一猜,答案我們會(huì)在下文揭曉伯顶。
lodash中的深拷貝實(shí)現(xiàn)
著名的 lodash 中的 cloneDeep 方法同樣是使用這種方法實(shí)現(xiàn)的囚灼,只不過它支持的對(duì)象種類更多,具體的實(shí)現(xiàn)過程讀者可以參考 lodash 的 baseClone 方法祭衩。
我們把測(cè)試用例用到的深拷貝函數(shù)換成lodash的:
let result = _.cloneDeep(test)
結(jié)果:
我們發(fā)現(xiàn)灶体,arr、obj掐暮、date蝎抽、reg深拷貝成功了,但 func 和 err 內(nèi)存引用仍然不變路克。
為什么不變呢樟结?這個(gè)問題留給讀者自己去探尋,嘿嘿~不過可以提示下精算,這跟 lodash 中的 cloneableTags 有關(guān)瓢宦。
由于前端中的對(duì)象種類太多了,所以 lodash 也給用戶準(zhǔn)備了自定義深拷貝的方法 cloneDeepWith灰羽,比如自定義深拷貝 DOM 對(duì)象:
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
序列化反序列化法
這個(gè)方法非常有趣驮履,它先把代碼序列化成數(shù)據(jù),再反序列化回對(duì)象:
// 序列化反序列化法
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj))
}
結(jié)果:
我們發(fā)現(xiàn)谦趣,它也只能深拷貝對(duì)象和數(shù)組疲吸,對(duì)于其他種類的對(duì)象,會(huì)失真前鹅。這種方法比較適合平常開發(fā)中使用摘悴,因?yàn)橥ǔ2恍枰紤]對(duì)象和數(shù)組之外的類型。
進(jìn)階
- 對(duì)象成環(huán)怎么辦舰绘?
我們給 test 加一個(gè) loopObj 鍵蹂喻,值指向自身:
test.loopObj = test
這時(shí)我們使用第一種方法中的 for..in 實(shí)現(xiàn)和 Reflect 實(shí)現(xiàn)都會(huì)棧溢出:
而使用第二種方法也會(huì)報(bào)錯(cuò):
但 lodash 卻可以得到正確結(jié)果:
為什么呢?我們?nèi)?lodash 源碼看看:
因?yàn)?lodash 使用的是棧把對(duì)象存儲(chǔ)起來了捂寿,如果有環(huán)對(duì)象口四,就會(huì)從棧里檢測(cè)到,從而直接返回結(jié)果秦陋,懸崖勒馬蔓彩。這種算法思想來源于 HTML5 規(guī)范定義的結(jié)構(gòu)化克隆算法,它同時(shí)也解釋了為什么 lodash 不對(duì) Error 和 Function 類型進(jìn)行拷貝。
當(dāng)然赤嚼,設(shè)置一個(gè)哈希表存儲(chǔ)已拷貝過的對(duì)象同樣可以達(dá)到同樣的目的:
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 ? [] : {}
// 哈希表設(shè)值
hash.set(obj, cloneObj)
let result = Object.keys(obj).map(key => {
return {
[key]: deepClone(obj[key], hash)
}
})
return Object.assign(cloneObj, ...result)
}
這里我們使用 WeakMap 作為哈希表旷赖,因?yàn)樗逆I是弱引用的,而我們這個(gè)場(chǎng)景里鍵恰好是對(duì)象更卒,需要弱引用等孵。
- 鍵值不是字符串而是 Symbol
我們修改一下測(cè)試用例:
var test = {}
let sym = Symbol('我是一個(gè)Symbol')
test[sym] = 'symbol'
let result = deepClone(test)
console.log(result)
console.log(result[sym] === test[sym])
運(yùn)行 for...in 實(shí)現(xiàn)的深拷貝我們會(huì)發(fā)現(xiàn):
拷貝失敗了,為什么蹂空?
因?yàn)?Symbol 是一種特殊的數(shù)據(jù)類型俯萌,它最大的特點(diǎn)便是獨(dú)一無二,所以它的深拷貝就是淺拷貝上枕。
但如果這時(shí)我們使用 Reflect 實(shí)現(xiàn)的版本:
成功了咐熙,因?yàn)?for...in 無法獲得 Symbol 類型的鍵,而 Reflect 是可以獲取的姿骏。
當(dāng)然糖声,我們改造一下 for...in 實(shí)現(xiàn)也可以:
function deepClone(obj) {
if (!isObject(obj)) {
throw new Error('obj 不是一個(gè)對(duì)象!')
}
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 對(duì)象是基于原型鏈設(shè)計(jì)的蘸泻,所以當(dāng)一個(gè)對(duì)象的屬性查找不到時(shí)會(huì)沿著它的原型鏈向上查找,也就是一個(gè)非構(gòu)造函數(shù)對(duì)象的 __proto__ 屬性嘲玫。
我們創(chuàng)建一個(gè) childTest 變量悦施,讓 result 為它的深拷貝結(jié)果,其他不變:
let childTest = Object.create(test)
let result = deepClone(childTest)
這時(shí)去团,我們最初提供的四種實(shí)現(xiàn)只有 for...in 的實(shí)現(xiàn)能正確拷貝抡诞,為什么呢?原因還是在結(jié)構(gòu)化克隆算法里:原形鏈上的屬性也不會(huì)被追蹤以及復(fù)制土陪。
落在具體實(shí)現(xiàn)上就是:for...in 會(huì)追蹤原型鏈上的屬性昼汗,而其它三種方法(Object.keys、Reflect.ownKeys 和 JSON 方法)都不會(huì)追蹤原型鏈上的屬性:
- 需要拷貝不可枚舉的屬性
第四種情況鬼雀,就是我們需要拷貝類似屬性描述符顷窒,setters 以及 getters 這樣不可枚舉的屬性,一般來說源哩,這就需要一個(gè)額外的不可枚舉的屬性集合來存儲(chǔ)它們鞋吉。類似在第二種情況使用 for...in 拷貝 Symbol 類型鍵時(shí):
我們給 test 變量里的 obj 和 arr 屬性定義一下屬性描述符:
Object.defineProperties(test, {
'obj': {
writable: false,
enumerable: false,
configurable: false
},
'arr': {
get() {
console.log('調(diào)用了get')
return [1,2,3]
},
set(val) {
console.log('調(diào)用了set')
}
}
})
然后實(shí)現(xiàn)我們的拷貝不可枚舉屬性的版本:
function deepClone(obj, hash = new WeakMap()) {
if (!isObject(obj)) {
return obj
}
// 查表,防止循環(huán)拷貝
if (hash.has(obj)) return hash.get(obj)
let isArray = Array.isArray(obj)
// 初始化拷貝對(duì)象
let cloneObj = isArray ? [] : {}
// 哈希表設(shè)值
hash.set(obj, cloneObj)
// 獲取源對(duì)象所有屬性描述符
let allDesc = Object.getOwnPropertyDescriptors(obj)
// 獲取源對(duì)象所有的 Symbol 類型鍵
let symKeys = Object.getOwnPropertySymbols(obj)
// 拷貝 Symbol 類型鍵對(duì)應(yīng)的屬性
if (symKeys.length > 0) {
symKeys.forEach(symKey => {
cloneObj[symKey] = isObject(obj[symKey]) ? deepClone(obj[symKey], hash) : obj[symKey]
})
}
// 拷貝不可枚舉屬性,因?yàn)?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
}
結(jié)果:
結(jié)語
- 日常深拷貝谓着,建議序列化反序列化方法。
- 面試時(shí)遇見面試官搞事情坛掠,寫一個(gè)能拷貝自身可枚舉赊锚、自身不可枚舉治筒、自身 Symbol 類型鍵、原型上可枚舉改抡、原型上不可枚舉矢炼、原型上的 Symol 類型鍵,循環(huán)引用也可以拷的深拷貝函數(shù):
// 將之前寫的 deepClone 函數(shù)封裝一下
function cloneDeep(obj) {
let family = {}
let parent = Object.getPrototypeOf(obj)
while (parent != null) {
family = completeAssign(deepClone(family), parent)
parent = Object.getPrototypeOf(parent)
}
// 下面這個(gè)函數(shù)會(huì)拷貝所有自有屬性的屬性描述符,來自于 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 默認(rèn)也會(huì)拷貝可枚舉的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 方法。
最后感謝一下知乎上關(guān)于這個(gè)問題的提問的啟發(fā)夷陋,無論做什么欠拾,盡量不要把簡單的事情復(fù)雜化,深拷貝能不用就不用骗绕,它面對(duì)的問題往往可以用更優(yōu)雅的方式解決藐窄,當(dāng)然面試的時(shí)候裝個(gè)逼是可以的。