前言:
JS的拷貝(copy),之所以分為深淺兩種形式,是因為JS變量的類型存在premitive(字面量)與reference(引用)兩種區別。當然,大多數編程語言都存在這種特性。
眾所周知,內存包含的結構中,有堆與棧。在JS里,字面量類型變量存放在棧中,儲存的是它的值,而引用類型變量雖然在棧中也占有空間,但儲存的只是一個內存地址(通過該地址可以索引找到真實結構所在的內存區域),它的真實結構是存在於堆中的。如下圖所示:
結合圖示來看,一般來說,淺拷貝只是拷貝了內存棧中的數據,深拷貝,則是要沿着引用類型變量的真實內存地址,去進行一次次的深度遍歷,直到拷貝完目標遍歷在棧與堆中的所有真實值。
一、淺拷貝的實現
JS實現了一些擁有淺拷貝功能的接口,比如解構賦值的rest模式、Object.assgin。
但淺拷貝的缺陷在於,進行拷貝之后,如果改變了被拷貝目標的某個引用屬性的值,則拷貝結果的對應屬性的值也會發生改變,反過來亦是如此。
比如將example['愛好'][0]賦予新值 ('聽歌'),如下圖所示。
從本質上來說,就是因為兩者都指向同一個內存區域。那片內存區域一旦發生了變動,自然兩者取到的值都發生改變,而且完全一樣。
二、深拷貝的實現
深拷貝的原理,前文已經敘述過,但過於抽象,還不夠具體。
JS里,可以利用原生的JSON序列化與反序列化接口組合進行實現深拷貝。
如下圖所示,深拷貝的結果與被拷貝的目標之間,已經互不影響。
不過,JSON方式實現的深拷貝,有很多缺陷,首先,是拷貝失真:
1. 值為undefined、函數、Symbol的屬性,或者鍵為Symbol字符串的屬性,拷貝后,屬性會丟失。
2. 值為NaN的屬性,拷貝后,值轉為了null。
3. 值為非標准對象Object,比如Set、Map、Error、RegExp等等的屬性或數組元素,拷貝后,值轉為了空的標准對象,丟失了原來的原型繼承關系。
3. 值為undefined、NaN、函數的數組元素,拷貝后,值轉為了null。
其次,是拷貝功能的缺陷:
1. 原型鏈丟失
2. 無法拷貝有循環引用的對象
綜上所述,要實現比較完整功能的深拷貝,就必須得兼顧JSON方式的功能和缺點。
三、手動實現深拷貝
追尋深拷貝的實現方式,可以理解為:深拷貝 = 淺拷貝+深度遍歷+特殊情況容錯。
以下,我們來實現一個深拷貝函數,deepCopy。假定函數接受的輸入為o。
// 深拷貝函數 function deepCopy(o) { }
深拷貝的基本實現思路,從JS數據類型的角度出發,可以先區分字面量與引用兩種類型的變量。
然后,只要判斷是字面量,我們就直接淺拷貝返回,否則就進入深度遍歷,重復前面的淺拷貝,直到遍歷結束。
// 深拷貝函數 function deepCopy(o) { // 如果是字面量,直接返回 if(isPrimitive(o)) return o; // 否則,進行深度遍歷 /** * 深度遍歷代碼 */ }
我們先實現一個判斷輸入是否為字面量的函數
function isPrimitive(o) { if (typeof o !== 'function' && typeof o !== 'object') return true; if (o === null) return true; return false; }
然后,進行深度遍歷。深度遍歷一般有兩種選擇,一個是遞歸,一個是while循環。
遞歸很好理解,但有個缺陷,大量函數棧幀的入棧,很容易導致內存空間不足而爆棧,特別是對於有循環引用關系的輸入,可能秒秒鍾爆炸。這里,我們采用while循環。
采用while循環的話,我們可以模擬一個棧結構,棧如果為空,則結束循環,若不為空,則進行循環,循環第一步,先出棧,然后處理數據,處理完之后,進入下一次循環判斷。
在JS里,模擬棧結構可以用數組,push與pop組合,完美實現后入先出。在數據結構與算法里,這叫深度優先。
// 深拷貝函數 function deepCopy(o) { // 如果是字面量,直接返回; 否則,進行深度遍歷 if(isPrimitive(o)) return o; // 首先,先定義一個觀察者,用來記錄遍歷的結果。等到遍歷結束,這個觀察者就是深拷貝的結果。 const observer = {}; // 然后,用數組模擬一個棧結構 const nodeList = []; // 其次,為了每次遍歷時能地做一些處理,入棧的數據用對象來表示比較合適。 nodeList.push({ key: null, // 這里,增加一個key屬性,用來關聯每次遍歷所要處理的數據。 }); // 循環遍歷 while(nodeList.length > 0) { const node = nodeList.pop(); // 出棧,深度優先 // 處理節點node } }
接下來,就是處理節點node了。這里要處理的任務,主要有:
1.特殊情況處理,比如Symbol類型的屬性雖然無法被Object.keys迭代出來,但可以用Reflect.ownKeys來解決。又比如,針對循環引用,可以在循環外面建立哈希表,每次循環都判斷要處理的輸入是否已存在哈希表,如果存在,直接引用,否則,存入哈希表。
// 用WeakMap模擬的哈希表,它的弱引用特性可以避免內存泄露 const hashmap = new WeakMap(); // 遍歷包括Symbol類型在內的所有屬性 const keys = Reflect.ownKeys(node.value);
2.初始化,將輸入o掛載到節點里,並存入哈希表。
// 初始化 if (node.key === null) { node.value = o; node.observer = observer // 存入哈希表 hashmap.set(node.value, node.observer) }
3.對節點的屬性進行遍歷,屬性值為引用類型,將它壓入棧,否則,觀察者利用關聯的key記錄屬性值,然后進入下一次循環。
for (let i = 0; i < keys.length; i++) { key = keys[i]; value = node.value[key]; // 是字面量,直接記錄 if (isPrimitive(value)) { node.observer[key] = value; continue; } // 否則,入棧 nodeList.push({ key, value, observer: node.observer }) }
4.每次對節點屬性進行遍歷前,先根據哈希表進行判斷
// 查詢哈希表,如果不存在對象key,就存入哈希表 if (!hashmap.has(node.value)) { hashmap.set(node.value, node.observer[node.key] = isArray(node.value) ? [] : {}); // 將對象壓入棧 nodeList.push({ key: node.key, value: node.value, observer: node.observer[node.key] }) continue; } // 存在哈希表里,則從哈希表里取出,賦值 else if (node.observer !== hashmap.get(node.value)) { node.observer[node.key] = hashmap.get(node.value) continue; }
這里,補上isArray函數,用來判斷是否為數組
function isArray(o) { return Object.prototype.toString.call(o) === '[object Array]'; }
到此,深拷貝函數已經成型了。但,還不夠完善,因為還沒有對輸入是函數的情況做處理。
所以,添加兩個函數,一個判斷是否是函數,一個用例拷貝函數。
// 判斷函數 function isFunction(o) { return Object.prototype.toString.call(o) === '[object Function]'; } // 拷貝函數 function copyFunction(fnc) { const f = eval(`(${fnc.toString()})`) Object.setPrototypeOf(f, Object.getPrototypeOf(fnc)) Object.keys(fnc).map(key => f[key] = deepCopy(fnc[key])) return f; }
循環遍歷之前,加一層對函數的判斷
// 是函數,則拷貝函數 if (isFunction(o)) return copyFunction(o);
遍歷的時候,也要加一層對函數的判斷
// 函數直接賦值
else if (isFunction(node.value)) { node.observer[node.key] = copyFunction(node.value) continue; }
循環結束后,我們還要對原型鏈進行處理,深拷貝,不能把繼承關系給弄丟,這也是輸入無論是數組還是對象都能獲得正確拷貝結果的一個技巧
// 繼承原型 Object.setPrototypeOf(observer, Object.getPrototypeOf(o))
最后,返回觀察者對象,即深拷貝結果。
// 返回深拷貝結果 return observer;
四、測試結果與結論
五、手動實現的深拷貝完整代碼