導(dǎo)讀
最近經(jīng)??吹胶芏郕avaScript手寫代碼的文章總結(jié),里面提供了很多JavaScript Api的手寫實現(xiàn)。
里面的題目實現(xiàn)大多類似,而且說實話很多代碼在我看來是非常簡陋的,如果我作為面試官,看到這樣的代碼,在我心里是不會合格的,本篇文章我拿最簡單的深拷貝來講一講。
看本文之前先問自己三個問題:
你真的理解什么是深拷貝嗎?
在面試官眼里,什么樣的深拷貝才算合格?
什么樣的深拷貝能讓面試官感到驚艷?
本文由淺入深,帶你一步一步實現(xiàn)一個驚艷面試官的深拷貝。
本文測試代碼:github.com/ConardLi/Co…
例如:代碼clone到本地后,執(zhí)行 node clone1.test.js查看測試結(jié)果。
建議結(jié)合測試代碼一起閱讀效果更佳。
深拷貝和淺拷貝的定義
深拷貝已經(jīng)是一個老生常談的話題了,也是現(xiàn)在前端面試的高頻題目,但是令我吃驚的是有很多同學(xué)還沒有搞懂深拷貝和淺拷貝的區(qū)別和定義。例如前幾天給我提issue的同學(xué):

很明顯這位同學(xué)把拷貝和賦值搞混了,如果你還對賦值、對象在內(nèi)存中的存儲、變量和類型等等有什么疑問,可以看看我這篇文章:juejin.im/post/5cec1b…?。
你只要少搞明白拷貝和賦值的區(qū)別。
我們來明確一下深拷貝和淺拷貝的定義:
淺拷貝:

創(chuàng)建一個新對象,這個對象有著原始對象屬性值的一份精確拷貝。如果屬性是基本類型,拷貝的就是基本類型的值,如果屬性是引用類型,拷貝的就是內(nèi)存地址 ,所以如果其中一個對象改變了這個地址,就會影響到另一個對象。
深拷貝:

將一個對象從內(nèi)存中完整的拷貝一份出來,從堆內(nèi)存中開辟一個新的區(qū)域存放新對象,且修改新對象不會影響原對象
話不多說,淺拷貝就不再多說,下面我們直入正題:
乞丐版
在不使用第三方庫的情況下,我們想要深拷貝一個對象,用的最多的就是下面這個方法。
JSON.parse(JSON.stringify());
這種寫法非常簡單,而且可以應(yīng)對大部分的應(yīng)用場景,但是它還是有很大缺陷的,比如拷貝其他引用類型、拷貝函數(shù)、循環(huán)引用等情況。
顯然,面試時你只說出這樣的方法是一定不會合格的。
接下來,我們一起來手動實現(xiàn)一個深拷貝方法。
基礎(chǔ)版本
如果是淺拷貝的話,我們可以很容易寫出下面的代碼:
functionclone(target){
let cloneTarget = {};
for(constkeyintarget) {? ? ? ?
?cloneTarget[key] = target[key];? ?
?}
returncloneTarget;
};
創(chuàng)建一個新的對象,遍歷需要克隆的對象,將需要克隆對象的屬性依次添加到新對象上,返回。
如果是深拷貝的話,考慮到我們要拷貝的對象是不知道有多少層深度的,我們可以用遞歸來解決問題,稍微改寫上面的代碼:
如果是原始類型,無需繼續(xù)拷貝,直接返回
如果是引用類型,創(chuàng)建一個新的對象,遍歷需要克隆的對象,將需要克隆對象的屬性執(zhí)行深拷貝后依次添加到新對象上。
很容易理解,如果有更深層次的對象可以繼續(xù)遞歸直到屬性為原始類型,這樣我們就完成了一個最簡單的深拷貝:
functionclone(target){if(typeoftarget ==='object') {letcloneTarget = {};for(constkeyintarget) {? ? ? ? ? ? cloneTarget[key] = clone(target[key]);? ? ? ? }returncloneTarget;? ? }else{returntarget;? ? }};復(fù)制代碼
我們可以打開測試代碼中的clone1.test.js對下面的測試用例進(jìn)行測試:
consttarget = {field1:1,field2:undefined,field3:'ConardLi',field4: {child:'child',child2: {child2:'child2'}? ? }};復(fù)制代碼
執(zhí)行結(jié)果:

