如何寫出一個驚豔面試官的深拷貝?

導讀

最近常常看到不少JavaScript手寫代碼的文章總結,裏面提供了不少JavaScript Api的手寫實現。前端

裏面的題目實現大多相似,並且說實話不少代碼在我看來是很是簡陋的,若是我做爲面試官,看到這樣的代碼,在我內心是不會合格的,本篇文章我拿最簡單的深拷貝來說一講。node

看本文以前先問本身三個問題:git

你真的理解什麼是深拷貝嗎?github

在面試官眼裏,什麼樣的深拷貝纔算合格?面試

什麼樣的深拷貝能讓面試官感到驚豔?正則表達式

本文由淺入深,帶你一步一步實現一個驚豔面試官的深拷貝。api

本文測試代碼:github.com/ConardLi/Co…數組

例如:代碼clone到本地後,執行 node clone1.test.js查看測試結果。性能優化

建議結合測試代碼一塊兒閱讀效果更佳。數據結構

深拷貝和淺拷貝的定義

深拷貝已是一個老生常談的話題了,也是如今前端面試的高頻題目,可是令我吃驚的是有不少同窗尚未搞懂深拷貝和淺拷貝的區別和定義。例如前幾天給我提issue的同窗:



很明顯這位同窗把拷貝和賦值搞混了,若是你還對賦值、對象在內存中的存儲、變量和類型等等有什麼疑問,能夠看看我這篇文章:juejin.im/post/5cec1b… 。

你只要少搞明白拷貝和賦值的區別。

咱們來明確一下深拷貝和淺拷貝的定義:

淺拷貝:



建立一個新對象,這個對象有着原始對象屬性值的一份精確拷貝。若是屬性是基本類型,拷貝的就是基本類型的值,若是屬性是引用類型,拷貝的就是內存地址 ,因此若是其中一個對象改變了這個地址,就會影響到另外一個對象。

深拷貝:



將一個對象從內存中完整的拷貝一份出來,從堆內存中開闢一個新的區域存放新對象,且修改新對象不會影響原對象

話很少說,淺拷貝就再也不多說,下面咱們直入正題:

乞丐版

在不使用第三方庫的狀況下,咱們想要深拷貝一個對象,用的最多的就是下面這個方法。

JSON.parse(JSON.stringify());

這種寫法很是簡單,並且能夠應對大部分的應用場景,可是它仍是有很大缺陷的,好比拷貝其餘引用類型、拷貝函數、循環引用等狀況。

顯然,面試時你只說出這樣的方法是必定不會合格的。

接下來,咱們一塊兒來手動實現一個深拷貝方法。

基礎版本

若是是淺拷貝的話,咱們能夠很容易寫出下面的代碼:

functionclone(target){

let cloneTarget = {};

for(constkeyintarget) {       

 cloneTarget[key] = target[key];   

 }

returncloneTarget;

};

建立一個新的對象,遍歷須要克隆的對象,將須要克隆對象的屬性依次添加到新對象上,返回。

若是是深拷貝的話,考慮到咱們要拷貝的對象是不知道有多少層深度的,咱們能夠用遞歸來解決問題,稍微改寫上面的代碼:

若是是原始類型,無需繼續拷貝,直接返回

若是是引用類型,建立一個新的對象,遍歷須要克隆的對象,將須要克隆對象的屬性執行深拷貝後依次添加到新對象上。

很容易理解,若是有更深層次的對象能夠繼續遞歸直到屬性爲原始類型,這樣咱們就完成了一個最簡單的深拷貝:

functionclone(target){if(typeoftarget ==='object') {letcloneTarget = {};for(constkeyintarget) {            cloneTarget[key] = clone(target[key]);        }returncloneTarget;    }else{returntarget;    }};複製代碼

咱們能夠打開測試代碼中的clone1.test.js對下面的測試用例進行測試:

consttarget = {field1:1,field2:undefined,field3:'ConardLi',field4: {child:'child',child2: {child2:'child2'}    }};複製代碼

執行結果:



這是一個最基礎版本的深拷貝,這段代碼可讓你向面試官展現你能夠用遞歸解決問題,可是顯然,他還有很是多的缺陷,好比,尚未考慮數組。

考慮數組

