深拷貝的終極探索(90%的人都不知道)
劃重點,這是一道面試必考題,我就問過很多面試者這個問題,✧(≖ ◡ ≖✿)嘿嘿
首先這是一道非常棒的面試題,可以考察面試者的很多方面,比如基本功,程式碼能力,邏輯能力,而且進可攻,退可守,針對不同級別的人可以考察不同難度,比如漂亮妹子就出1☆題,(*^__^*) 嘻嘻……
一般在面試者回答出問題後,我總能夠瀟灑的再丟擲一些問題,看著面試者露出驚異的眼神,默默一轉身,深藏功與名
本文我將給大家破解深拷貝的謎題,由淺入深,環環相扣,總共涉及4種深拷貝方式,每種方式都有自己的特點和個性
深拷貝 VS 淺拷貝
再開始之前需要先給同學科普下什麼是深拷貝,和深拷貝有關係的另個一術語是淺拷貝又是什麼意思呢?如果對這部分部分內容瞭解的同學可以跳過
其實深拷貝和淺拷貝都是針對的引用型別,JS中的變數型別分為值型別(基本型別)和引用型別;對值型別進行復制操作會對值進行一份拷貝,而對引用型別賦值,則會進行地址的拷貝,最終兩個變數指向同一份資料
// 基本型別
var a = 1;
var b = a;
a = 2;
console.log(a, b); // 2, 1 ,a b指向不同的資料
// 引用型別指向同一份資料
var a = {c: 1};
var b = a;
a.c = 2;
console.log(a.c, b.c); // 2, 2 全是2,a b指向同一份資料
複製程式碼
對於引用型別,會導致a b指向同一份資料,此時如果對其中一個進行修改,就會影響到另外一個,有時候這可能不是我們想要的結果,如果對這種現象不清楚的話,還可能造成不必要的bug
那麼如何切斷a和b之間的關係呢,可以拷貝一份a的資料,根據拷貝的層級不同可以分為淺拷貝和深拷貝,淺拷貝就是隻進行一層拷貝,深拷貝就是無限層級拷貝
var a1 = {b: {c: {}};
var a2 = shallowClone(a1); // 淺拷貝
a2.b.c === a1.b.c // true
var a3 = clone(a1); // 深拷貝
a3.b.c === a1.b.c // false
複製程式碼
淺拷貝的實現非常簡單,而且還有多種方法,其實就是遍歷物件屬性的問題,這裡只給出一種,如果看不懂下面的方法,或對其他方法感興趣,可以看我的這篇文章
function shallowClone(source) {
var target = {};
for(var i in source) {
if (source.hasOwnProperty(i)) {
target[i] = source[i];
}
}
return target;
}
複製程式碼
最簡單的深拷貝
深拷貝的問題其實可以分解成兩個問題,淺拷貝+遞迴,什麼意思呢?假設我們有如下資料
var a1 = {b: {c: {d: 1}};
複製程式碼
只需稍加改動上面淺拷貝的程式碼即可,注意區別
function clone(source) {
var target = {};
for(var i in source) {
if (source.hasOwnProperty(i)) {
if (typeof source[i] === 'object') {
target[i] = clone(source[i]); // 注意這裡
} else {
target[i] = source[i];
}
}
}
return target;
}
複製程式碼
大部分人都能寫出上面的程式碼,但當我問上面的程式碼有什麼問題嗎?就很少有人答得上來了,聰明的你能找到問題嗎?
其實上面的程式碼問題太多了,先來舉幾個例子吧
- 沒有對引數做檢驗
- 判斷是否物件的邏輯不夠嚴謹
- 沒有考慮陣列的相容
(⊙o⊙),下面我們來看看各個問題的解決辦法,首先我們需要抽象一個判斷物件的方法,其實比較常用的判斷物件的方法如下,其實下面的方法也有問題,但如果能夠回答上來那就非常不錯了,如果完美的解決辦法感興趣,不妨看看這裡吧
function isObject(x) {
return Object.prototype.toString.call(x) === '[object Object]';
}
複製程式碼
函式需要校驗引數,如果不是物件的話直接返回
function clone(source) {
if (!isObject(source)) return source;
// xxx
}
複製程式碼
關於第三個問題,嗯,就留給大家自己思考吧,本文為了減輕大家的負擔,就不考慮陣列的情況了,其實ES6之後還要考慮set, map, weakset, weakmap,/(ㄒoㄒ)/~~
其實吧這三個都是小問題,其實遞迴方法最大的問題在於爆棧,當資料的層次很深是就會棧溢位
下面的程式碼可以生成指定深度和每層廣度的程式碼,這段程式碼我們後面還會再次用到
function createData(deep, breadth) {
var data = {};
var temp = data;
for (var i = 0; i < deep; i++) {
temp = temp['data'] = {};
for (var j = 0; j < breadth; j++) {
temp[j] = j;
}
}
return data;
}
createData(1, 3); // 1層深度,每層有3個數據 {data: {0: 0, 1: 1, 2: 2}}
createData(3, 0); // 3層深度,每層有0個數據 {data: {data: {data: {}}}}
複製程式碼
當clone層級很深的話就會棧溢位,但資料的廣度不會造成溢位
clone(createData(1000)); // ok
clone(createData(10000)); // Maximum call stack size exceeded
clone(createData(10, 100000)); // ok 廣度不會溢位
複製程式碼
其實大部分情況下不會出現這麼深層級的資料,但這種方式還有一個致命的問題,就是迴圈引用,舉個例子
var a = {};
a.a = a;
clone(a) // Maximum call stack size exceeded 直接死迴圈了有沒有,/(ㄒoㄒ)/~~
複製程式碼
關於迴圈引用的問題解決思路有兩種,一直是迴圈檢測,一種是暴力破解,關於迴圈檢測大家可以自己思考下;關於暴力破解我們會在下面的內容中詳細講解
一行程式碼的深拷貝
有些同學可能見過用系統自帶的JSON來做深拷貝的例子,下面來看下程式碼實現
function cloneJSON(source) {
return JSON.parse(JSON.stringify(source));
}
複製程式碼
其實我第一次簡單這個方法的時候,由衷的表示佩服,其實利用工具,達到目的,是非常聰明的做法
下面來測試下cloneJSON有沒有溢位的問題,看起來cloneJSON內部也是使用遞迴的方式
cloneJSON(createData(10000)); // Maximum call stack size exceeded
複製程式碼
既然是用了遞迴,那迴圈引用呢?並沒有因為死迴圈而導致棧溢位啊,原來是JSON.stringify內部做了迴圈引用的檢測,正是我們上面提到破解迴圈引用的第一種方法:迴圈檢測
var a = {};
a.a = a;
cloneJSON(a) // Uncaught TypeError: Converting circular structure to JSON
複製程式碼
破解遞迴爆棧
其實破解遞迴爆棧的方法有兩條路,第一種是消除尾遞迴,但在這個例子中貌似行不通,第二種方法就是乾脆不用遞迴,改用迴圈,當我提出用迴圈來實現時,基本上90%的前端都是寫不出來的程式碼的,這其實讓我很震驚
舉個例子,假設有如下的資料結構
var a = {
a1: 1,
a2: {
b1: 1,
b2: {
c1: 1
}
}
}
複製程式碼
這不就是一個樹嗎,其實只要把資料橫過來看就非常明顯了
a
/ \
a1 a2
| / \
1 b1 b2
| |
1 c1
|
1
複製程式碼
用迴圈遍歷一棵樹,需要藉助一個棧,當棧為空時就遍歷完了,棧裡面儲存下一個需要拷貝的節點
首先我們往棧裡放入種子資料,key
用來儲存放哪一個父元素的那一個子元素拷貝物件
然後遍歷當前節點下的子元素,如果是物件就放到棧裡,否則直接拷貝
function cloneLoop(x) {
const root = {};
// 棧
const loopList = [
{
parent: root,
key: undefined,
data: x,
}
];
while(loopList.length) {
// 深度優先
const node = loopList.pop();
const parent = node.parent;
const key = node.key;
const data = node.data;
// 初始化賦值目標,key為undefined則拷貝到父元素,否則拷貝到子元素
let res = parent;
if (typeof key !== 'undefined') {
res = parent[key] = {};
}
for(let k in data) {
if (data.hasOwnProperty(k)) {
if (typeof data[k] === 'object') {
// 下一次迴圈
loopList.push({
parent: res,
key: k,
data: data[k],
});
} else {
res[k] = data[k];
}
}
}
}
return root;
}
複製程式碼
改用迴圈後,再也不會出現爆棧的問題了,但是對於迴圈引用依然無力應對
破解迴圈引用
有沒有一種辦法可以破解迴圈應用呢?彆著急,我們先來看另一個問題,上面的三種方法都存在的一個問題就是引用丟失,這在某些情況下也許是不能接受的
舉個例子,假如一個物件a,a下面的兩個鍵值都引用同一個物件b,經過深拷貝後,a的兩個鍵值會丟失引用關係,從而變成兩個不同的物件,o(╯□╰)o
var b = {};
var a = {a1: b, a2: b};
a.a1 === a.a2 // true
var c = clone(a);
c.a1 === c.a2 // false
複製程式碼
如果我們發現個新物件就把這個物件和他的拷貝存下來,每次拷貝物件前,都先看一下這個物件是不是已經拷貝過了,如果拷貝過了,就不需要拷貝了,直接用原來的,這樣我們就能夠保留引用關係了,✧(≖ ◡ ≖✿)嘿嘿
但是程式碼怎麼寫呢,o(╯□╰)o,別急往下看,其實和迴圈的程式碼大體一樣,不一樣的地方我用// ==========
標註出來了
引入一個數組uniqueList
用來儲存已經拷貝的陣列,每次迴圈遍歷時,先判斷物件是否在uniqueList
中了,如果在的話就不執行拷貝邏輯了
find
是抽象的一個函式,其實就是遍歷uniqueList
// 保持引用關係
function cloneForce(x) {
// =============
const uniqueList = []; // 用來去重
// =============
let root = {};
// 迴圈陣列
const loopList = [
{
parent: root,
key: undefined,
data: x,
}
];
while(loopList.length) {
// 深度優先
const node = loopList.pop();
const parent = node.parent;
const key = node.key;
const data = node.data;
// 初始化賦值目標,key為undefined則拷貝到父元素,否則拷貝到子元素
let res = parent;
if (typeof key !== 'undefined') {
res = parent[key] = {};
}
// =============
// 資料已經存在
let uniqueData = find(uniqueList, data);
if (uniqueData) {
parent[key] = uniqueData.target;
continue; // 中斷本次迴圈
}
// 資料不存在
// 儲存源資料,在拷貝資料中對應的引用
uniqueList.push({
source: data,
target: res,
});
// =============
for(let k in data) {
if (data.hasOwnProperty(k)) {
if (typeof data[k] === 'object') {
// 下一次迴圈
loopList.push({
parent: res,
key: k,
data: data[k],
});
} else {
res[k] = data[k];
}
}
}
}
return root;
}
function find(arr, item) {
for(let i = 0; i < arr.length; i++) {
if (arr[i].source === item) {
return arr[i];
}
}
return null;
}
複製程式碼
下面來驗證一下效果,amazing
var b = {};
var a = {a1: b, a2: b};
a.a1 === a.a2 // true
var c = cloneForce(a);
c.a1 === c.a2 // true
複製程式碼
接下來再說一下如何破解迴圈引用,等一下,上面的程式碼好像可以破解迴圈引用啊,趕緊驗證一下
驚不驚喜,(*^__^*) 嘻嘻……
var a = {};
a.a = a;
cloneForce(a)
複製程式碼
看起來完美的cloneForce
是不是就沒問題呢?cloneForce
有兩個問題
第一個問題,所謂成也蕭何,敗也蕭何,如果保持引用不是你想要的,那就不能用cloneForce
了;
第二個問題,cloneForce
在物件數量很多時會出現很大的問題,如果資料量很大不適合使用cloneForce
效能對比
上邊的內容還是有點難度,下面我們來點更有難度的,對比一下不同方法的效能
我們先來做實驗,看資料,影響效能的原因有兩個,一個是深度,一個是每層的廣度,我們採用固定一個變數,只讓一個變數變化的方式來測試效能
測試的方法是在指定的時間內,深拷貝執行的次數,次數越多,證明效能越好
下面的runTime
是測試程式碼的核心片段,下面的例子中,我們可以測試在2秒內執行clone(createData(500, 1)
的次數
function runTime(fn, time) {
var stime = Date.now();
var count = 0;
while(Date.now() - stime < time) {
fn();
count++;
}
return count;
}
runTime(function () { clone(createData(500, 1)) }, 2000);
複製程式碼
下面來做第一個測試,將廣度固定在100,深度由小到大變化,記錄1秒內執行的次數
深度 | clone | cloneJSON | cloneLoop | cloneForce |
---|---|---|---|---|
500 | 351 | 212 | 338 | 372 |
1000 | 174 | 104 | 175 | 143 |
1500 | 116 | 67 | 112 | 82 |
2000 | 92 | 50 | 88 | 69 |
將上面的資料做成表格可以發現,一些規律
- 隨著深度變小,相互之間的差異在變小
- clone和cloneLoop的差別並不大
- cloneLoop > cloneForce > cloneJSON
我們先來分析下各個方法的時間複雜度問題,各個方法要做的相同事情,這裡就不計算,比如迴圈物件,判斷是否為物件
- clone時間 = 建立遞迴函式 + 每個物件處理時間
- cloneJSON時間 = 迴圈檢測 + 每個物件處理時間 * 2 (遞迴轉字串 + 遞迴解析)
- cloneLoop時間 = 每個物件處理時間
- cloneForce時間 = 判斷物件是否快取中 + 每個物件處理時間
cloneJSON的速度只有clone的50%,很容易理解,因為其會多進行一次遞迴時間
cloneForce由於要判斷物件是否在快取中,而導致速度變慢,我們來計算下判斷邏輯的時間複雜度,假設物件的個數是n,則其時間複雜度為O(n2),物件的個數越多,cloneForce的速度會越慢
1 + 2 + 3 ... + n = n^2/2 - 1
複製程式碼
關於clone和cloneLoop這裡有一點問題,看起來實驗結果和推理結果不一致,其中必有蹊蹺
接下來做第二個測試,將深度固定在10000,廣度固定為0,記錄2秒內執行的次數
寬度 | clone | cloneJSON | cloneLoop | cloneForce |
---|---|---|---|---|
0 | 13400 | 3272 | 14292 | 989 |
排除寬度的干擾,來看看深度對各個方法的影響
- 隨著物件的增多,cloneForce的效能低下凸顯
- cloneJSON的效能也大打折扣,這是因為迴圈檢測佔用了很多時間
- cloneLoop的效能高於clone,可以看出遞迴新建函式的時間和迴圈物件比起來可以忽略不計
下面我們來測試一下cloneForce的效能極限,這次我們測試執行指定次數需要的時間
var data1 = createData(2000, 0);
var data2 = createData(4000, 0);
var data3 = createData(6000, 0);
var data4 = createData(8000, 0);
var data5 = createData(10000, 0);
cloneForce(data1)
cloneForce(data2)
cloneForce(data3)
cloneForce(data4)
cloneForce(data5)
複製程式碼
通過測試發現,其時間成指數級增長,當物件個數大於萬級別,就會有300ms以上的延遲
總結
尺有所短寸有所長,無關乎好壞優劣,其實每種方法都有自己的優缺點,和適用場景,人盡其才,物盡其用,方是真理
下面對各種方法進行對比,希望給大家提供一些幫助
clone | cloneJSON | cloneLoop | cloneForce | |
---|---|---|---|---|
難度 | ☆☆ | ☆ | ☆☆☆ | ☆☆☆☆ |
相容性 | ie6 | ie8 | ie6 | ie6 |
迴圈引用 | 一層 | 不支援 | 一層 | 支援 |
棧溢位 | 會 | 會 | 不會 | 不會 |
保持引用 | 否 | 否 | 否 | 是 |
適合場景 | 一般資料拷貝 | 一般資料拷貝 | 層級很多 | 保持引用關係 |
本文的靈感都來自於@jsmini/clone,如果大家想使用文中的4種深拷貝方式,可以直接使用@jsmini/clone這個庫
// npm install --save @jsmini/clone
import { clone, cloneJSON, cloneLoop, cloneForce } from '@jsmini/clone';
複製程式碼
本文為了簡單和易讀,示例程式碼中忽略了一些邊界情況,如果想學習生產中的程式碼,請閱讀@jsmini/clone的原始碼
@jsmini/clone孵化於jsmini,jsmini致力於為大家提供一組小而美,無依賴的高質量庫
jsmini的誕生離不開jslib-base,感謝jslib-base為jsmini提供了底層技術
感謝你閱讀了本文,相信現在你能夠駕馭任何深拷貝的問題了,如果有什麼疑問,歡迎和我討論
最後推薦下我的新書《React狀態管理與同構實戰》,深入解讀前沿同構技術,感謝大家支援
最後最後招聘前端,後端,客戶端啦!地點:北京+上海+成都,感興趣的同學,可以把簡歷發到我的郵箱: [email protected]