一:為什么要實(shí)現(xiàn)深度克麓煞?
這是一個(gè)前端面試經(jīng)常問(wèn)到的問(wèn)題割坠,并且在知乎上我看到很多的前端大神也都探討過(guò)齐帚。這個(gè)問(wèn)題背后的考察點(diǎn)相當(dāng)豐富,涉及JS的數(shù)據(jù)類型彼哼、數(shù)據(jù)存儲(chǔ)对妄、內(nèi)存管理。還涉及很多邊界條件的考慮敢朱,很具有代表性剪菱。所以為了鞏固這個(gè)這些知識(shí)點(diǎn),查閱了很多資料拴签,整理一篇文章孝常,供學(xué)習(xí)交流使用,如有不足之處蚓哩,歡迎指正构灸。
二:JavaScript中的內(nèi)存管理
JS內(nèi)存管理,往深了挖很復(fù)雜岸梨,這里只做簡(jiǎn)單的介紹喜颁,幫助理解js的基本類型和引用類型稠氮,為了后面講解深度克隆做鋪墊,我們知道JS擁有自動(dòng)的垃圾回收機(jī)制半开,這樣就使得很多前端開發(fā)人員不是很重視內(nèi)存管理這一塊隔披。但是其實(shí)這一部分的內(nèi)容對(duì)于理解JS中原型與原型鏈,閉包寂拆,遞歸都是非常有幫助的奢米。
在JS中,每一個(gè)數(shù)據(jù)都需要一個(gè)內(nèi)存空間漓库。內(nèi)存空間又被分為兩種:
棧內(nèi)存(stock)
堆內(nèi)存(heap)
-
基礎(chǔ)數(shù)據(jù)類型和棧內(nèi)存
JS中的基礎(chǔ)數(shù)據(jù)類型恃慧,我們也稱之為原始數(shù)據(jù)類型,這些值都有固定的大小渺蒿,往往都保存在棧內(nèi)存中痢士,由系統(tǒng)自動(dòng)分配存儲(chǔ)空間。我們可以直接操作保存在棧內(nèi)存空間的值茂装,因此基礎(chǔ)數(shù)據(jù)類型都是按值訪問(wèn)怠蹂。也就是說(shuō),它們的值直接存儲(chǔ)在變量訪問(wèn)的位置少态。
數(shù)據(jù)在棧內(nèi)存中的存儲(chǔ)與使用方式類似于數(shù)據(jù)結(jié)構(gòu)中的堆棧數(shù)據(jù)結(jié)構(gòu)城侧,遵循后進(jìn)先出的原則
基礎(chǔ)數(shù)據(jù)類型: Number String Null Undefined Boolean Symbol(ES6新增)
要簡(jiǎn)單理解棧內(nèi)存空間的存儲(chǔ)方式,我們可以通過(guò)類比乒乓球盒子來(lái)分析彼妻。
乒乓球的存放方式與棧內(nèi)存中存儲(chǔ)數(shù)據(jù)的方式如出一轍嫌佑。處于盒子中最頂層的乒乓球,它一定是最后被放進(jìn)去的侨歉,但可以最先被使用屋摇。而我們想要使用底層的乒乓球,就必須將上面的兩個(gè)乒乓球取出來(lái)幽邓,讓最底層的乒乓球處于盒子頂層炮温。這就是棧空間 “先進(jìn)后出牵舵,后進(jìn)先出” 的特點(diǎn)柒啤。
- 引用數(shù)據(jù)類型與堆內(nèi)存
與java等其他語(yǔ)言不同,JS的引用數(shù)據(jù)類型畸颅,比如數(shù)組Array担巩,它們值的大小是不固定的,可以再不聲明長(zhǎng)度的情況下没炒,動(dòng)態(tài)填充兵睛。引用數(shù)據(jù)類型的值是保存在堆內(nèi)存中的對(duì)象。
JavaScript不允許直接訪問(wèn)堆內(nèi)存中的位置窥浪,因此我們不能直接操作對(duì)象的堆內(nèi)存空間祖很。
在操作對(duì)象時(shí),實(shí)際上是在操作對(duì)象的引用而不是實(shí)際的對(duì)象漾脂。因此假颇,引用類型的值都是按引用訪問(wèn)的。
這里的引用骨稿,我們可以粗淺地理解為保存在棧內(nèi)存中的一個(gè)地址笨鸡,該地址與堆內(nèi)存的實(shí)際值相關(guān)聯(lián)。
為了更好的搞懂棧內(nèi)存與堆內(nèi)存坦冠,我們可以結(jié)合以下例子與圖解進(jìn)行理解形耗。
```
var a1 = 0; // 棧
var a2 = 'this is string'; // 棧
var a3 = null; // 棧
var b = { m: 20 }; // 變量b存在于棧中,{m: 20} 作為對(duì)象存在于堆內(nèi)存中
var c = [1, 2, 3]; // 變量c存在于棧中辙浑,[1, 2, 3] 作為對(duì)象存在于堆內(nèi)存中
```
上例變量的內(nèi)存分配情況圖解
因此當(dāng)我們要訪問(wèn)堆內(nèi)存中的引用數(shù)據(jù)類型時(shí)激涤,實(shí)際上我們首先是從棧中獲取了該對(duì)象的地址引用(或者地址指針),然后再?gòu)亩褍?nèi)存中取得我們需要的數(shù)據(jù)判呕。
三:JavaScript中基礎(chǔ)類型和引用類型的特點(diǎn)倦踢。
既然已經(jīng)明白了棧內(nèi)存和堆內(nèi)存的存儲(chǔ)數(shù)據(jù)的特點(diǎn),那么接下來(lái)就看一些小的例子侠草,這些小的例子專門用來(lái)考察基礎(chǔ)類型和引用類型的存儲(chǔ)特點(diǎn)
-
例一
let a = 20; let b = a; b = 30; console.log(a) // 這時(shí)a的值是多少辱挥?
在棧內(nèi)存中的數(shù)據(jù)發(fā)生復(fù)制行為時(shí),系統(tǒng)會(huì)自動(dòng)為新的變量分配一個(gè)新的內(nèi)存空間边涕。上例中 let b = a 執(zhí)行之后晤碘,a與b雖然值都等于20,但是他們其實(shí)已經(jīng)是相互獨(dú)立互不影響的值了功蜓。具體如圖园爷。所以我們修改了b的值以后,a的值并不會(huì)發(fā)生變化霞赫。因此輸出的 a 的值還是 20腮介。
-
例二
let m = { a: 10, b: 20 } let n = m; n.a = 15; console.log(m.a) // 這時(shí)m.a的值是多少
我們通過(guò)let n = m 執(zhí)行一次復(fù)制引用類型的操作。引用類型的復(fù)制同樣也會(huì)為新的變量自動(dòng)分配一個(gè)新的值保存在棧內(nèi)存中端衰,但不同的是叠洗,這個(gè)新的值,僅僅只是引用類型存在棧內(nèi)存中的一個(gè)地址指針旅东。當(dāng)?shù)刂分羔樝嗤瑫r(shí)灭抑,盡管他們相互獨(dú)立,但是在堆內(nèi)存中訪問(wèn)到的具體對(duì)象實(shí)際上是同一個(gè)抵代。如圖所示腾节。
因此當(dāng)我改變n時(shí),m也發(fā)生了變化。此時(shí)輸出的m.a的值也變成了15案腺,這就是引用類型的特性庆冕。
如果這樣還不好理解,就舉一個(gè)生活中的例子劈榨,假設(shè)甲乙兩個(gè)人一起租房子访递,那么他們都共同擁有同一個(gè)大門進(jìn)入房間,如果一個(gè)人將屋子里面的僅有的空調(diào)弄壞了同辣,那么兩個(gè)人就都沒(méi)有空調(diào)使用了拷姿。
四:JavaScript淺克隆和深度克隆
既然已經(jīng)理解了JS中基礎(chǔ)類型和引用類型的特點(diǎn),下面就開始真正探討關(guān)于深度克隆問(wèn)題了旱函。
- 1响巢、淺克隆
淺克隆之所以被稱為淺克隆,是因?yàn)閷?duì)象只會(huì)被克隆最外部的一層,至于更深層的對(duì)象,則依然是通過(guò)引用指向同一塊堆內(nèi)存.
// 淺克隆函數(shù)
function shallowClone(o) {
const obj = {};
for ( let i in o) {
obj[i] = o[i];
}
return obj;
}
// 被克隆對(duì)象
const oldObj = {
a: 1,
b: [ 'e', 'f', 'g' ],
c: { h: { i: 2 } }
};
const newObj = shallowClone(oldObj);
console.log(newObj.c.h, oldObj.c.h); // { i: 2 } { i: 2 }
console.log(oldObj.c.h === newObj.c.h); // true
我們可以很明顯地看到,雖然oldObj.c.h
被克隆了,但是它還與oldObj.c.h
相等,這表明他們依然指向同一段堆內(nèi)存,我們上面討論過(guò)了引用類型的特點(diǎn)棒妨,這就造成了如果對(duì)newObj.c.h
進(jìn)行修改,也會(huì)影響oldObj.c.h
踪古。這本身不是我們想要的,因此就不算是一版好的克隆靶衍。
newObj.c.h.i = '我們兩個(gè)都變了';
console.log(newObj.c.h, oldObj.c.h); // { i: '我們兩個(gè)都變了' } { i: '我們兩個(gè)都變了' }
我們改變了newObj.c.h.i
的值,oldObj.c.h.i
也被改變了,這就是淺克隆的問(wèn)題所在.
-
2灾炭、深克隆
-
2.1 JSON.parse方法
JSON對(duì)象parse方法可以將JSON字符串反序列化成JS對(duì)象,stringify方法可以將JS對(duì)象序列化成JSON字符串,這兩個(gè)方法結(jié)合起來(lái)就能產(chǎn)生一個(gè)便捷的深克隆.
const newObj = JSON.parse(JSON.stringify(oldObj));
我們依然使用上述中的那個(gè)例子做演示颅眶。
const oldObj = { a: 1, b: [ 'e', 'f', 'g' ], c: { h: { i: 2 } } }; const newObj = JSON.parse(JSON.stringify(oldObj)); // 將oldObj先序列化再反序列化蜈出。 console.log(newObj.c.h, oldObj.c.h); // { i: 2 } { i: 2 } console.log(oldObj.c.h === newObj.c.h); // false 這時(shí)候就已經(jīng)不一樣了 newObj.c.h.i = '我和oldObj相互獨(dú)立'; console.log(newObj.c.h, oldObj.c.h); // { i: '我和oldObj相互獨(dú)立' } { i: 2 }
果然,這是一個(gè)實(shí)現(xiàn)深克隆的好方法,但是這個(gè)解決辦法是不是太過(guò)簡(jiǎn)單了.
確實(shí),這個(gè)方法雖然可以解決絕大部分是使用場(chǎng)景,但是卻有很多坑.
- 1.他無(wú)法實(shí)現(xiàn)對(duì)函數(shù) 、RegExp等特殊對(duì)象的克隆;
- 2.會(huì)拋棄對(duì)象的constructor,所有的構(gòu)造函數(shù)會(huì)指向Object;
- 3.對(duì)象有循環(huán)引用,會(huì)報(bào)錯(cuò);
針對(duì)以上的情況涛酗,我們可以測(cè)試一下:
// 構(gòu)造函數(shù) function person(pname) { this.name = pname; } const Messi = new person('Messi'); // 函數(shù) function say() { console.log('hi'); }; const oldObj = { a: say, b: new Array(1), c: new RegExp('ab+c', 'i'), d: Messi }; const newObj = JSON.parse(JSON.stringify(oldObj)); // 無(wú)法復(fù)制函數(shù) console.log(newObj.a, oldObj.a); // undefined [Function: say] // 稀疏數(shù)組 復(fù)制錯(cuò)誤 console.log(newObj.b[0], oldObj.b[0]); // null undefined // 無(wú)法復(fù)制正則對(duì)象 console.log(newObj.c, oldObj.c); // {} /ab+c/i // 構(gòu)造函數(shù)指向錯(cuò)誤 console.log(newObj.d.constructor, oldObj.d.constructor); // [Function: Object] [Function: person]
我們可以看到在對(duì)函數(shù)铡原、正則對(duì)象、稀疏數(shù)組等對(duì)象克隆時(shí)會(huì)發(fā)生意外商叹,構(gòu)造函數(shù)指向也會(huì)發(fā)生錯(cuò)誤燕刻。
const oldObj = {}; oldObj.a = oldObj; const newObj = JSON.parse(JSON.stringify(oldObj)); console.log(newObj.a, oldObj.a); // TypeError: Converting circular structure to JSON
對(duì)象的循環(huán)引用會(huì)拋出錯(cuò)誤。
-
-
2.2 構(gòu)造一個(gè)深度克隆函數(shù)
由于要面對(duì)不同的對(duì)象(正則剖笙、數(shù)組卵洗、Date等)要采用不同的處理方式,我們需要實(shí)現(xiàn)一個(gè)對(duì)象類型判斷函數(shù)
const isType = (obj, type) => { if (typeof obj !== 'object') return false; // 判斷數(shù)據(jù)類型的經(jīng)典方法: const typeString = Object.prototype.toString.call(obj); let flag; switch (type) { case 'Array': flag = typeString === '[object Array]'; break; case 'Date': flag = typeString === '[object Date]'; break; case 'RegExp': flag = typeString === '[object RegExp]'; break; default: flag = false; } return flag; };
這樣我們就可以對(duì)特殊對(duì)象進(jìn)行類型判斷了,從而采用針對(duì)性的克隆策略.
const arr = Array.of(3, 4, 5, 2); console.log(isType(arr, 'Array')); // true
對(duì)于正則對(duì)象,我們?cè)谔幚碇耙妊a(bǔ)充一點(diǎn)新知識(shí).
我們需要通過(guò)正則的擴(kuò)展了解到flags屬性等等,因此我們需要實(shí)現(xiàn)一個(gè)提取flags的函數(shù)const getRegExp = re => { var flags = ''; if (re.global) flags += 'g'; if (re.ignoreCase) flags += 'i'; if (re.multiline) flags += 'm'; return flags; };
做好了這些準(zhǔn)備工作,我們就可以進(jìn)行深克隆的實(shí)現(xiàn)了.
/** * deep clone * @param {[type]} parent object 需要進(jìn)行克隆的對(duì)象 * @return {[type]} 深克隆后的對(duì)象 */ const clone = parent => { // 維護(hù)兩個(gè)儲(chǔ)存循環(huán)引用的數(shù)組 const parents = []; const children = []; const _clone = parent => { if (parent === null) return null; if (typeof parent !== 'object') return parent; let child, proto; if (isType(parent, 'Array')) { // 對(duì)數(shù)組做特殊處理 child = []; } else if (isType(parent, 'RegExp')) { // 對(duì)正則對(duì)象做特殊處理 child = new RegExp(parent.source, getRegExp(parent)); if (parent.lastIndex) child.lastIndex = parent.lastIndex; } else if (isType(parent, 'Date')) { // 對(duì)Date對(duì)象做特殊處理 child = new Date(parent.getTime()); } else { // 處理對(duì)象原型 proto = Object.getPrototypeOf(parent); // 利用Object.create切斷原型鏈 child = Object.create(proto); } // 處理循環(huán)引用 const index = parents.indexOf(parent); if (index != -1) { // 如果父數(shù)組存在本對(duì)象,說(shuō)明之前已經(jīng)被引用過(guò),直接返回此對(duì)象 return children[index]; } parents.push(parent); children.push(child); for (let i in parent) { // 遞歸 child[i] = _clone(parent[i]); } return child; }; return _clone(parent); };
我們做一下測(cè)試
function person(pname) { this.name = pname; } const Messi = new person('Messi'); function say() { console.log('hi'); } const oldObj = { a: say, c: new RegExp('ab+c', 'i'), d: Messi, }; oldObj.b = oldObj; const newObj = clone(oldObj); console.log(newObj.a, oldObj.a); // [Function: say] [Function: say] console.log(newObj.b, oldObj.b); // { a: [Function: say], c: /ab+c/i, d: person { name: 'Messi' }, b: [Circular] } { a: [Function: say], c: /ab+c/i, d: person { name: 'Messi' }, b: [Circular] } console.log(newObj.c, oldObj.c); // /ab+c/i /ab+c/i console.log(newObj.d.constructor, oldObj.d.constructor); // [Function: person] [Function: person]
當(dāng)然,我們這個(gè)深克隆還不算完美,例如Buffer對(duì)象弥咪、Promise过蹂、Set、Map可能都需要我們做特殊處理聚至,另外對(duì)于確保沒(méi)有循環(huán)引用的對(duì)象酷勺,我們可以省去對(duì)循環(huán)引用的特殊處理,因?yàn)檫@很消耗時(shí)間扳躬,不過(guò)一個(gè)基本的深克隆函數(shù)我們已經(jīng)實(shí)現(xiàn)了脆诉。
實(shí)現(xiàn)一個(gè)完整的深克隆是由許多坑要踩的,npm上一些庫(kù)的實(shí)現(xiàn)也不夠完整,在生產(chǎn)環(huán)境中最好用lodash的深克隆實(shí)現(xiàn).
參考鏈接:
https://juejin.im/post/5abb55ee6fb9a028e33b7e0a
https://juejin.im/entry/589c29a9b123db16a3c18adf
https://www.zhihu.com/question/20289071
https://www.zhihu.com/question/47746441?from=profile_question_card
http://laichuanfeng.com/study/javascript-immutable-primitive-values-and-mutable-object-references/