在上面的版本中,咱們的初始化結果只考慮了普通的object,下面咱們只須要把初始化代碼稍微一變,就能夠兼容數組了:

module.exports =functionclone(target){if(typeoftarget ==='object') {letcloneTarget =Array.isArray(target) ? [] : {};for(constkeyintarget) {            cloneTarget[key] = clone(target[key]);        }returncloneTarget;    }else{returntarget;    }};複製代碼

clone2.test.js中執行下面的測試用例:

consttarget = {field1:1,field2:undefined,field3: {child:'child'},field4: [2,4,8]};複製代碼

執行結果:



OK,沒有問題,你的代碼又向合格邁進了一小步。

循環引用

咱們執行下面這樣一個測試用例:

consttarget = {field1:1,field2:undefined,field3: {child:'child'},field4: [2,4,8]};target.target = target;複製代碼

能夠看到下面的結果:



很明顯,由於遞歸進入死循環致使棧內存溢出了。緣由就是上面的對象存在循環引用的狀況,即對象的屬性間接或直接的引用了自身的狀況:

解決循環引用問題,咱們能夠額外開闢一個存儲空間,來存儲當前對象和拷貝對象的對應關係,當須要拷貝當前對象時,先去存儲空間中找,有沒有拷貝過這個對象,若是有的話直接返回,若是沒有的話繼續拷貝,這樣就巧妙化解的循環引用的問題。

這個存儲空間,須要能夠存儲key-value形式的數據,且key能夠是一個引用類型,咱們能夠選擇Map這種數據結構:

檢查map中有無克隆過的對象

有 - 直接返回

沒有 - 將當前對象做爲key,克隆對象做爲value進行存儲

繼續克隆

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;    }};

再來執行上面的測試用例:



能夠看到,執行沒有報錯,且target屬性,變爲了一個Circular類型,即循環應用的意思。

接下來,咱們可使用,WeakMap提代Map來使代碼達到畫龍點睛的做用。

functionclone(target, map = new WeakMap()){// ...};複製代碼

爲何要這樣作呢?,先來看看WeakMap的做用:



WeakMap 對象是一組鍵/值對的集合,其中的鍵是弱引用的。其鍵必須是對象,而值能夠是任意的。

什麼是弱引用呢?

在計算機程序設計中,弱引用與強引用相對,是指不能確保其引用的對象不會被垃圾回收器回收的引用。 一個對象若只被弱引用所引用,則被認爲是不可訪問(或弱可訪問)的,並所以可能在任什麼時候刻被回收。

咱們默認建立一個對象:const obj = {},就默認建立了一個強引用的對象,咱們只有手動將obj = null,它纔會被垃圾回收機制進行回收,若是是弱引用對象,垃圾回收機制會自動幫咱們回收。

舉個例子:

若是咱們使用Map的話,那麼對象間是存在強引用關係的:

letobj = {name:'ConardLi'}consttarget =newMap();target.set(obj,'code祕密花園');obj =null;複製代碼

雖然咱們手動將obj,進行釋放,然是target依然對obj存在強引用關係,因此這部份內存依然沒法被釋放。

再來看WeakMap:

letobj = {name:'ConardLi'}consttarget =newWeakMap();target.set(obj,'code祕密花園');obj =null;複製代碼

若是是WeakMap的話,target和obj存在的就是弱引用關係,當下一次垃圾回收機制執行時,這塊內存就會被釋放掉。

設想一下,若是咱們要拷貝的對象很是龐大時,使用Map會對內存形成很是大的額外消耗,並且咱們須要手動清除Map的屬性才能釋放這塊內存,而WeakMap會幫咱們巧妙化解這個問題。

我也常常在某些代碼中看到有人使用WeakMap來解決循環引用問題,可是解釋都是模棱兩可的,當你不太瞭解WeakMap的真正做用時。我建議你也不要在面試中寫這樣的代碼,結果只能是給本身挖坑,即便是準備面試,你寫的每一行代碼也都是須要通過深思熟慮而且很是明白的。

能考慮到循環引用的問題,你已經向面試官展現了你考慮問題的全面性,若是還能用WeakMap解決問題,並很明確的向面試官解釋這樣作的目的,那麼你的代碼在面試官眼裏應該算是合格了。

性能優化

在上面的代碼中,咱們遍歷數組和對象都使用了for in這種方式,實際上for in在遍歷時效率是很是低的,咱們來對比下常見的三種循環for、while、for in的執行效率:



能夠看到,while的效率是最好的,因此,咱們能夠想辦法把for in遍歷改變爲while遍歷。

咱們先使用while來實現一個通用的forEach遍歷,iteratee是遍歷的回掉函數,他能夠接收每次遍歷的value和index兩個參數:

functionforEach(array, iteratee){letindex =-1;constlength = array.length;while(++index < length) {        iteratee(array[index], index);    }returnarray;}複製代碼

下面對咱們的cloen函數進行改寫:當遍歷數組時,直接使用forEach進行遍歷,當遍歷對象時,使用Object.keys取出全部的key進行遍歷,而後在遍歷時把forEach會調函數的value看成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;    }}複製代碼

