寫個JS深複製,面試備用

hello_world_1024發表於2023-02-09

深複製淺複製和賦值的原理及實現剖析

在工作中我們經常會用到深複製與淺複製,但是你有沒有去分析什麼場景下使用它,為什麼需要使用呢,深淺複製有何異同呢,什麼是深複製呢,如何實現呢,你會有這些問題嗎,今天就為大家總結一下吧。

棧記憶體與堆記憶體
區別
  • 淺複製---複製的是一個物件的指標,而不是複製物件本身,複製出來的物件共用一個指標,其中一個改變了值,其他的也會同時改變。
  • 深複製---複製出來一個新的物件,開闢一塊新的空間,複製前後的物件相互獨立,互相不會改變,擁有不同的指標。

簡單的總結下,假設有個A,我們複製了一個為B,就是修改A或者B的時候看看另一個會不會也變化,如果改變A的值B也變了那麼就是淺複製,如果改變A之後B的值沒有發生變化就是深複製,當然這是基礎理解,下面我們一起來分析下吧。

賦值
/** demo1基本資料型別 */
let a = 1;
let b = a;
b = 10;
console.log(a,b)//  1    10
/** demo2引用資料型別 */
let a = {
    name: '小九',
    age: 23,
    favorite: ['吃飯','睡覺','打豆豆']
}
let b = a;
a.name = '小七'
a.age = 18
a.favorite = ['上班','下班','加班']
console.log(a,b)
/** { name: '小七', age: 18, favorite: [ '上班', '下班', '加班' ] } { name: '小七', age: 18, favorite: [ '上班', '下班', '加班' ] }*/

透過看上面的例子可以看出透過賦值去拿到新的值,賦值對於基本資料來說就是在棧中新開了一個變數,相當於是兩個獨立的棧記憶體,所以相互不會影響,但是對於引用資料型別,他只是複製了一份a在棧記憶體的指標,所以兩個指標指向了同一個堆記憶體的空間,透過任何一個指標改變值都會影響其他的,透過這樣的賦值可以產生多個指標,但是堆記憶體的空間始終只有一個,這就是賦值產生的問題,我們在開發中當然不希望改變B而影響了A,所以這個時候就需要用到淺複製和深複製了。

  • 針對基本資料型別,隨便賦值都不會相互影響
  • 針對引用資料型別,賦值就會出現我們不想看到的,改動一方雙方都變化。
淺複製
Object.assign()
/** Object.assign */
let A = {
    name: '小九',
    age: 23,
    sex: '男'
}
let B = Object.assign( {}, A);
B.name = '小七'
B.sex = '女'
B.age = 18
console.log(A,B)
/** { name: '小九', age: 23, sex: '男' } { name: '小七', age: 18, sex: '女' } */

首先實現淺複製的第一個方法是透過 Object.assign()這個 方法,Object.assign() 方法用於將所有可列舉屬性的值從一個或多個源物件複製到目標物件。它將返回目標物件。

先不管這個方法具體幹嘛,我們先來看看結果,我們發現複製一個A之後的B改變了nameagesex之後A的值並沒有發生變化,在這裡,你可能會想,這不是就成功了麼,AB宜家互相不影響了,可是和我們上面講的淺複製會AB互相不變化就是深複製產生了矛盾,那麼是為什麼呢,其實上面已經說到了,這個demo裡面用到的全是基本資料型別,所以複製和賦值一樣,針對基本資料型別,都是在棧重新開闢一個變數,所以相互不會影響,那我們看看引用資料型別,

let A = {
    name: '小九',
    age: 23,
    sex: '男',
    favorite: {
        item_a:['打遊戲','上網'],
        item_b:['讀書','網課']
    }
}
let B = Object.assign( {}, A);

B.name = '小七'
B.sex = '女'
B.age = 18
B.favorite.item_a =['打籃球'] 
B.favorite.item_b =['寫筆記'] 
console.log(A)
console.log(B)
/** 列印結果對比 */
{ name: '小九',
  age: 23,
  sex: '男',
  favorite: { item_a: [ '打籃球' ], item_b: [ '寫筆記' ] } }
