【每日一題】深拷貝時(shí)有循環(huán)引用怎么解決?

人生苦短,總需要一點(diǎn)儀式感。比如學(xué)前端~
深拷貝
為什么要深拷貝?
怕有些剛基礎(chǔ)的同學(xué)對(duì)深拷貝不理解,先解釋下。懂得跳過(guò)。
之前我們說(shuō)過(guò)JS中的數(shù)據(jù)類(lèi)型分為兩大類(lèi):“原始類(lèi)型”和“引用類(lèi)型”。并且也解釋了這兩種類(lèi)型數(shù)據(jù)的拷貝實(shí)現(xiàn)有不同的地方,還不了解的同學(xué)可以先看下這篇文章《【每日一題】你可以系統(tǒng)說(shuō)說(shuō)JS中都有哪些數(shù)據(jù)類(lèi)型嗎?》。
淺拷貝
對(duì)應(yīng)的還有淺拷貝,淺拷貝就是簡(jiǎn)單的賦值拷貝。平時(shí)JS中的很多帶有拷貝功能的函數(shù)只要不特殊處理基本都是淺拷貝(比如數(shù)組的slice、concat等)

這樣拷貝的問(wèn)題:我新數(shù)組arr1的內(nèi)容改了,源數(shù)組arr也被改了。
深拷貝
深拷貝函數(shù)就是解決上圖問(wèn)題的。主要用于拷貝引用類(lèi)型的數(shù)據(jù)(實(shí)際應(yīng)用中,主要是對(duì)象和數(shù)組),好讓拷貝的對(duì)象和源對(duì)象脫離干系,修改數(shù)據(jù)不互相影響。
深拷貝實(shí)現(xiàn)
深拷貝的寫(xiě)法雖然百花齊放,但是內(nèi)部原理基本一致:
都是先判斷類(lèi)型,基本類(lèi)型直接返回;引用類(lèi)型再判斷是數(shù)組還是對(duì)象。如果是對(duì)象就創(chuàng)建對(duì)象、并把待拷貝對(duì)象的各個(gè)鍵值對(duì)遞歸的拷貝一遍;如果是數(shù)組就創(chuàng)建數(shù)組、并把目標(biāo)數(shù)組的每一項(xiàng)遞歸克隆push到新數(shù)組中。
實(shí)現(xiàn)原理簡(jiǎn)而言之就是:遞歸 + 類(lèi)型判斷 + 遍歷賦值。(淺拷貝實(shí)現(xiàn)上就沒(méi)有遞歸類(lèi)型判斷這一層了)
首先,讓我們先實(shí)現(xiàn)一個(gè)深拷貝函數(shù),用于拷貝引用類(lèi)型的數(shù)據(jù)(實(shí)際應(yīng)用中,主要是對(duì)象和數(shù)組)。
/**
* 深克隆(深拷貝)
* @param {object} origin 待克隆的數(shù)據(jù)
* @returns origin / 克隆的origin
*/
function deepClone(origin) {
let result = null;
if (typeof origin === 'object' && origin !== null) { // 【類(lèi)型判斷】引用類(lèi)型,進(jìn)行遞歸拷貝(用typeof判斷類(lèi)型要剔除null的情況)
if (Object.prototype.toString.call(origin) === '[object Array]') {
// 【類(lèi)型判斷】數(shù)組類(lèi)型,創(chuàng)建一個(gè)新數(shù)組
result = [];
// 【遍歷賦值】
origin.forEach(el => {
result.push(deepClone(el)); // 【遞歸】
});
} else {
// 【類(lèi)型判斷】對(duì)象類(lèi)型,創(chuàng)建一個(gè)新對(duì)象
result = {};
for (const key in origin) {
// 【遍歷賦值】對(duì)象這里特殊處理了,不遍歷拷貝原型鏈上的屬性
if (origin.hasOwnProperty(key)) {
result[key] = deepClone(origin[key]); // 【遞歸】
}
}
}
} else { // 【類(lèi)型判斷】原始類(lèi)型直接返回
return origin;
}
return result;
}
使用
let obj = {
name: '深拷貝測(cè)試',
age: 19,
hobby: ['sing', 'sing', 'sing'],
info: { address: '北京', email: 'whgsn.bh.com' },
}
let objClone = deepClone(obj);
console.log(objClone); // 克隆出一個(gè)新的對(duì)象
objClone.hobby[1] = 'painting'
console.log(obj.hobby) // 修改新對(duì)象的引用值后,目標(biāo)原對(duì)象都沒(méi)有被改變。我們實(shí)現(xiàn)了深拷貝!
嘗試修改新對(duì)象,原對(duì)象不會(huì)被同步修改:

問(wèn)題-循環(huán)引用導(dǎo)致棧溢出
上邊的例子目前看很正常,但是當(dāng)我們?cè)瓕?duì)象內(nèi)的屬性值有循環(huán)引用時(shí),再次使用深拷貝會(huì)引起問(wèn)題:
let obj2 = {
name: '循環(huán)引用測(cè)試'
}
obj2.cycle = obj2
let objClone2 = deepClone(obj2); // 報(bào)錯(cuò)棧溢出 Uncaught RangeError: Maximum call stack size exceeded

從例子可以看到,深拷貝目標(biāo)原對(duì)象時(shí)沒(méi)有問(wèn)題,但當(dāng)目標(biāo)原對(duì)象存在循環(huán)引用時(shí)進(jìn)行深拷貝會(huì)報(bào)錯(cuò)“棧溢出”。
棧溢出
ECStack執(zhí)行環(huán)境棧又叫調(diào)用棧,是JS引擎為了運(yùn)行js代碼在計(jì)算機(jī)中開(kāi)辟的一塊棧內(nèi)存空間,用來(lái)管理函數(shù)調(diào)用關(guān)系。棧內(nèi)存遵循先進(jìn)后出的規(guī)律,并且這段棧內(nèi)存空間是有限的。
當(dāng)我們進(jìn)行函數(shù)調(diào)用的時(shí)候,JS引擎會(huì)創(chuàng)建一個(gè)執(zhí)行上下文并推進(jìn)調(diào)用棧中供函數(shù)執(zhí)行。當(dāng)函數(shù)執(zhí)行完畢,這個(gè)執(zhí)行上下文會(huì)自動(dòng)推出執(zhí)行棧、釋放空間。
但是當(dāng)我們執(zhí)行上邊的代碼,因?yàn)樯羁寺?huì)進(jìn)行遞歸函數(shù)調(diào)用,在深層遞歸內(nèi)的deepClone函數(shù)沒(méi)執(zhí)行完畢前,先推進(jìn)執(zhí)行棧的函數(shù)就不能結(jié)束、不能出棧。
又因?yàn)槲覀儸F(xiàn)在obj2循環(huán)引用了自己,我們的deepClone就要不停的循環(huán)遞歸。這就導(dǎo)致了一直往棧中塞數(shù)據(jù)、但從不釋放。??臻g就像一個(gè)水杯一樣,被我們一直“加水”直至“溢出”并報(bào)錯(cuò)。

圖源網(wǎng)絡(luò)
循環(huán)引用問(wèn)題解決方案
思路
解決循環(huán)引用導(dǎo)致的棧溢出問(wèn)題,就需要我們判斷要拷貝的對(duì)象,是不是已經(jīng)拷貝過(guò),而不要循環(huán)拷貝。
我們可以利用緩存的思想,額外創(chuàng)建一個(gè)哈希映射表(字典集合,其實(shí)就是一個(gè)緩存對(duì)象),來(lái)存儲(chǔ)當(dāng)前對(duì)象和拷貝對(duì)象的對(duì)應(yīng)關(guān)系。哈希映射表需要key: value這種鍵值對(duì)結(jié)構(gòu),并且要滿(mǎn)足key可能是引用類(lèi)型的要求(一般情況下key是字符串類(lèi)型)。
后續(xù)使用時(shí),每拷貝一個(gè)引用值,就記錄(緩存)到集合中。下次拷貝時(shí),先檢查是否在緩存中:
若有,直接取緩存;
若無(wú),實(shí)行拷貝并緩存。
特別的,這個(gè)緩存的思路也是瀏覽器緩存、也是Node require緩存、也是DNS緩存、也是代理服務(wù)器緩存等等各種緩存都有的一個(gè)套路。其實(shí)掌握了核心技巧后,其他相關(guān)知識(shí)點(diǎn)就是舊瓶裝新酒、換湯不換藥了。
解決方向
因?yàn)槲覀儗?shí)際是要創(chuàng)建一個(gè)緩存對(duì)象,但我們的key又要可以是引用類(lèi)型。
JS提供的原生對(duì)象雖然就是鍵值對(duì)的集合,但是傳統(tǒng)上對(duì)象只能用字符串當(dāng)作鍵。不太滿(mǎn)足我們的條件。
為此,我們的解決方向上,可以考慮用ES6新增的數(shù)據(jù)結(jié)構(gòu):Map、WeakMap
?ES6 提供了
?Map數(shù)據(jù)結(jié)構(gòu)。它類(lèi)似于對(duì)象,也是鍵值對(duì)的集合,但是“鍵”的范圍不限于字符串,各種類(lèi)型的值(包括對(duì)象)都可以當(dāng)作鍵。也就是說(shuō),Object 結(jié)構(gòu)提供了“字符串—值”的對(duì)應(yīng),Map 結(jié)構(gòu)提供了“值—值”的對(duì)應(yīng),是一種更完善的 Hash 結(jié)構(gòu)實(shí)現(xiàn)。如果你需要“鍵值對(duì)”的數(shù)據(jù)結(jié)構(gòu),Map 比 Object 更合適。
WeakMap結(jié)構(gòu)與Map結(jié)構(gòu)類(lèi)似,也是用于生成鍵值對(duì)的集合。
來(lái)源:https://es6.ruanyifeng.com/#docs/set-map#Map
但是WeakMap比Map有兩個(gè)不同:
1、【特殊點(diǎn)】WeakMap只接受引用類(lèi)型(對(duì)象)作為鍵名;
2、【優(yōu)點(diǎn)】WeakMap的鍵名所指向的對(duì)象都是弱引用,不計(jì)入垃圾回收機(jī)制,不用考慮內(nèi)存泄漏。 當(dāng)引用對(duì)象被清除,其所對(duì)應(yīng)的WeakMap記錄就會(huì)自動(dòng)被移除。(具體請(qǐng)看ES6相關(guān)解釋?zhuān)@里不展開(kāi)。)
簡(jiǎn)而言之就是:Map是高級(jí)版的Object,WeakMap是高級(jí)版的Map。