下面,咱們執行clone4.test.js分別對上一個克隆函數和改寫後的克隆函數進行測試:

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();複製代碼

執行結果:



很明顯,咱們的性能優化是有效的。

到這裏,你已經向面試官展現了,在寫代碼的時候你會考慮程序的運行效率,而且你具備通用函數的抽象能力。

其餘數據類型

在上面的代碼中,咱們其實只考慮了普通的object和array兩種數據類型,實際上全部的引用類型遠遠不止這兩個,還有不少,下面咱們先嚐試獲取對象準確的類型。

合理的判斷引用類型

首先,判斷是否爲引用類型,咱們還須要考慮function和null兩種特殊的數據類型:

functionisObject(target){consttype =typeoftarget;returntarget !==null&& (type ==='object'|| type ==='function');}複製代碼

if(!isObject(target)) {returntarget;    }// ...複製代碼

獲取數據類型

咱們可使用toString來獲取準確的引用類型:

每個引用類型都有toString方法,默認狀況下,toString()方法被每一個Object對象繼承。若是此方法在自定義對象中未被覆蓋,toString()返回"[object type]",其中type是對象的類型。

注意,上面提到了若是此方法在自定義對象中未被覆蓋,toString纔會達到預想的效果,事實上,大部分引用類型好比Array、Date、RegExp等都重寫了toString方法。

咱們能夠直接調用Object原型上未被覆蓋的toString()方法,使用call來改變this指向來達到咱們想要的效果。

functiongetType(target){returnObject.prototype.toString.call(target);}



下面咱們抽離出一些經常使用的數據類型以便後面使用:

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]';複製代碼

在上面的集中類型中,咱們簡單將他們分爲兩類:

能夠繼續遍歷的類型

不能夠繼續遍歷的類型

咱們分別爲它們作不一樣的拷貝。

可繼續遍歷的類型

上面咱們已經考慮的object、array都屬於能夠繼續遍歷的類型,由於它們內存都還能夠存儲其餘數據類型的數據,另外還有Map,Set等都是能夠繼續遍歷的類型,這裏咱們只考慮這四種,若是你有興趣能夠繼續探索其餘類型。

有序這幾種類型還須要繼續進行遞歸,咱們首先須要獲取它們的初始化數據,例如上面的[]和{},咱們能夠經過拿到constructor的方式來通用的獲取。

例如:const target = {}就是const target = new Object()的語法糖。另外這種方法還有一個好處:由於咱們還使用了原對象的構造方法,因此它能夠保留對象原型上的數據,若是直接使用普通的{},那麼原型必然是丟失了的。

functiongetInit(target){constCtor = target.constructor;returnnewCtor();}

下面,咱們改寫clone函數,對可繼續遍歷的數據類型進行處理:

functionclone(target, map = new WeakMap()){// 克隆原始類型if(!isObject(target)) {returntarget;    }// 初始化consttype = getType(target);letcloneTarget;if(deepTag.includes(type)) {        cloneTarget = getInit(target, type);    }// 防止循環引用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;    }// 克隆對象和數組constkeys = type === arrayTag ?undefined:Object.keys(target);    forEach(keys || target, (value, key) => {if(keys) {            key = value;        }        cloneTarget[key] = clone(target[key], map);    });returncloneTarget;}

咱們執行clone5.test.js對下面的測試用例進行測試:

consttarget = {field1:1,field2:undefined,field3: {child:'child'},field4: [2,4,8],empty:null,    map,    set,};複製代碼

執行結果:



沒有問題,裏大功告成又進一步,下面咱們繼續處理其餘類型:

不可繼續遍歷的類型

其餘剩餘的類型咱們把它們統一歸類成不可處理的數據類型,咱們依次進行處理:

Bool、Number、String、String、Date、Error這幾種類型咱們均可以直接用構造函數和原始數據建立一個新對象:

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;}