----------------------------------------------------------------
{ name: '小七',
  age: 18,
  sex: '女',
  favorite: { item_a: [ '打籃球' ], item_b: [ '寫筆記' ] } }

透過對比發現我們同樣複製了A之後發現改變B的name age sex都不會影響,但是改變facorite的時候卻影響了A,那麼問題來了,這我們透過淺複製發現依然無法滿足我們的需求,改變B同樣影響了A,回到這個方法,Object.assign()這個方法是可以把任意的多個源物件的可列舉屬性複製給目標物件,然後返回目標物件,它進行的是物件的淺複製,複製的是物件的引用,而不是物件本身,所以針對於這種有兩層的資料結構就出出現只複製了第一層,第二層以下的物件依然複製不了,所以我們稱Object.assign()為淺複製,只有在物件只有一層結構的時候才時候使用,

  • 很多人說Object.assign是深複製,其實是錯誤的,
  • 淺複製是按位複製物件,它會建立一個新物件,這個物件有著原始物件屬性值的一份精確複製。如果屬性是基本型別,複製的就是基本型別的值;如果屬性是記憶體地址(引用型別),複製的就是記憶體地址 ,因此如果其中一個物件改變了這個地址,就會影響到另一個物件。即預設複製建構函式只是對物件進行淺複製複製(逐個成員依次複製),即只複製物件空間而不復制資源。
  • 該方法只複製源物件自身的屬性,不複製其繼承的屬性。
  • 該方法不會複製物件不可列舉的屬性
  • undefined和null無法轉成物件,他們不能作為Object.assign引數,但是可以作為源物件
  • 屬性為Symbol的值,可以被該方法複製。
  • 淺複製,複製了第一層的基本資料型別結構,但是深層的依然沒有複製到,也就是第一層基本型別資料已經不會影響了,但是引用卻不行,所以還不夠

參考 前端進階面試題詳細解答

Array.prototype.silce

看這個方法之前先給大家看看mdn對於這個方法的描述。

返回值

返回一個新的陣列,包含從 start 到 end (不包括該元素)的 arrayObject 中的元素。

說明

請注意,該方法並不會修改陣列,而是返回一個子陣列。如果想刪除陣列中的一段元素,應該使用方法 Array.splice()。

看完它的描述大家就應該差不多明白了吧,讓我們繼續用剛剛的例子來實現下

let A = [1,2,3,[4,5]]
let B = A.slice();
B[3] = 4
console.log(A)
console.log(B)
/** 對比 */
[ 1, 2, 3, [ 4, 5 ] ]
[ 1, 2, 3, 4 ]

可以發現互相不會影響,也就實現了淺複製,同理針對於複雜的多層資料結構和之前一樣也會互相影響,所以個人理解,這個淺字也是由此而來吧,所以上面的說法也不是很準備,不一定AB互相不影響就一定是深複製了,還得結合資料結構層級來看。

Array.from()

先來看一句mdn的描述

Array.from() 方法從一個類似陣列或可迭代物件建立一個新的,淺複製的陣列例項。

Array.form() 用於將兩類物件轉成真正的陣列,一種是like-array(類陣列),和可遍歷的(iterable)物件,我們可以利用這個方法來進行一個淺複製。

let A = [1,2,3,[4,5]] ;
let B = Array.from(A) ;
B[3] = 6
console.log(A)
console.log(B)
/** 對比結果 */
[ 1, 2, 3, [ 4, 5 ] ]
[ 1, 2, 3, 6 ]

可以發現,也是一樣的效果,可以實現。

Array.prototype.concat
let A = [1,2,3,[4,5]] ;
let B = [].concat(A) ;
B[3] = 6
console.log(A)
console.log(B)
/** 對比結果 */
[ 1, 2, 3, [ 4, 5 ] ]
[ 1, 2, 3, 6 ]

陣列的方法原理大同小異,適當瞭解就行,可以自行操作試試看。

ES6 -> []

ES6的擴充套件運運算元也可以輕鬆做到,也非常方便來看看吧

let A = [1,2,3,[4,5]]
let B =[...A]
B[3] = 6
console.log(A)
console.log(B)
/** 對比結果 */
[ 1, 2, 3, [ 4, 5 ] ]
[ 1, 2, 3, 6 ]