weakMap鍵名
Map / weakMap
綜合所有考量,我們啟用最優(yōu)解——WeakMap來(lái)實(shí)現(xiàn)這個(gè)緩存字典。
偽代碼思路:
1. 檢查 weakMap 中有無(wú)克隆過(guò)的對(duì)象。
2. 有,直接返回
3. 沒(méi)有,將當(dāng)前對(duì)象作為key,克隆對(duì)象作為value進(jìn)行存儲(chǔ)
4. 繼續(xù)克隆
/**
* 深克?。ㄉ羁截悾? 解決深拷貝函數(shù)中循環(huán)引用時(shí)導(dǎo)致的棧溢出的問(wèn)題
* @param {object} origin
* @param {*} hashMap WeakMap數(shù)據(jù),用于緩存克隆過(guò)的對(duì)象
* @returns origin / 克隆的origin
*/
function deepCloneCycle(origin, hashMap = new WeakMap()) {
let result = null;
if (hashMap.has(origin)) return hashMap.get(origin); // 查緩存字典中是否已有需要克隆的對(duì)象,有的話(huà)直接返回同一個(gè)對(duì)象(同一個(gè)引用,不用遞歸無(wú)限創(chuàng)建進(jìn)而導(dǎo)致棧溢出了)
if (typeof origin === 'object' && origin !== null) { // 【類(lèi)型判斷】引用類(lèi)型,進(jìn)行遞歸拷貝(用typeof判斷類(lèi)型要剔除null的情況)
if (Object.prototype.toString.call(origin) === '[object Array]') {
// 【類(lèi)型判斷】數(shù)組類(lèi)型,創(chuàng)建一個(gè)新數(shù)組
result = [];
hashMap.set(origin, result); // 哈希表緩存新值
// 【遍歷賦值】
origin.forEach(el => {
result.push(deepCloneCycle(el, hashMap)); // 【遞歸】
});
} else {
// 【類(lèi)型判斷】對(duì)象類(lèi)型,創(chuàng)建一個(gè)新對(duì)象
result = {};
hashMap.set(origin, result); // 哈希表緩存新值
for (const key in origin) {
// 【遍歷賦值】對(duì)象這里特殊處理了,不遍歷拷貝原型鏈上的屬性
if (origin.hasOwnProperty(key)) {
result[key] = deepCloneCycle(origin[key], hashMap); // 【遞歸】
}
}
}
} else { // 【類(lèi)型判斷】原始類(lèi)型直接返回
return origin;
}
return result;
}
源代碼見(jiàn)“閱讀原文“。如果github沒(méi)找到,那應(yīng)該是我還沒(méi)把本地的代碼傳上去。。。

讓我們一起攜手同走前端路!
關(guān)注公眾號(hào)回復(fù)【加群】即可

● 工作中常見(jiàn)頁(yè)面布局的n種實(shí)現(xiàn)方法
● 三欄響應(yīng)式布局(左右固寬中間自適應(yīng))的5種方法
● 兩欄自適應(yīng)布局的n種實(shí)現(xiàn)方法匯總
