在開始之前,先拋出來兩個問題,思考一下,
- 拷貝一個很多嵌套的物件怎么實作?
- 在面試官眼中,寫成什么樣的深拷貝代碼才能算合格?
帶著這兩個問題,我們先來看下淺拷貝的相關內容,
淺拷貝的原理和實作
??對于淺拷貝的定義,我們可以初步理解為:
自己創建一個新的物件,來接受你要重新復制或參考的物件值,如果物件屬性是基本的資料型別,復制的就是基本型別的值給新物件;但如果屬性是參考資料型別,復制的就是記憶體中的地址,如果其中一個物件改變了這個記憶體中的地址,肯定會影響到另一個物件,
??下面總結了一些JavaScript提供的淺拷貝方法,一起來看看哪些方法能實作上述定義所描述的程序,
方法一:object.assign
??object.assign 是 ES6 中 object 的一個方法,該方法可以用于 JS 物件的合并等多個用途,其中一個用途就是可以進行淺拷貝,該方法的第一個引數是拷貝的目標物件,后面的引數是拷貝的來源物件(也可以是多個來源),
object.assign 的語法為:Object.assign(target, …sources)
object.assign 的示例代碼如下:
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 = 2
console.log(obj) //{a:2,b:{c:1}} console.log(obj2); //{a:1,b:{c:1}}
obj.b.c = 2
console.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);
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 === 'obejct' && target !==null) {
const cloneTarget = Array.isArray(target) ? [] : {};
for (let i in target) {
if (target.hasOwnProperty(i)){
cloneTarget[prop] = target[prop];
}
}
return cloneTarget;
} else {
return target;
}
}
??從上面這段代碼可以看出,利用型別判斷,針對參考型別的物件進行 for 回圈遍歷物件屬性賦值給目標物件的屬性,基本就可以手工實作一個淺拷貝的代碼了,
深拷貝的原理和實作
??淺拷貝只是創建了一個新的物件,復制了原有物件的基本型別的值,而參考資料型別只拷貝了一層屬性,再深層的還是無法進行拷貝,深拷貝則不同,對于復雜參考資料型別,其在堆記憶體中完全開辟了一塊記憶體地址,并將原有的物件完全復制過來存放,
??這兩個物件是相互獨立、不受影響的,徹底實作了記憶體上的分離,總的來說,深拷貝的原理可以總結如下:
將一個物件從記憶體中完整地拷貝出來一份給目標物件,并從堆記憶體中開辟一個全新的空間存放新物件,且新物件的修改并不會改變原物件,二者實作真正的分離,
方法一:乞丐版(JSON.stringify)
??JSON.stringify() 是目前開發程序中最簡單的深拷貝方法,其實就是把一個物件序列化成為 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.stringify 可以初步實作一個物件的深拷貝,通過改變 obj1 的 b 屬性,其實可以看出 obj2 這個物件也不受影響,
??但是使用 JSON.stringify 實作深拷貝還是有一些地方值得注意,總結下來主要有這幾點:
- 拷貝的物件的值中如果有函式、
undefined、symbol這幾種型別,經過JSON.stringify序列化之后的字串中這個鍵值對會消失; - 拷貝
Date參考型別會變成字串; - 無法拷貝不可列舉的屬性;
- 無法拷貝物件的原型鏈;
- 拷貝
RegExp參考型別會變成空物件; - 物件中含有
NaN、Infinity以及-Infinity,JSON序列化的結果會變成null; - 無法拷貝物件的回圈應用,即物件成環
(obj[key] = obj),
針對這些存在的問題,可以嘗試著用下面的這段代碼親自執行一遍,來看看如此復雜的物件,如果用 JSON.stringify 實作深拷貝會出現什么情況,
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.stringify 一樣,還是有一些問題沒有完全解決,例如:
- 這個深拷貝函式并不能復制不可列舉的屬性以及
Symbol型別; - 這種方法只是針對普通的參考型別的值做遞回復制,而對于
Array、Date、RegExp、Error、Function這樣的參考型別并不能正確地拷貝; - 物件的屬性里面成環,即回圈參考沒有解決,
這種基礎版本的寫法也比較簡單,可以應對大部分的應用情況,但是在面試的程序中,如果只能寫出這樣的一個有缺陷的深拷貝方法,有可能不會通過,
方法三:改進版(改進后遞回實作)
針對上面幾個待解決問題,先通過四點相關的理論告訴分別應該怎么做,
- 針對能夠遍歷物件的不可列舉屬性以及
Symbol型別,我們可以使用Reflect.ownKeys方法; - 當引數為
Date、RegExp型別,則直接生成一個新的實體回傳; - 利用
Object的getOwnPropertyDescriptors方法可以獲得物件的所有屬性,以及對應的特性,順便結合Object的create方法創建一個新物件,并繼承傳入原物件的原型鏈; - 利用
WeakMap型別作為Hash表,因為WeakMap是弱參考型別,可以有效防止記憶體泄漏(你可以關注一下Map和weakMap的關鍵區別,這里要用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 理解的深度會決定能把這個東西做得有多好,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qianduan/328018.html
標籤:其他
上一篇:Java專案:寵物醫院預約掛號系統(java+JSP+Spring+SpringBoot+MyBatis+html+layui+maven+Mysql)
下一篇:watch監聽器的基本使用