擴充套件運運算元是es6新增的特性,作用很強大也非常方便,也是我日常愛用的一種方式,物件,陣列都可以操作,除了輕鬆實現淺複製,合併物件也非常的輕鬆,可以多多使用。

for in

先寫個簡單版本,因為這個也可以實現深複製,所以直接動手吧,

let A = [1,2,3,[4,5]]
let B = []
for (var i in A){
    B[i] = A[i]
}

B[3] = 9
console.log(A,B)
/** 對比結果 */
[ 1, 2, 3, [ 4, 5 ] ]
[ 1, 2, 3, 9 ]

發現同樣可以實現,原理也很簡單,自行分析下。

淺複製的實現有很多種方法,不單單是我這裡寫出的六種,當然,實際開發中,我們更注重的是深複製,所以我們來看看如何實現一個深複製吧。

深複製
JSON.parse(JSon.stringify())
/** 乞丐版本  JSON.parse(JSON.stringify()) */
let A = {
    a: 1,
    b: 2,
    c: [4,5,6]
}
let B = JSON.parse(JSON.stringify(A))
B.a = 2 
B.b = 3
B.c = 4
console.log(A == B)
console.log(A,B)
/** 對比結果  * { a: 1, b: 2, c: [ 4, 5, 6 ] }  * { a: 2, b: 3, c: 4 } */

可以發現,使用這個方法可以做到複製之後的AB互相不受影響,成為單獨一個新值,我們來分析下,這個方法裡面我們用到了兩個東西,分別是JSON.stringify()JSON.parse()這兩個方法,首先透過stringify將json序列化(json字串),然後在透過parse實現反序列(還原)js物件,序列化的作用是儲存和傳輸,在這個過程中就會開啟新的記憶體空間就會產生和源物件不一樣的空間從而實現深複製,實際開發中這個用法已經可以解決很多場景了,但是依然有很多弊端。

  • 如果obj裡面有時間物件,則JSON.stringify後再JSON.parse的結果,時間將只是字串的形式。而不是時間物件;
  • 如果obj裡有RegExp、Error物件,則序列化的結果將只得到空物件;
  • 如果obj裡有函式,undefined,則序列化的結果會把函式或 undefined丟失;
  • 如果obj裡有NaN、Infinity和-Infinity,則序列化的結果會變成null
  • JSON.stringify()只能序列化物件的可列舉的自有屬性,例如 如果obj中的物件是有建構函式生成的, 使用這個方法後會丟棄物件的constructor。
  • 該方法不能複製function型別

綜上所看,這個方法也有不少的問題,當然對於一個合格的程式設計師來說,這個版本也過於low,我們當然也希望實現的更加全面一點。

基礎版本(淺複製)
/** 基礎版本 for  in */

let A = {
    a: [1, 2, 3],
    b: { a: 1,b: 2},
    c: 99
}

function deepClone(target) {
    let return_result = {}
    for(let key in target) {
        return_result[key] = target[key]
    }
    return return_result
}

let B = deepClone(A)
B.a= 99
B.b = 88
console.log(A,'----------',B)
/** 對比結果  *  { a: [ 1, 2, 3 ], b: { a: 1, b: 2 }, c: 99 } *  { a: 99, b: 88, c: 99 } */

可以看到,透過for in可以實現一個基礎的淺複製,和Object.assign()一樣,只能複製第一層,但是我們初步已經成功了,

接下來我們需要考慮的是需要考慮陣列了吧,上面只能是物件,也很簡單,我們只需要加個判斷就行,接下來改造一下:

相容陣列(淺複製)
/** 基礎版本 for  in + 相容陣列 */
let A = [1,2,3,[4,5]]
function deepClone(target) {
    if (typeof target == 'object'){ //先判斷是不是引用資料型別
        let return_result =  Array.isArray(target) ? [] : {}
        for(let key in target) {
            return_result[key] = target[key]
        }
        return return_result
    }else{
        return target;
    }
}
let B = deepClone(A)
B[3]= 99
B[2] = 88
console.log(A,'----------',B)
/** 對比結果 *  [ 1, 2, 3, [ 4, 5 ] ] *  [ 1, 2, 88, 99 ] */