這是一個最基礎(chǔ)版本的深拷貝,這段代碼可以讓你向面試官展示你可以用遞歸解決問題,但是顯然,他還有非常多的缺陷,比如,還沒有考慮數(shù)組。
考慮數(shù)組
在上面的版本中,我們的初始化結(jié)果只考慮了普通的object,下面我們只需要把初始化代碼稍微一變,就可以兼容數(shù)組了:
module.exports =functionclone(target){if(typeoftarget ==='object') {letcloneTarget =Array.isArray(target) ? [] : {};for(constkeyintarget) {? ? ? ? ? ? cloneTarget[key] = clone(target[key]);? ? ? ? }returncloneTarget;? ? }else{returntarget;? ? }};復(fù)制代碼
在clone2.test.js中執(zhí)行下面的測試用例:
consttarget = {field1:1,field2:undefined,field3: {child:'child'},field4: [2,4,8]};復(fù)制代碼
執(zhí)行結(jié)果:

OK,沒有問題,你的代碼又向合格邁進(jìn)了一小步。
循環(huán)引用
我們執(zhí)行下面這樣一個測試用例:
consttarget = {field1:1,field2:undefined,field3: {child:'child'},field4: [2,4,8]};target.target = target;復(fù)制代碼
可以看到下面的結(jié)果:

很明顯,因為遞歸進(jìn)入死循環(huán)導(dǎo)致棧內(nèi)存溢出了。原因就是上面的對象存在循環(huán)引用的情況,即對象的屬性間接或直接的引用了自身的情況:
解決循環(huán)引用問題,我們可以額外開辟一個存儲空間,來存儲當(dāng)前對象和拷貝對象的對應(yīng)關(guān)系,當(dāng)需要拷貝當(dāng)前對象時,先去存儲空間中找,有沒有拷貝過這個對象,如果有的話直接返回,如果沒有的話繼續(xù)拷貝,這樣就巧妙化解的循環(huán)引用的問題。
這個存儲空間,需要可以存儲key-value形式的數(shù)據(jù),且key可以是一個引用類型,我們可以選擇Map這種數(shù)據(jù)結(jié)構(gòu):
檢查map中有無克隆過的對象
有 - 直接返回
沒有 - 將當(dāng)前對象作為key,克隆對象作為value進(jìn)行存儲
繼續(xù)克隆
functionclone(target, map = new Map()){if(typeoftarget ==='object') {letcloneTarget =Array.isArray(target) ? [] : {};if(map.get(target)) {returnmap.get(target);? ? ? ? }? ? ? ? map.set(target, cloneTarget);for(constkeyintarget) {? ? ? ? ? ? cloneTarget[key] = clone(target[key], map);? ? ? ? }returncloneTarget;? ? }else{returntarget;? ? }};
再來執(zhí)行上面的測試用例:

