前言
這是前端面試題系列的第 9 篇,你可能錯過了前面的篇章,可以在這裡找到:
- 陣列去重(10 種濃縮版)
- JavaScript 中的事件機制(從原生到框架)
- 理解函式的柯里化
- ES6 中箭頭函式的用法
- this 的原理以及用法
- 偽類與偽元素的區別及實戰
- 如何實現一個聖盃佈局?
- 今日頭條 面試題和思路解析
面試的時候,我經常會問候選人深拷貝與淺拷貝的問題。因為它可以考察一個人的很多方面,比如基本功,邏輯能力,編碼能力等等。
另外在實際工作中,也常會遇到它。比如用於頁面展示的資料狀態,與需要傳給後端的資料包中,有部分欄位的值不一致的話,就需要在傳參時根據介面文件覆寫那幾個欄位的值。
最常見的可能就是 status 這個引數了。介面上的展示需要 Boolean 值,而後端同學希望拿到的是 Number 值,1 或者 0。為了不影響展示效果,往往就需要深拷貝一下,再進行覆寫,否則介面上就會因為某些值的變化,出現奇怪的現象。
至於為什麼會這樣,下文會講到。馬上開始今天的主題,讓我們先從賦值開始說起。
賦值
Javascript 的原始資料型別有這幾種:Boolean、Null、Undefined、Number、String、Symbol(ES6)
。它們的賦值很簡單,且賦值後兩個變數互不影響。
let test1 = 'chao';
let test2 = test1;
// test2: chao
test1 = 'chao_change';
// test2: chao
// test1: chao_change
複製程式碼
另外的引用資料型別有:Object
和 Array
。深拷貝與淺拷貝的出現,就與這兩個資料型別有關。
const obj = {a:1, b:2};
const obj2 = obj;
obj2.a = 3;
console.log(obj.a); // 3
複製程式碼
依照賦值的思路,對 Object 引用型別進行拷貝,就會出問題。很多情況下,這不是我們想要的。這時,就需要用淺拷貝來實現了。
淺拷貝
什麼是淺拷貝?可以這麼理解:建立一個新的物件,把原有的物件屬性值,完整地拷貝過來。其中包括了原始型別的值,還有引用型別的記憶體地址。
讓我們用 Object.assign
來改寫一下上面的例子:
const obj = {a:1, b:2};
const obj2 = Object.assign({}, obj);
obj2.a = 3;
console.log(obj.a); // 1
複製程式碼
Ok,改變了 obj2 的 a 屬性,但 obj 的 a 並沒有發生變化,這正是我們想要的。
可是,這樣的拷貝還有瑕疵,再改一下例子:
const arr = [{a:1,b:2}, {a:3,b:4}];
const newArr = [].concat(arr);
newArr.length = 1; // 為了方便區分,只保留新陣列的第一個元素
console.log(newArr); // [{a:1,b:2}]
console.log(arr); // [{a:1,b:2},{a:3,b:4}]
newArr[0].a = 123; // 修改 newArr 中第一個元素的a
console.log(arr[0]); // {a: 123, b: 2},竟然把 arr 的第一個元素的 a 也改了
複製程式碼
oh,no!這不是我們想要的...
經過一番查詢,才發現:原來,物件的 Object.assign()
,陣列的 Array.prototype.slice()
和 Array.prototype.concat()
,還有 ES6 的 擴充套件運算子
,都有類似的問題,它們都屬於 淺拷貝。這一點,在實際工作中處理資料的組裝時,要格外注意。
所以,我將淺拷貝這樣定義:只拷貝第一層的原始型別值,和第一層的引用型別地址
。
深拷貝
我們當然希望當拷貝多層級的物件時,也能實現互不影響的效果。所以,深拷貝的概念也就油然而生了。我將深拷貝定義為:拷貝所有的屬性值,以及屬性地址指向的值的記憶體空間
。
也就是說,當遇到物件時,就再新開一個物件,然後將第二層源物件的屬性值,完整地拷貝到這個新開的物件中。
按照淺拷貝的思路,很容易就想到了遞迴呼叫。所以,就自己封裝了個深拷貝的方法:
function deepClone(obj) {
if(!obj && typeof obj !== 'object'){
return;
}
var newObj= toString.call(obj) === '[object Array]' ? [] : {};
for (var key in obj) {
if (obj[key] && typeof obj[key] === 'object') {
newObj[key] = deepClone(obj[key]);
} else {
newObj[key] = obj[key];
}
}
return newObj;
}
複製程式碼
再試試看:
let arr = [{a:1,b:2}, {a:3,b:4}];
let newArr = deepClone(arr);
newArr.length = 1; // 為了方便區分,只保留新陣列的第一個元素
console.log(newArr); // [{a:1, b:2}]
console.log(arr); // [{a:1, b:2}, {a:3, b:4}]
newArr[0].a = 123; // 修改 newArr 中第一個元素的 a
console.log(arr[0]); // {a:1, b:2}
複製程式碼
ok,這下搞定了。
不過,這個方法貌似會存在 引用丟失 的的問題。比如這樣:
var b = {};
var a = {a1: b, a2: b};
a.a1 === a.a2 // true
var c = clone(a);
c.a1 === c.a2 // false
複製程式碼
如果我們的需求是,應該丟失引用,那就可以用這個方法。反之,就得想辦法解決。
一行程式碼的深拷貝
當然,還有最簡單粗暴的深拷貝方法,就是利用 JSON
了。像這樣:
let newArr2 = JSON.parse(JSON.stringify(arr));
console.log(arr[0]); // {a:1, b:2}
newArr2[0].a = 123;
console.log(arr[0]); // {a:1, b:2}
複製程式碼
但是,JSON 內部用了遞迴的方式。資料一但過多,就會有遞迴爆棧的風險。
// Maximum call stack size exceeded
複製程式碼
深拷貝的終極方案
有位大佬給出了深拷貝的終極方案,利用了“棧”的思想。
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 = uniqueList.find((item) => item.source === 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;
}
複製程式碼
其思路是:引入一個陣列 uniqueList
用來儲存已經拷貝的陣列,每次迴圈遍歷時,先判斷物件是否在 uniqueList
中了,如果在的話就不執行拷貝邏輯了。
這個方法是在解決遞迴爆棧問題的基礎上,加以改進解決迴圈引用的問題。但如果你並不想保持引用,那就改用 cloneLoop
(用於解決遞迴爆棧)即可。有興趣的同學,可以前往 深拷貝的終極探索(90%的人都不知道),檢視更多的細節。
總結
所謂深拷貝與淺拷貝,指的是 Object
和 Array
這樣的引用資料型別。
淺拷貝
,只拷貝第一層的原始型別值,和第一層的引用型別地址。
深拷貝
,拷貝所有的屬性值,以及屬性地址指向的值的記憶體空間。通過遞迴呼叫,或者 JSON 來做深拷貝,都會有一些問題。而 cloneForce 方法倒是目前看來最完美的解決方案了。
在日常的工作中,我們要特別注意,物件的 Object.assign()
,陣列的 Array.prototype.slice()
和 Array.prototype.concat()
,還有 ES6 的 擴充套件運算子
,都屬於淺拷貝。當需要做資料組裝時,一定要用深拷貝,以免影響介面展示效果。
崗位內推
莉莉絲遊戲招 中高階前端工程師
啦!!!
你玩過《小冰冰傳奇([刀塔傳奇])》麼?你玩過《劍與家園》麼?
你想和 薛兆豐老師 成為同事麼?有興趣的同學,可以 關注下面的公眾 號加我微信 詳聊哈~