「前端面試題系列9」淺拷貝與深拷貝的含義、區別及實現(文末有崗位內推哦~)

前言

這是前端面試題系列的第 9 篇,你可能錯過了前面的篇章,可以在這裏找到:

面試的時候,我經常會問候選人深拷貝與淺拷貝的問題。因爲它可以考察一個人的很多方面,比如基本功,邏輯能力,編碼能力等等。

另外在實際工作中,也常會遇到它。比如用於頁面展示的數據狀態,與需要傳給後端的數據包中,有部分字段的值不一致的話,就需要在傳參時根據接口文檔覆寫那幾個字段的值。

最常見的可能就是 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

另外的引用數據類型有:ObjectArray。深拷貝與淺拷貝的出現,就與這兩個數據類型有關。

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%的人都不知道),查看更多的細節。

總結

所謂深拷貝與淺拷貝,指的是 ObjectArray 這樣的引用數據類型。

淺拷貝,只拷貝第一層的原始類型值,和第一層的引用類型地址。

深拷貝,拷貝所有的屬性值,以及屬性地址指向的值的內存空間。通過遞歸調用,或者 JSON 來做深拷貝,都會有一些問題。而 cloneForce 方法倒是目前看來最完美的解決方案了。

在日常的工作中,我們要特別注意,對象的 Object.assign(),數組的 Array.prototype.slice()Array.prototype.concat(),還有 ES6 的 擴展運算符,都屬於淺拷貝。當需要做數據組裝時,一定要用深拷貝,以免影響界面展示效果。

崗位內推

莉莉絲遊戲招 中高級前端工程師 啦!!!

你玩過《小冰冰傳奇([刀塔傳奇])》麼?你玩過《劍與家園》麼?

你想和 薛兆豐老師 成爲同事麼?有興趣的同學,可以 關注下面的公衆 號加我微信 詳聊哈~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章