可以看到,現在已經可以相容陣列了,但是依然不夠,我們依然只能複製第一層,所以接下來需要對深層側的物件進行遞迴複製了,繼續剛剛的方法改進吧:

基礎版本+相容陣列+遞迴呼叫(深複製)
/** 相容陣列 + 遞迴呼叫 */
function deepClone(target) {
    let result;
    if (typeof target === 'object') {
        if (Array.isArray(target)) {
            result = []
            for (let i in target) {
                result.push(deepClone(target[i]))
            }
        } else {
            result = {}
            for (let key in target) {
                result[key] = target[key]
            }
        }
    } else {
        result = target;
    }
    return result;
}
let A = [1, 2, 3, { a: 1, b: 2 }]
let B = deepClone(A)
B[3].a = 99
console.log(A)
console.log(B)
/** 對比結果 *  [ 1, 2, 3, [ 4, 5 ] ] *  [ 1, 2, 3, { a: 99, b: 2 } ] */

我們先判斷其型別,再對物件和陣列分別遞迴呼叫,如果是基本資料型別就直接賦值,至此,我們已經可以完成一個基礎的深複製,但是還遠遠不夠,因為我們這裡只對陣列做了型別判斷,其他預設都是object,但是實際情況還會有很多型別,例如,RegExp,Date,Null,Undefined,function等等很多的型別,所以接下來我們將其完善,加上所以判斷,由於型別比較多,我們可以把物件的判斷單獨抽離出來,接下來一起完善它吧:在這之前我們還需要考慮的一個點就是 關於js的迴圈引用問題當目前的這個方法去複製一個帶有迴圈引用關係的物件時是有問題的,來看看:

 /** 基礎版本 for  in + 相容陣列 + 遞迴呼叫 + 迴圈引用問題 */
function deepClone(target) {
    let result;
    if (typeof target === 'object') {
        if (Array.isArray(target)) {
            result = []
            for (let i in target) {
                result.push(deepClone(target[i]))
            }
        } else {
            result = {}
            for (let key in target) {
                result[key] = target[key]
            }
        }
    } else {
        result = target;
    }
    return result;
}
let A = [1, 2, 3, { a: 1, b: 2 }]
A[4] = A
let B = deepClone(A)
console.log(A,B)
/**  RangeError: Maximum call stack size exceeded */

會出現一個超出了最大呼叫堆疊大小的錯誤,這也是深複製中的一個坑,在這裡我們可以透過js的一種weakmap的型別來解決這個問題,透過閱讀mdn的檔案可以瞭解到:

原生的 WeakMap 持有的是每個鍵物件的“弱引用”,這意味著在沒有其他引用存在時垃圾回收能正確進行。原生 WeakMap 的結構是特殊且有效的,其用於對映的 key 只有在其沒有被回收時才是有效的。

正由於這樣的弱引用,WeakMap 的 key 是不可列舉的 (沒有方法能給出所有的 key)。如果key 是可列舉的話,其列表將會受垃圾回收機制的影響,從而得到不確定的結果。因此,如果你想要這種型別物件的 key 值的列表,你應該使用 Map

基本上,如果你要往物件上新增資料,又不想干擾垃圾回收機制,就可以使用 WeakMap。

解決:使用一個WeakMap結構儲存已經被複製的物件,每一次進行複製的時候就先向WeakMap查詢該物件是否已經被複製,如果已經被複製則取出該物件並返回。

/** 基礎版本 for  in + 相容陣列 + 遞迴呼叫 + 解決迴圈引用問題 */
function deepClone(val, hash = new WeakMap()) {
    if (hash.has(val)) return hash.get()
    let cloneVal;
    if (isObj(val)) { //判斷是不是引用型別
        if (Array.isArray(val)) { // 判斷是不是陣列 
            cloneVal = []
            hash.set(val, cloneVal)
            for (let i in val) {
                cloneVal.push(deepClone(val[i]))
            }
        }
        else {
            cloneVal = {}
            hash.set(val, cloneVal)
            for (let key in val) {
                cloneVal[key] = val[key]
            }
        }
    } else {
        cloneVal = val;
    }
    return cloneVal;
}
/** 是否是引用型別 */
function isObj(val) {
    return (typeof val == 'object' || typeof val == 'function') && val != null
}
var a = {}
a.a = a
var b = deepClone(a)
console.log(b)