可以看到,執(zhí)行沒有報錯,且target屬性,變?yōu)榱艘粋€Circular類型,即循環(huán)應(yīng)用的意思。
接下來,我們可以使用,WeakMap提代Map來使代碼達(dá)到畫龍點睛的作用。
functionclone(target, map = new WeakMap()){// ...};復(fù)制代碼
為什么要這樣做呢?,先來看看WeakMap的作用:

WeakMap 對象是一組鍵/值對的集合,其中的鍵是弱引用的。其鍵必須是對象,而值可以是任意的。
什么是弱引用呢?
在計算機(jī)程序設(shè)計中,弱引用與強(qiáng)引用相對,是指不能確保其引用的對象不會被垃圾回收器回收的引用。 一個對象若只被弱引用所引用,則被認(rèn)為是不可訪問(或弱可訪問)的,并因此可能在任何時刻被回收。
我們默認(rèn)創(chuàng)建一個對象:const obj = {},就默認(rèn)創(chuàng)建了一個強(qiáng)引用的對象,我們只有手動將obj = null,它才會被垃圾回收機(jī)制進(jìn)行回收,如果是弱引用對象,垃圾回收機(jī)制會自動幫我們回收。
舉個例子:
如果我們使用Map的話,那么對象間是存在強(qiáng)引用關(guān)系的:
letobj = {name:'ConardLi'}consttarget =newMap();target.set(obj,'code秘密花園');obj =null;復(fù)制代碼
雖然我們手動將obj,進(jìn)行釋放,然是target依然對obj存在強(qiáng)引用關(guān)系,所以這部分內(nèi)存依然無法被釋放。
再來看WeakMap:
letobj = {name:'ConardLi'}consttarget =newWeakMap();target.set(obj,'code秘密花園');obj =null;復(fù)制代碼
如果是WeakMap的話,target和obj存在的就是弱引用關(guān)系,當(dāng)下一次垃圾回收機(jī)制執(zhí)行時,這塊內(nèi)存就會被釋放掉。
設(shè)想一下,如果我們要拷貝的對象非常龐大時,使用Map會對內(nèi)存造成非常大的額外消耗,而且我們需要手動清除Map的屬性才能釋放這塊內(nèi)存,而WeakMap會幫我們巧妙化解這個問題。
我也經(jīng)常在某些代碼中看到有人使用WeakMap來解決循環(huán)引用問題,但是解釋都是模棱兩可的,當(dāng)你不太了解WeakMap的真正作用時。我建議你也不要在面試中寫這樣的代碼,結(jié)果只能是給自己挖坑,即使是準(zhǔn)備面試,你寫的每一行代碼也都是需要經(jīng)過深思熟慮并且非常明白的。
能考慮到循環(huán)引用的問題,你已經(jīng)向面試官展示了你考慮問題的全面性,如果還能用WeakMap解決問題,并很明確的向面試官解釋這樣做的目的,那么你的代碼在面試官眼里應(yīng)該算是合格了。
性能優(yōu)化
在上面的代碼中,我們遍歷數(shù)組和對象都使用了for in這種方式,實際上for in在遍歷時效率是非常低的,我們來對比下常見的三種循環(huán)for、while、for in的執(zhí)行效率:

可以看到,while的效率是最好的,所以,我們可以想辦法把for in遍歷改變?yōu)閣hile遍歷。
我們先使用while來實現(xiàn)一個通用的forEach遍歷,iteratee是遍歷的回掉函數(shù),他可以接收每次遍歷的value和index兩個參數(shù):
functionforEach(array, iteratee){letindex =-1;constlength = array.length;while(++index < length) {? ? ? ? iteratee(array[index], index);? ? }returnarray;}復(fù)制代碼
下面對我們的cloen函數(shù)進(jìn)行改寫:當(dāng)遍歷數(shù)組時,直接使用forEach進(jìn)行遍歷,當(dāng)遍歷對象時,使用Object.keys取出所有的key進(jìn)行遍歷,然后在遍歷時把forEach會調(diào)函數(shù)的value當(dāng)作key使用:
functionclone(target, map = new WeakMap()){if(typeoftarget ==='object') {constisArray =Array.isArray(target);letcloneTarget = isArray ? [] : {};if(map.get(target)) {returnmap.get(target);? ? ? ? }? ? ? ? map.set(target, cloneTarget);constkeys = isArray ?undefined:Object.keys(target);? ? ? ? forEach(keys || target, (value, key) => {if(keys) {? ? ? ? ? ? ? ? key = value;? ? ? ? ? ? }? ? ? ? ? ? cloneTarget[key] = clone2(target[key], map);? ? ? ? });returncloneTarget;? ? }else{returntarget;? ? }}復(fù)制代碼
下面,我們執(zhí)行clone4.test.js分別對上一個克隆函數(shù)和改寫后的克隆函數(shù)進(jìn)行測試:
consttarget = {field1:1,field2:undefined,field3: {child:'child'},field4: [2,4,8],f: {f: {f: {f: {f: {f: {f: {f: {f: {f: {f: {f: {} } } } } } } } } } } },};target.target = target;console.time();constresult = clone1(target);console.timeEnd();console.time();constresult2 = clone2(target);console.timeEnd();復(fù)制代碼
執(zhí)行結(jié)果:

很明顯,我們的性能優(yōu)化是有效的。
到這里,你已經(jīng)向面試官展示了,在寫代碼的時候你會考慮程序的運行效率,并且你具有通用函數(shù)的抽象能力。
其他數(shù)據(jù)類型
在上面的代碼中,我們其實只考慮了普通的object和array兩種數(shù)據(jù)類型,實際上所有的引用類型遠(yuǎn)遠(yuǎn)不止這兩個,還有很多,下面我們先嘗試獲取對象準(zhǔn)確的類型。
合理的判斷引用類型
首先,判斷是否為引用類型,我們還需要考慮function和null兩種特殊的數(shù)據(jù)類型:
functionisObject(target){consttype =typeoftarget;returntarget !==null&& (type ==='object'|| type ==='function');}復(fù)制代碼
if(!isObject(target)) {returntarget;? ? }// ...復(fù)制代碼
獲取數(shù)據(jù)類型
我們可以使用toString來獲取準(zhǔn)確的引用類型:
每一個引用類型都有toString方法,默認(rèn)情況下,toString()方法被每個Object對象繼承。如果此方法在自定義對象中未被覆蓋,toString()返回"[object type]",其中type是對象的類型。
注意,上面提到了如果此方法在自定義對象中未被覆蓋,toString才會達(dá)到預(yù)想的效果,事實上,大部分引用類型比如Array、Date、RegExp等都重寫了toString方法。
我們可以直接調(diào)用Object原型上未被覆蓋的toString()方法,使用call來改變this指向來達(dá)到我們想要的效果。
functiongetType(target){returnObject.prototype.toString.call(target);}