實際上還有不少數據類型我這裏沒有寫到,有興趣的話能夠繼續探索實現一下。

能寫到這裏,面試官已經看到了你考慮問題的嚴謹性,你對變量和類型的理解,對JS API的熟練程度,相信面試官已經開始對你另眼相看了。

克隆函數

最後,我把克隆函數單獨拎出來了,實際上克隆函數是沒有實際應用場景的,兩個對象使用一個在內存中處於同一個地址的函數也是沒有任何問題的,我特地看了下lodash對函數的處理:

constisFunc =typeofvalue =='function'if(isFunc || !cloneableTags[tag]) {returnobject ? value : {} }複製代碼

可見這裏若是發現是函數的話就會直接返回了,沒有作特殊的處理,可是我發現很多面試官仍是熱衷於問這個問題的,並且據我瞭解能寫出來的少之又少。。。

實際上這個方法並無什麼難度,主要就是考察你對基礎的掌握紮實不紮實。

首先,咱們能夠經過prototype來區分下箭頭函數和普通函數,箭頭函數是沒有prototype的。

咱們能夠直接使用eval和函數字符串來從新生成一個箭頭函數,注意這種方法是不適用於普通函數的。

咱們可使用正則來處理普通函數:

分別使用正則取出函數體和函數參數,而後使用new Function ([arg1[, arg2[, ...argN]],] functionBody)構造函數從新構造一個新的函數:

functioncloneFunction(func){constbodyReg =/(?<={)(.|\n)+(?=})/m;constparamReg =/(?<=\().+(?=\)\s+{)/;constfuncString = func.toString();if(func.prototype) {console.log('普通函數');constparam = paramReg.exec(funcString);constbody = bodyReg.exec(funcString);if(body) {console.log('匹配到函數體:', body[0]);if(param) {constparamArr = param[0].split(',');console.log('匹配到參數:', paramArr);returnnewFunction(...paramArr, body[0]);            }else{returnnewFunction(body[0]);            }        }else{returnnull;        }    }else{returneval(funcString);    }}

最後,咱們再來執行clone6.test.js對下面的測試用例進行測試:

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;    }};複製代碼

執行結果:



最後

完整代碼:github.com/ConardLi/Co…

可見,一個小小的深拷貝仍是隱藏了不少的知識點的。

千萬不要以最低的要求來要求本身,若是你只是爲了應付面試中的一個題目,那麼你可能只會去準備上面最簡陋的深拷貝的方法。

可是面試官考察你的目的是全方位的考察你的思惟能力,若是你寫出上面的代碼,能夠體現你多方位的能力:

基本實現

遞歸能力

循環引用

考慮問題的全面性

理解weakmap的真正意義

多種類型

考慮問題的嚴謹性

建立各類引用類型的方法,JS API的熟練程度

準確的判斷數據類型,對數據類型的理解程度

通用遍歷:

寫代碼能夠考慮性能優化

瞭解集中遍歷的效率

代碼抽象能力

拷貝函數:

箭頭函數和普通函數的區別

正則表達式熟練程度

看吧,一個小小的深拷貝能考察你這麼多的能力,若是面試官看到這樣的代碼,怎麼可以不驚豔呢?

其實面試官出的全部題目你均可以用這樣的思路去考慮。不要爲了應付面試而去背一些代碼,這樣在有經驗的面試官面前會都會暴露出來。你寫的每一段代碼都要通過深思熟慮,爲何要這樣用,還能怎麼優化...這樣才能給面試官展示一個最好的你。

參考

WeakMap

lodash

小結

但願看完本篇文章能對你有以下幫助:

理解深淺拷貝的真正意義

能整我深拷貝的各個要點,對問題進行深刻分析

能夠手寫一個比較完整的深拷貝

文中若有錯誤,歡迎在評論區指正,若是這篇文章幫助到了你,歡迎點贊和關注

相關文章
相關標籤/搜索