上一講我們介紹了 JS 的兩種資料型別,分別是基礎資料型別和引用資料型別,你可以回憶一下我提到的重點內容。那麼這一講要聊的淺複製和深複製,其實就是圍繞著這兩種資料型別展開的。
我把深淺複製單獨作為一講來專門講解,是因為在 JavaScript 的程式設計中經常需要對資料進行復制,什麼時候用深複製、什麼時候用淺複製,是開發過程中需要思考的;同時深淺複製也是前端面試中比較高頻的題目。
但是我在面試候選人的過程中,發現有很多同學都沒有搞懂深複製和淺複製的區別和定義。最近我也在一些關於 JavaScript 的技術文章中發現,裡面很多關於深淺複製的程式碼寫得比較簡陋,從面試官的角度來講,簡陋的答案是不太能讓人滿意的。
因此,深入學習這部分知識有助於提高你手寫 JS 的能力,以及對一些邊界特殊情況的深入思考能力,這一講我會結合最基礎但是又容易寫不好的的題目來幫助你提升。
在開始之前,我先丟擲來兩個問題,你可以思考一下。
複製一個很多巢狀的物件怎麼實現?
在面試官眼中,寫成什麼樣的深複製程式碼才能算合格?
帶著這兩個問題,我們先來看下淺複製的相關內容。
淺複製的原理和實現對於淺複製的定義我們可以初步理解為:
自己建立一個新的物件,來接受你要重新複製或引用的物件值。如果物件屬性是基本的資料型別,複製的就是基本型別的值給新物件;但如果屬性是引用資料型別,複製的就是記憶體中的地址,如果其中一個物件改變了這個記憶體中的地址,肯定會影響到另一個物件。
下面我總結了一些 JavaScript 提供的淺複製方法,一起來看看哪些方法能實現上述定義所描述的過程。
方法一:object.assign
object.assign 的語法為:Object.assign(target, ...sources)
object.assign 的示例程式碼如下:
let target = {};let source = { a: { b: 1 } };Object.assign(target, source);console.log(target); // { a: { b: 1 } };
從上面的程式碼中可以看到,透過 object.assign 我們的確簡單實現了一個淺複製,“target”就是我們新複製的物件,下面再看一個和上面不太一樣的例子。
let target = {};let source = { a: { b: 2 } };Object.assign(target, source);console.log(target); // { a: { b: 10 } };source.a.b = 10;console.log(source); // { a: { b: 10 } };console.log(target); // { a: { b: 10 } };
從上面程式碼中我們可以看到,首先透過 Object.assign 將 source 複製到 target 物件中,然後我們嘗試將 source 物件中的 b 屬性由 2 修改為 10。透過控制檯可以發現,列印結果中,三個 target 裡的 b 屬性都變為 10 了,證明 Object.assign 暫時實現了我們想要的複製效果。
但是使用 object.assign 方法有幾點需要注意:
它不會複製物件的繼承屬性;它不會複製物件的不可列舉的屬性;可以複製 Symbol 型別的屬性。可以簡單理解為:Object.assign 迴圈遍歷原物件的屬性,透過複製的方式將其賦值給目標物件的相應屬性,來看一下這段程式碼,以驗證它可以複製 Symbol 型別的物件。
let obj1 = { a:{ b:1 }, sym:Symbol(1)};Object.defineProperty(obj1, 'innumerable' ,{ value:'不可列舉屬性', enumerable:false});let obj2 = {};Object.assign(obj2,obj1)obj1.a.b = 2;console.log('obj1',obj1);console.log('obj2',obj2);
我們來看一下控制檯列印的結果,如下圖所示。
從上面的樣例程式碼中可以看到,利用 object.assign 也可以複製 Symbol 型別的物件,但是如果到了物件的第二層屬性 obj1.a.b 這裡的時候,前者值得改變也會影響後者的第二層屬性的值,說明其中依舊存在著訪問共同堆記憶體的問題,也就是說這種方法還不能進一步複製,而只是完成了淺複製的功能。
方法二:擴充套件運算子方式
我們也可以利用 JS 的擴充套件運算子,在構造物件的同時完成淺複製的功能。
擴充套件運算子的語法為:let cloneObj = { ...obj };
程式碼如下所示。
/ 物件的複製 /let obj = {a:1,b:{c:1}}let obj2 = {...obj}obj.a = 2console.log(obj) //{a:2,b:{c:1}} console.log(obj2); //{a:1,b:{c:1}}obj.b.c = 2console.log(obj) //{a:2,b:{c:2}} console.log(obj2); //{a:1,b:{c:2}}/ 陣列的複製 /let arr = [1, 2, 3];let newArr = [...arr]; //跟arr.slice()是一樣的效果
擴充套件運算子 和 object.assign 有同樣的缺陷,也就是實現的淺複製的功能差不多,但是如果屬性都是基本型別的值,使用擴充套件運算子進行淺複製會更加方便。
方法三:concat 複製陣列
陣列的 concat 方法其實也是淺複製,所以連線一個含有引用型別的陣列時,需要注意修改原陣列中的元素的屬性,因為它會影響複製之後連線的陣列。不過 concat 只能用於陣列的淺複製,使用場景比較侷限。程式碼如下所示。
let arr = [1, 2, 3];let newArr = arr.concat();newArr[1] = 100;console.log(arr); // [ 1, 2, 3 ]console.log(newArr); // [ 1, 100, 3 ]
方法四:slice 複製陣列
slice 方法也比較有侷限性,因為它僅僅針對陣列型別。slice 方法會返回一個新的陣列物件,這一物件由該方法的前兩個引數來決定原陣列擷取的開始和結束時間,是不會影響和改變原始陣列的。
slice 的語法為:arr.slice(begin, end);
我們來看一下 slice 怎麼使用,程式碼如下所示。
let arr = [1, 2, {val: 4}];let newArr = arr.slice();newArr[2].val = 1000;console.log(arr); //[ 1, 2, { val: 1000 } ]
從上面的程式碼中可以看出,這就是淺複製的限制所在了——它只能複製一層物件。如果存在物件的巢狀,那麼淺複製將無能為力。因此深複製就是為了解決這個問題而生的,它能解決多層物件巢狀問題,徹底實現複製。這一講的後面我會介紹深複製相關的內容。
手工實現一個淺複製根據以上對淺複製的理解,如果讓你自己實現一個淺複製,大致的思路分為兩點:
對基礎型別做一個最基本的一個複製;
對引用型別開闢一個新的儲存,並且複製一層物件屬性。
那麼,圍繞著這兩個思路,請你跟著我的操作,自己來實現一個淺複製吧,程式碼如下所示。
const shallowClone = (target) => { if (typeof target === 'object' && target !== null) { const cloneTarget = Array.isArray(target) ? []: {}; for (let prop in target) { if (target.hasOwnProperty(prop)) { cloneTarget[prop] = target[prop]; } } return cloneTarget; } else { return target; }}
從上面這段程式碼可以看出,利用型別判斷,針對引用型別的物件進行 for 迴圈遍歷物件屬性賦值給目標物件的屬性,基本就可以手工實現一個淺複製的程式碼了。
那麼瞭解了實現淺複製程式碼的思路,接下來我們再看看深複製是怎麼實現的。
深複製的原理和實現淺複製只是建立了一個新的物件,複製了原有物件的基本型別的值,而引用資料型別只複製了一層屬性,再深層的還是無法進行複製。深複製則不同,對於複雜引用資料型別,其在堆記憶體中完全開闢了一塊記憶體地址,並將原有的物件完全複製過來存放。
這兩個物件是相互獨立、不受影響的,徹底實現了記憶體上的分離。總的來說,深複製的原理可以總結如下:
將一個物件從記憶體中完整地複製出來一份給目標物件,並從堆記憶體中開闢一個全新的空間存放新物件,且新物件的修改並不會改變原物件,二者實現真正的分離。
現在原理你知道了,那麼怎麼去實現深複製呢?我也總結了幾種方法分享給你。
方法一:乞丐版(JSON.stringfy)
JSON.stringfy() 是目前開發過程中最簡單的深複製方法,其實就是把一個物件序列化成為 JSON 的字串,並將物件裡面的內容轉換成字串,最後再用 JSON.parse() 的方法將JSON 字串生成一個新的物件。示例程式碼如下所示。
let obj1 = { a:1, b:[1,2,3] }let str = JSON.stringify(obj1);let obj2 = JSON.parse(str);console.log(obj2); //{a:1,b:[1,2,3]}obj1.a = 2;obj1.b.push(4);console.log(obj1); //{a:2,b:[1,2,3,4]}console.log(obj2); //{a:1,b:[1,2,3]}
從上面的程式碼可以看到,透過 JSON.stringfy 可以初步實現一個物件的深複製,透過改變 obj1 的 b 屬性,其實可以看出 obj2 這個物件也不受影響。
但是使用 JSON.stringfy 實現深複製還是有一些地方值得注意,我總結下來主要有這幾點:
複製的物件的值中如果有函式、undefined、symbol 這幾種型別,經過 JSON.stringify 序列化之後的字串中這個鍵值對會消失;
複製 Date 引用型別會變成字串;無法複製不可列舉的屬性;無法複製物件的原型鏈;複製 RegExp 引用型別會變成空物件;物件中含有 NaN、Infinity 以及 -Infinity,JSON 序列化的結果會變成 null;無法複製物件的迴圈應用,即物件成環 (obj[key] = obj)。針對這些存在的問題,你可以嘗試著用下面的這段程式碼親自執行一遍,來看看如此複雜的物件,如果用 JSON.stringfy 實現深複製會出現什麼情況。
function Obj() { this.func = function () { alert(1) }; this.obj = {a:1}; this.arr = [1,2,3]; this.und = undefined; this.reg = /123/; this.date = new Date(0); this.NaN = NaN; this.infinity = Infinity; this.sym = Symbol(1);}let obj1 = new Obj(); Object.defineProperty(obj1,'innumerable',{ enumerable:false, value:'innumerable' });console.log('obj1',obj1);let str = JSON.stringify(obj1);let obj2 = JSON.parse(str);console.log('obj2',obj2);
透過上面這段程式碼可以看到執行結果如下圖所示。
使用 JSON.stringify 方法實現深複製物件,雖然到目前為止還有很多無法實現的功能,但是這種方法足以滿足日常的開發需求,並且是最簡單和快捷的。而對於其他的也要實現深複製的,比較麻煩的屬性對應的資料型別,JSON.stringify 暫時還是無法滿足的,那麼就需要下面的幾種方法了。
方法二:基礎版(手寫遞迴實現)
下面是一個實現 deepClone 函式封裝的例子,透過 for in 遍歷傳入引數的屬性值,如果值是引用型別則再次遞迴呼叫該函式,如果是基礎資料型別就直接複製,程式碼如下所示。
let obj1 = { a:{ b:1 }}function deepClone(obj) { let cloneObj = {} for(let key in obj) { //遍歷 if(typeof obj[key] ==='object') { cloneObj[key] = deepClone(obj[key]) //是物件就再次呼叫該函式遞迴 } else { cloneObj[key] = obj[key] //基本型別的話直接複製值 } } return cloneObj}let obj2 = deepClone(obj1);obj1.a.b = 2;console.log(obj2); // {a:{b:1}}
雖然利用遞迴能實現一個深複製,但是同上面的 JSON.stringfy 一樣,還是有一些問題沒有完全解決,例如:
這個深複製函式並不能複製不可列舉的屬性以及 Symbol 型別;
這種方法只是針對普通的引用型別的值做遞迴複製,而對於 Array、Date、RegExp、Error、Function 這樣的引用型別並不能正確地複製;
物件的屬性裡面成環,即迴圈引用沒有解決。
這種基礎版本的寫法也比較簡單,可以應對大部分的應用情況。但是你在面試的過程中,如果只能寫出這樣的一個有缺陷的深複製方法,有可能不會透過。
所以為了“拯救”這些缺陷,下面我帶你一起看看改進的版本,以便於你可以在面試中呈現出更好的深複製方法,贏得面試官的青睞。
方法三:改進版(改進後遞迴實現)
針對上面幾個待解決問題,我先透過四點相關的理論告訴你分別應該怎麼做。
針對能夠遍歷物件的不可列舉屬性以及 Symbol 型別,我們可以使用 Reflect.ownKeys 方法;
當引數為 Date、RegExp 型別,則直接生成一個新的例項返回;
利用 Object 的 getOwnPropertyDescriptors 方法可以獲得物件的所有屬性,以及對應的特性,順便結合 Object 的 create 方法建立一個新物件,並繼承傳入原物件的原型鏈;
關於第 4 點的 WeakMap,這裡我不進行過多的科普講解了,你如果不清楚可以自己再透過相關資料瞭解一下。我也經常在給人面試中看到有人使用 WeakMap 來解決迴圈引用問題,但是很多解釋都是不夠清晰的。
當你不太瞭解 WeakMap 的真正作用時,我建議你不要在面試中寫出這樣的程式碼,如果只是死記硬背,會給自己挖坑的。因為你寫的每一行程式碼都是需要經過深思熟慮並且非常清晰明白的,這樣你才能經得住面試官的推敲。
當然,如果你在考慮到迴圈引用的問題之後,還能用 WeakMap 來很好地解決,並且向面試官解釋這樣做的目的,那麼你所展示的程式碼,以及你對問題思考的全面性,在面試官眼中應該算是合格的了。
那麼針對上面這幾個問題,我們來看下改進後的遞迴實現的深複製程式碼應該是什麼樣子的,如下所示。
const isComplexDataType = obj => (typeof obj === 'object' || typeof obj === 'function') && (obj !== null)const deepClone = function (obj, hash = new WeakMap()) { if (obj.constructor === Date) return new Date(obj) // 日期物件直接返回一個新的日期物件 if (obj.constructor === RegExp) return new RegExp(obj) //正則物件直接返回一個新的正則物件 //如果迴圈引用了就用 weakMap 來解決 if (hash.has(obj)) return hash.get(obj) let allDesc = Object.getOwnPropertyDescriptors(obj) //遍歷傳入引數所有鍵的特性 let cloneObj = Object.create(Object.getPrototypeOf(obj), allDesc) //繼承原型鏈 hash.set(obj, cloneObj) for (let key of Reflect.ownKeys(obj)) { cloneObj[key] = (isComplexDataType(obj[key]) && typeof obj[key] !== 'function') ? deepClone(obj[key], hash) : obj[key] } return cloneObj}// 下面是驗證程式碼let obj = { num: 0, str: '', boolean: true, unf: undefined, nul: null, obj: { name: '我是一個物件', id: 1 }, arr: [0, 1, 2], func: function () { console.log('我是一個函式') }, date: new Date(0), reg: new RegExp('/我是一個正則/ig'), [Symbol('1')]: 1,};Object.defineProperty(obj, 'innumerable', { enumerable: false, value: '不可列舉屬性' });obj = Object.create(obj, Object.getOwnPropertyDescriptors(obj))obj.loop = obj // 設定loop成迴圈引用的屬性let cloneObj = deepClone(obj)cloneObj.arr.push(4)console.log('obj', obj)console.log('cloneObj', cloneObj)
我們看一下結果,cloneObj 在 obj 的基礎上進行了一次深複製,cloneObj 裡的 arr 陣列進行了修改,並未影響到 obj.arr 的變化,如下圖所示。
從這張截圖的結果可以看出,改進版的 deepClone 函式已經對基礎版的那幾個問題進行了改進,也驗證了我上面提到的那四點理論。
那麼到這裡,深複製的相關內容就介紹得差不多了。
總結
這一講,我們探討了如何實現一個深淺複製。在日常的開發中,由於開發者可以使用一些現成的庫來實現深複製,所以很多人對如何實現深複製的細節問題並不清楚。但是如果仔細研究你就會發現,這部分內容對於你深入瞭解 JS 底層的原理有很大幫助。如果未來你需要自己實現一個前端相關的工具或者庫,對 JS 理解的深度會決定你能把這個東西做得有多好。
其實到最後我們可以看到,自己完整實現一個深複製,還是考察了不少的知識點和程式設計能力,總結下來大致分為這幾點,請看下圖。
可以看到透過這一個問題能考察的能力有很多,因此千萬不要用最低的標準來要求自己,應該用類似的方法去分析每個問題深入考察的究竟是什麼,這樣才能更好地去全面提升自己的基本功。