下面我們抽離出一些常用的數(shù)據(jù)類型以便后面使用:
constmapTag ='[object Map]';constsetTag ='[object Set]';constarrayTag ='[object Array]';constobjectTag ='[object Object]';constboolTag ='[object Boolean]';constdateTag ='[object Date]';consterrorTag ='[object Error]';constnumberTag ='[object Number]';constregexpTag ='[object RegExp]';conststringTag ='[object String]';constsymbolTag ='[object Symbol]';復(fù)制代碼
在上面的集中類型中,我們簡單將他們分為兩類:
可以繼續(xù)遍歷的類型
不可以繼續(xù)遍歷的類型
我們分別為它們做不同的拷貝。
可繼續(xù)遍歷的類型
上面我們已經(jīng)考慮的object、array都屬于可以繼續(xù)遍歷的類型,因為它們內(nèi)存都還可以存儲其他數(shù)據(jù)類型的數(shù)據(jù),另外還有Map,Set等都是可以繼續(xù)遍歷的類型,這里我們只考慮這四種,如果你有興趣可以繼續(xù)探索其他類型。
有序這幾種類型還需要繼續(xù)進(jìn)行遞歸,我們首先需要獲取它們的初始化數(shù)據(jù),例如上面的[]和{},我們可以通過拿到constructor的方式來通用的獲取。
例如:const target = {}就是const target = new Object()的語法糖。另外這種方法還有一個好處:因為我們還使用了原對象的構(gòu)造方法,所以它可以保留對象原型上的數(shù)據(jù),如果直接使用普通的{},那么原型必然是丟失了的。
functiongetInit(target){constCtor = target.constructor;returnnewCtor();}
下面,我們改寫clone函數(shù),對可繼續(xù)遍歷的數(shù)據(jù)類型進(jìn)行處理:
functionclone(target, map = new WeakMap()){// 克隆原始類型if(!isObject(target)) {returntarget;? ? }// 初始化consttype = getType(target);letcloneTarget;if(deepTag.includes(type)) {? ? ? ? cloneTarget = getInit(target, type);? ? }// 防止循環(huán)引用if(map.get(target)) {returnmap.get(target);? ? }? ? map.set(target, cloneTarget);// 克隆setif(type === setTag) {? ? ? ? target.forEach(value=>{? ? ? ? ? ? cloneTarget.add(clone(value,map));? ? ? ? });returncloneTarget;? ? }// 克隆mapif(type === mapTag) {? ? ? ? target.forEach((value, key) =>{? ? ? ? ? ? cloneTarget.set(key, clone(value,map));? ? ? ? });returncloneTarget;? ? }// 克隆對象和數(shù)組constkeys = type === arrayTag ?undefined:Object.keys(target);? ? forEach(keys || target, (value, key) => {if(keys) {? ? ? ? ? ? key = value;? ? ? ? }? ? ? ? cloneTarget[key] = clone(target[key], map);? ? });returncloneTarget;}
我們執(zhí)行clone5.test.js對下面的測試用例進(jìn)行測試:
consttarget = {field1:1,field2:undefined,field3: {child:'child'},field4: [2,4,8],empty:null,? ? map,? ? set,};復(fù)制代碼
執(zhí)行結(jié)果:

沒有問題,里大功告成又進(jìn)一步,下面我們繼續(xù)處理其他類型:
不可繼續(xù)遍歷的類型
其他剩余的類型我們把它們統(tǒng)一歸類成不可處理的數(shù)據(jù)類型,我們依次進(jìn)行處理:
Bool、Number、String、String、Date、Error這幾種類型我們都可以直接用構(gòu)造函數(shù)和原始數(shù)據(jù)創(chuàng)建一個新對象:
functioncloneOtherType(targe, type){constCtor = targe.constructor;switch(type) {caseboolTag:casenumberTag:casestringTag:caseerrorTag:casedateTag:returnnewCtor(targe);caseregexpTag:returncloneReg(targe);casesymbolTag:returncloneSymbol(targe);default:returnnull;? ? }}
克隆Symbol類型:
functioncloneSymbol(targe){returnObject(Symbol.prototype.valueOf.call(targe));}克隆正則:functioncloneReg(targe){constreFlags =/\w*$/;constresult =newtarge.constructor(targe.source, reFlags.exec(targe));? ? result.lastIndex = targe.lastIndex;returnresult;}
實際上還有很多數(shù)據(jù)類型我這里沒有寫到,有興趣的話可以繼續(xù)探索實現(xiàn)一下。
能寫到這里,面試官已經(jīng)看到了你考慮問題的嚴(yán)謹(jǐn)性,你對變量和類型的理解,對JS API的熟練程度,相信面試官已經(jīng)開始對你刮目相看了。
克隆函數(shù)
最后,我把克隆函數(shù)單獨拎出來了,實際上克隆函數(shù)是沒有實際應(yīng)用場景的,兩個對象使用一個在內(nèi)存中處于同一個地址的函數(shù)也是沒有任何問題的,我特意看了下lodash對函數(shù)的處理:
constisFunc =typeofvalue =='function'if(isFunc || !cloneableTags[tag]) {returnobject ? value : {} }復(fù)制代碼
可見這里如果發(fā)現(xiàn)是函數(shù)的話就會直接返回了,沒有做特殊的處理,但是我發(fā)現(xiàn)不少面試官還是熱衷于問這個問題的,而且據(jù)我了解能寫出來的少之又少。。。
實際上這個方法并沒有什么難度,主要就是考察你對基礎(chǔ)的掌握扎實不扎實。
首先,我們可以通過prototype來區(qū)分下箭頭函數(shù)和普通函數(shù),箭頭函數(shù)是沒有prototype的。
我們可以直接使用eval和函數(shù)字符串來重新生成一個箭頭函數(shù),注意這種方法是不適用于普通函數(shù)的。
我們可以使用正則來處理普通函數(shù):
分別使用正則取出函數(shù)體和函數(shù)參數(shù),然后使用new Function ([arg1[, arg2[, ...argN]],] functionBody)構(gòu)造函數(shù)重新構(gòu)造一個新的函數(shù):
functioncloneFunction(func){constbodyReg =/(?<={)(.|\n)+(?=})/m;constparamReg =/(?<=\().+(?=\)\s+{)/;constfuncString = func.toString();if(func.prototype) {console.log('普通函數(shù)');constparam = paramReg.exec(funcString);constbody = bodyReg.exec(funcString);if(body) {console.log('匹配到函數(shù)體:', body[0]);if(param) {constparamArr = param[0].split(',');console.log('匹配到參數(shù):', paramArr);returnnewFunction(...paramArr, body[0]);? ? ? ? ? ? }else{returnnewFunction(body[0]);? ? ? ? ? ? }? ? ? ? }else{returnnull;? ? ? ? }? ? }else{returneval(funcString);? ? }}
最后,我們再來執(zhí)行clone6.test.js對下面的測試用例進(jìn)行測試:
constmap =newMap();map.set('key','value');map.set('ConardLi','code秘密花園');constset =newSet();set.add('ConardLi');set.add('code秘密花園');consttarget = {field1:1,field2:undefined,field3: {child:'child'},field4: [2,4,8],empty:null,? ? map,? ? set,bool:newBoolean(true),num:newNumber(2),str:newString(2),symbol:Object(Symbol(1)),date:newDate(),reg:/\d+/,error:newError(),func1:()=>{console.log('code秘密花園');? ? },func2:function(a, b){returna + b;? ? }};復(fù)制代碼
執(zhí)行結(jié)果:

最后
可見,一個小小的深拷貝還是隱藏了很多的知識點的。
千萬不要以最低的要求來要求自己,如果你只是為了應(yīng)付面試中的一個題目,那么你可能只會去準(zhǔn)備上面最簡陋的深拷貝的方法。
但是面試官考察你的目的是全方位的考察你的思維能力,如果你寫出上面的代碼,可以體現(xiàn)你多方位的能力:
基本實現(xiàn)
遞歸能力
循環(huán)引用
考慮問題的全面性
理解weakmap的真正意義
多種類型
考慮問題的嚴(yán)謹(jǐn)性
創(chuàng)建各種引用類型的方法,JS API的熟練程度
準(zhǔn)確的判斷數(shù)據(jù)類型,對數(shù)據(jù)類型的理解程度
通用遍歷:
寫代碼可以考慮性能優(yōu)化
了解集中遍歷的效率
代碼抽象能力
拷貝函數(shù):
箭頭函數(shù)和普通函數(shù)的區(qū)別
正則表達(dá)式熟練程度
看吧,一個小小的深拷貝能考察你這么多的能力,如果面試官看到這樣的代碼,怎么能夠不驚艷呢?
其實面試官出的所有題目你都可以用這樣的思路去考慮。不要為了應(yīng)付面試而去背一些代碼,這樣在有經(jīng)驗的面試官面前會都會暴露出來。你寫的每一段代碼都要經(jīng)過深思熟慮,為什么要這樣用,還能怎么優(yōu)化...這樣才能給面試官展現(xiàn)一個最好的你。
參考
小結(jié)
希望看完本篇文章能對你有如下幫助:
理解深淺拷貝的真正意義
能整我深拷貝的各個要點,對問題進(jìn)行深入分析
可以手寫一個比較完整的深拷貝
文中如有錯誤,歡迎在評論區(qū)指正,如果這篇文章幫助到了你,歡迎點贊和關(guān)注