這樣就可以初步解決迴圈呼叫問題,接下來要考慮的是如何為更多型別做不同處理,我們借用之前的一個檢測js型別的文章,透過js檢測資料型別 的這個方法來為多種型別分別處理。

/** 完整版本 */
function deepClonea(val, map = new WeakMap()) {
    let type = getType(val); //當是引用型別的時候先拿到其確定的型別
    if (isObj(val)) {
        switch (type) {
            case 'date':                   //日期型別重新new一次傳入之前的值,date例項化本身結果不變
                return new Date(val);
                break;
            case 'regexp':                 //正則型別直接new一個新的正則傳入source和flags即可
                return new RegExp(val.source, val.flags);
                break;
            case 'function':               //如果是函式型別就直接透過function包裹返回一個新的函式,並且改變this指向
                return new RegExp(val.source, val.flags);
                break;
            default:
                let cloneVal = Array.isArray(val) ? [] : {};
                if (map.has(val)) return map.get(val)
                map.set(val, cloneVal)
                for (let key in val) {
                    if (val.hasOwnProperty(key)) { //判斷是不是自身的key
                        cloneVal[key] = deepClone(val[key]), map;//每一項就算是基本型別也需要走deepclone方法進行複製
                    }
                }
                return cloneVal;
        }
    } else {
        return val;     //當是基本資料型別的時候直接返回
    }
}
function isObj(val) {   //判斷是否是引用型別
    return (typeof val == 'object' || typeof val == 'function') && val != null
}
function getType(data) { //獲取型別
    var s = Object.prototype.toString.call(data);
    return s.match(/\[object (.*?)\]/)[1].toLowerCase();
};
// /** 測試 */
var a = {}
a.a = a
var b = deepClonea(a)
console.log(b)
最終完整版

上面差不多已經完成了一個可以應對大部分場景的深複製了,下面讓我們用class類的方法來改造一下,方便後期對其進行擴充套件更改。

/**  改用class類寫 */
class DeepClone {
    constructor(){
        cloneVal: null;
    }
    clone(val, map = new WeakMap()) {
        let type = this.getType(val); //當是引用型別的時候先拿到其確定的型別
        if (this.isObj(val)) {
            switch (type) {
                case 'date':             //日期型別重新new一次傳入之前的值,date例項化本身結果不變
                    return new Date(val);
                    break;
                case 'regexp':           //正則型別直接new一個新的正則傳入source和flags即可
                    return new RegExp(val.source, val.flags);
                    break;
                case 'function':        //如果是函式型別就直接透過function包裹返回一個新的函式,並且改變this指向
                    return new RegExp(val.source, val.flags);
                    break;
                default:
                    this.cloneVal = Array.isArray(val) ? [] : {};
                    if (map.has(val)) return map.get(val)
                    map.set(val, this.cloneVal)
                    for (let key in val) {
                        if (val.hasOwnProperty(key)) { //判斷是不是自身的key
                            this.cloneVal[key] = new DeepClone().clone(val[key], map);
                        }
                    }
                    return this.cloneVal;
            }
        } else {
            return val;     //當是基本資料型別的時候直接返回
        }
    }
    /** 判斷是否是引用型別 */
    isObj(val) {   
        return (typeof val == 'object' || typeof val == 'function') && val != null
    }
    /** 獲取型別 */
    getType(data) { 
        var s = Object.prototype.toString.call(data);
        return s.match(/\[object (.*?)\]/)[1].toLowerCase();
    };
}
 /** 測試 */
var a ={
    a:1,
    b:true,
    c:undefined,
    d:null,
    e:function(a,b){
        return a + b
    },
    f: /\W+/gi,
    time: new Date(),

}
const deepClone = new DeepClone()
let b = deepClone.clone(a)
console.log(b)

好了上面就是本次總結的深複製,當然還不夠完善,還有很多種場景,後期可能會補充,但是這個目前已經可以應對你很大一部分的場景了。

相關文章