這一次,完全理解JavaScript深拷貝

導語

這一次,經過本文完全理解JavaScript深拷貝!javascript

閱讀本文前能夠先思考三個問題:前端

  • JS世界裏,數據是如何存儲的?
  • 深拷貝和淺拷貝的區別是什麼?
  • 如何寫出一個真正合格的深拷貝?

本文會一步步解答這三個問題java

數據是如何存儲的

先看一個問題,下面這段代碼的輸出結果是什麼:數組

function foo(){
    let a = {name:"dellyoung"}
    let b = a
    a.name = "dell" 
    console.log(a)
    console.log(b)
}
foo()

JS的內存空間

要解答這個問題就要先了解,JS中數據是如何存儲的。瀏覽器

要理解JS中數據是如何存儲的,就要先明白其內存空間的種類。下圖就是JS的內存空間模型。性能優化

從模型中咱們能夠看出JS內存空間分爲:代碼空間、棧空間、堆空間。微信

代碼空間:代碼空間主要是存儲可執行代碼的。函數

棧空間:棧(call stack)指的就是調用棧,用來存儲執行上下文的。(每一個執行上下文包括了:變量環境、詞法環境)post

堆空間:堆(Heap)空間,通常用來存儲對象的。性能

JS的數據類型

如今咱們已經瞭解JS內存空間了。接下來咱們瞭解一下JS中的數據類型 :


JS中一共有8中數據類型:Number、BigInt、String、Boolean、Symble、Null、Undefined、Object。

前7種稱爲原始類型,最後一種Object稱爲引用類型,之因此把它們區分紅兩種類型,是由於它們在內存中存放的位置不一樣

原始類型存放在棧空間中,具體點到執行上下文來講就是:用var定義的變量會存放在變量環境中,而用let、const定義的變量會存放在詞法環境中。而且對原始類型來講存放的是值,而引用類型存放的是指針,指針指向堆內存中存放的真正內容。

好啦,如今咱們就明白JS中數據是如何存儲的了:原始類型存放在棧空間中,引用類型存放在堆空間中

深拷貝和淺拷貝的區別

咱們先來明確一下深拷貝和淺拷貝的定義:

淺拷貝

建立一個新對象,這個對象有着原始對象屬性值的一份精確拷貝。若是屬性是基本類型,拷貝的就是基本類型的值,若是屬性是引用類型,拷貝的就是內存地址 ,因此修改新拷貝的對象會影響原對象。

深拷貝

將一個對象從內存中完整的拷貝一份出來,從堆內存中開闢一個新的區域存放新對象,且修改新對象不會影響原對象

接下來咱們就開始逐步實現一個深拷貝

自帶版

通常狀況下若是不使用loadsh的深拷貝函數,咱們可能會這樣寫一個深拷貝函數

JSON.parse(JSON.stringify());

可是這個方法侷限性比較大:

  • 會忽略 undefined
  • 會忽略 symbol
  • 不能序列化函數
  • 不能解決循環引用的對象

顯然這絕對不是咱們想要的一個合格的深拷貝函數

基本版

手動實現的話咱們很容易寫出以下函數

const clone = (target) => {
    let cloneTarget = {};
    Object.keys(target).forEach((item) => {
        cloneTarget[item] = target[item]
    });
    return cloneTarget
}

先看下這個函數作了什麼:建立一個新對象,遍歷原對象,而且將須要拷貝的對象依次添加到新對象上,返回新對象。

既然是深拷貝的話,對於引用了類型咱們不知道對象屬性的深度,咱們能夠經過遞歸來解決這個問題,接下來咱們修改一下上面的代碼:

  • 判斷是不是引用類型,若是是原始類型的話直接返回就能夠了。
  • 若是是原始類型,那麼咱們須要建立一個對象,遍歷原對象,將須要拷貝的對象執行深拷貝後再依次添加到新對象上。
  • 另外若是對象有更深層次的對象,咱們就能夠經過遞歸來解決。

這樣咱們就實現了一個最基本的深拷貝函數:

// 是不是引用類型
const isObject = (target) => {
    return typeof target === 'object';
};

const clone = (target) => {
    // 處理原始類型直接返回(Number BigInt String Boolean Symbol Undefined Null)
    if (!isObject(target)) {
        return target;
    }
    let cloneTarget = {};
    Object.keys(target).forEach((item) => {
        cloneTarget[item] = clone(target[item])
    });
    return cloneTarget
}

顯然這個深拷貝函數還有不少缺陷,好比:沒有考慮包含數組的狀況

考慮數組

上面代碼中,咱們只考慮了是object的狀況,並無考慮存在數組的狀況。改爲兼容數組也很是簡單:

  • 判斷傳入的對象是數組仍是對象,咱們分別對它們進行處理
  • 判斷類型的方法有不少好比 type of、instanceof,可是這兩種方法缺陷都比較多,這裏我使用的是Object.prototype.toString.call()的方法,它能夠精準的判斷各類類型
  • 當判斷出是數組時,那麼咱們須要建立一個新數組,遍歷原數組,將須要數組中的每一個值執行深拷貝後再依次添加到新的數組上,返回新數組。

代碼以下:

const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 是不是引用類型
const isObject = (target) => {
    return typeof target === 'object';
};

// 獲取標準類型
const getType = (target) => {
    return Object.prototype.toString.call(target);
};

const clone = (target) => {
    // 處理原始類型直接返回(Number BigInt String Boolean Symbol Undefined Null)
    if (!isObject(target)) {
        return target;
    }
    const type = getType(target);
    let cloneTarget;
    switch (type) {
        case typeArray:
            // 數組
            cloneTarget = [];
            target.forEach((item, index) => {
                cloneTarget[index] = clone(item)
            });
            return cloneTarget;
        case typeObject:
            // 對象
            cloneTarget = {};
            Object.keys(target).forEach((item) => {
                cloneTarget[item] = clone(target[item])
            });
            return cloneTarget;
        default:
            return target;
    }
    return cloneTarget
}

OK,這樣咱們的深拷貝函數就兼容了最經常使用的數組和對象的狀況。

循環引用

可是若是出現下面這種狀況

const target = {
    field1: 1,
    field2: {
        child: 'dellyoung'
    },
    field3: [2, 4, 8]
};
target.target = target;

咱們來拷貝這個target對象的話,就會發現會出現報錯:循環引用致使了棧溢出。

解決循環引用問題,咱們須要額外有一個空間,來專門存儲已經被拷貝過的對象。當須要拷貝對象時,咱們先從這個空間裏找是否已經拷貝過,若是拷貝過了就直接返回這個對象,沒有拷貝過就進行接下來的拷貝。須要注意的是隻有可遍歷的引用類型纔會出現循環引用的狀況。

很顯然這種狀況下咱們使用Map,以key-value來存儲就很是的合適:

  • 用has方法檢查Map中有無克隆過的對象
  • 有的話就獲取Map存入的值後直接返回
  • 沒有的話以當前對象爲key,以拷貝獲得的值爲value存儲到Map中
  • 繼續進行克隆
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 是不是引用類型
const isObject = (target) => {
    return typeof target === 'object';
};

// 獲取標準類型
const getType = (target) => {
    return Object.prototype.toString.call(target);
};

const clone = (target, map = new Map()) => {
    // 處理原始類型直接返回(Number BigInt String Boolean Symbol Undefined Null)
    if (!isObject(target)) {
        return target;
    }
    const type = getType(target);
    // 用於返回
    let cloneTarget;

    // 處理循環引用
    if (map.get(target)) {
        // 已經放入過map的直接返回
        return map.get(target)
    }
    
    switch (type) {
        case typeArray:
            // 數組
            cloneTarget = [];
            map.set(target, cloneTarget);
            target.forEach((item, index) => {
                cloneTarget[index] = clone(item, map)
            });
            return cloneTarget;
        case typeObject:
            // 對象
            cloneTarget = {};
            map.set(target, cloneTarget);
            Object.keys(target).forEach((item) => {
                cloneTarget[item] = clone(target[item], map)
            });
            return cloneTarget;
        default:
            return target;
    }
    
    return cloneTarget
}

性能優化

循環性能優化:

其實咱們寫代碼的時候已經考慮到了性能優化了,好比:循環沒有使用 for in 循環而是使用的forEach循環,使用forEach或while循環會比for in循環快上很多的

WeakMap性能優化:

咱們可使用WeakMap來替代Map,提升性能。

const clone = (target, map = new WeakMap()) => {
    // ...
};

爲何要這樣作呢?,先來看看WeakMap的做用:

WeakMap 對象是一組鍵/值對的集合,其中的鍵是弱引用的。其鍵必須是對象,而值能夠是任意的。

那什麼是弱引用呢?

在計算機程序設計中,弱引用與強引用相對,是指不能確保其引用的對象不會被垃圾回收器回收的引用。 一個對象若只被弱引用所引用,則被認爲是不可訪問(或弱可訪問)的,並所以可能在任什麼時候刻被回收。

咱們默認建立一個對象:const obj = {},就默認建立了一個強引用的對象,咱們只有手動將obj = null,它纔會被垃圾回收機制進行回收,若是是弱引用對象,垃圾回收機制會自動幫咱們回收。

咱們來舉個例子:

let obj = { name : 'dellyoung'}
const target = new Map();
target.set(obj,'dell');
obj = null;

雖然咱們手動將obj賦值爲null,進行釋放,可是target依然對obj存在強引用關係,因此這部份內存依然沒法被釋放。

基於此咱們再來看WeakMap:

let obj = { name : 'dellyoung'}
const target = new WeakMap();
target.set(obj,'dell');
obj = null;

若是是WeakMap的話,target和obj存在的就是弱引用關係,當下一次垃圾回收機制執行的時候,這塊內存就會被釋放掉了。

若是咱們要拷貝的對象很是龐大時,使用Map會對內存形成很是大的額外消耗,並且咱們須要手動delete Map的key才能釋放這塊內存,而WeakMap會幫咱們解決這個問題。

更多的數據類型

到如今其實咱們已經解決了Number BigInt String Boolean Symbol Undefined Null Object Array,這9種狀況了,可是引用類型中咱們其實只考慮了Object和Array兩種數據類型,可是實際上全部的引用類型遠遠不止這兩個。

判斷引用類型

判斷是不是引用類型還須要考慮null和function兩種類型。

// 是不是引用類型
const isObject = (target) => {
    if (target === null) {
        return false;
    } else {
        const type = typeof target;
        return type === 'object' || type === 'function';
    }
};

獲取數據類型

獲取類型,咱們可使用toString來獲取準確的引用類型:

每個引用類型都有toString方法,默認狀況下,toString()方法被每一個Object對象繼承。若是此方法在自定義對象中未被覆蓋,toString() 返回 "[object type]",其中type是對象的類型。

可是因爲大部分引用類型好比Array、Date、RegExp等都重寫了toString方法,因此咱們能夠直接調用Object原型上未被覆蓋的toString()方法,使用call來改變this指向來達到咱們想要的效果

// 獲取標準類型
const getType = (target) => {
    return Object.prototype.toString.call(target);
};

類型很是多,本文先考慮大部分經常使用的類型,其餘類型就等小夥伴來探索啦

// 可遍歷類型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 非原始類型的 不可遍歷類型  Date RegExp Function
const typeDate = '[object Date]';
const typeRegExp = '[object RegExp]';
const typeFunction = '[object Function]';

可繼續遍歷類型

上面咱們已經考慮的Object、Array都屬於能夠繼續遍歷的類型,由於它們內存都還能夠存儲其餘數據類型的數據,另外還有Map,Set等都是能夠繼續遍歷的類型,這裏咱們只考慮這四種經常使用的,其餘類型等你來探索咯。

下面,咱們改寫clone函數,使其對可繼續遍歷的數據類型進行處理:

// 可遍歷類型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';

// 是不是引用類型
const isObject = (target) => {
    if (target === null) {
        return false;
    } else {
        const type = typeof target;
        return type === 'object' || type === 'function';
    }
};

// 獲取標準類型
const getType = (target) => {
    return Object.prototype.toString.call(target);
};

/*
* 一、處理原始類型 Number String Boolean Symbol Null Undefined
* 二、處理循環引用狀況 WeakMap
* 三、處理可遍歷類型 Set Map Array Object
* */
const clone = (target, map = new WeakMap()) => {
    // 處理原始類型直接返回(Number BigInt String Boolean Symbol Undefined Null)
    if (!isObject(target)) {
        return target;
    }

    // 用於返回
    let cloneTarget;

    // 處理循環引用
    if (map.get(target)) {
        // 已經放入過map的直接返回
        return map.get(target)
    }

    // 處理可遍歷類型
    switch (type) {
        case typeSet:
            // Set
            cloneTarget = new Set();
            map.set(target, cloneTarget);
            target.forEach((item) => {
                cloneTarget.add(clone(item, map))
            });
            return cloneTarget;
        case typeMap:
            // Map
            cloneTarget = new Map();
            map.set(target, cloneTarget);
            target.forEach((value, key) => {
                cloneTarget.set(key, clone(value, map))
            });
            return cloneTarget;
        case typeArray:
            // 數組
            cloneTarget = [];
            map.set(target, cloneTarget);
            target.forEach((item, index) => {
                cloneTarget[index] = clone(item, map)
            });
            return cloneTarget;
        case typeObject:
            // 對象
            cloneTarget = {};
            map.set(target, cloneTarget);
            Object.keys(target).forEach((item) => {
                cloneTarget[item] = clone(target[item], map)
            });
            return cloneTarget;
        default:
            return target;
    }
};

這樣咱們就完成了對Set和Map的兼容

考慮對象鍵名爲Symbol類型

對於對象鍵名爲Symbol類型時,用Object.keys(target)是獲取不到的,這時候就須要用到Object.getOwnPropertySymbols(target)方法。

case typeObject:
    // 對象
    cloneTarget = {};
    map.set(target, cloneTarget);
    [...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach((item) => {
        cloneTarget[item] = clone(target[item], map)
    });
    return cloneTarget;

這樣就實現了對於對象鍵名爲Symbol類型的兼容。

不可繼續遍歷類型

不可遍歷的類型有Number BigInt String Boolean Symbol Undefined Null Date RegExp Function 等等,可是前7中已經被isObject攔截了,因而咱們先對後面Date RegExp Function進行處理,其實後面不止有這幾種,其餘類型等你來探索咯。

其中對函數的處理要簡單說下,我認爲克隆函數是沒有必要的其實,兩個對象使用一個在內存中處於同一個地址的函數也是沒有任何問題的,以下是lodash對函數的處理:

const isFunc = typeof value == 'function'
 if (isFunc || !cloneableTags[tag]) {
        return object ? value : {}
 }

顯然若是發現是函數的話就會直接返回了,沒有作特殊的處理,這裏咱們暫時也這樣處理,之後有時間我會把拷貝函數的部分給補上。

// 可遍歷類型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 非原始類型的 不可遍歷類型  Date RegExp Function
const typeDate = '[object Date]';
const typeRegExp = '[object RegExp]';
const typeFunction = '[object Function]';

// 非原始類型的 不可遍歷類型的 集合(原始類型已經被過濾了不用再考慮了)
const simpleType = [typeDate, typeRegExp, typeFunction];

// 是不是引用類型
const isObject = (target) => {
    if (target === null) {
        return false;
    } else {
        const type = typeof target;
        return type === 'object' || type === 'function';
    }
};

// 獲取標準類型
const getType = (target) => {
    return Object.prototype.toString.call(target);
};

/*
* 一、處理原始類型 Number String Boolean Symbol Null Undefined
* 二、處理不可遍歷類型 Date RegExp Function
* 三、處理循環引用狀況 WeakMap
* 四、處理可遍歷類型 Set Map Array Object
* */
const clone = (target, map = new WeakMap()) => {
    // 處理原始類型直接返回(Number BigInt String Boolean Symbol Undefined Null)
    if (!isObject(target)) {
        return target;
    }

    // 處理不可遍歷類型
    const type = getType(target);
    if (simpleType.includes(type)) {
        switch (type) {
            case typeDate:
                // 日期
                return new Date(target);
            case typeRegExp:
                // 正則
                const reg = /\w*$/;
                const result = new RegExp(target.source, reg.exec(target)[0]);
                result.lastIndex = target.lastIndex; // lastIndex 表示每次匹配時的開始位置
                return result;
            case typeFunction:
                // 函數
                return target;
            default:
                return target;
        }
    }

    // 用於返回
    let cloneTarget;

    // 處理循環引用
    if (map.get(target)) {
        // 已經放入過map的直接返回
        return map.get(target)
    }

    // 處理可遍歷類型
    switch (type) {
        case typeSet:
            // Set
            cloneTarget = new Set();
            map.set(target, cloneTarget);
            target.forEach((item) => {
                cloneTarget.add(clone(item, map))
            });
            return cloneTarget;
        case typeMap:
            // Map
            cloneTarget = new Map();
            map.set(target, cloneTarget);
            target.forEach((value, key) => {
                cloneTarget.set(key, clone(value, map))
            });
            return cloneTarget;
        case typeArray:
            // 數組
            cloneTarget = [];
            map.set(target, cloneTarget);
            target.forEach((item, index) => {
                cloneTarget[index] = clone(item, map)
            });
            return cloneTarget;
        case typeObject:
            // 對象
            cloneTarget = {};
            map.set(target, cloneTarget);
            [...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach((item) => {
                cloneTarget[item] = clone(target[item], map)
            });
            return cloneTarget;
        default:
            return target;
    }
};

至此這個深拷貝函數已經能處理大部分的類型了:Number String Boolean Symbol Null Undefined Date RegExp Function Set Map Array Object,而且也能優秀的處理循環引用狀況了

參考

總結

如今咱們應該能理清楚寫一個合格深拷貝的思路了:

  • 處理原始類型 如: Number String Boolean Symbol Null Undefined
  • 處理不可遍歷類型 如: Date RegExp Function
  • 處理循環引用狀況 使用: WeakMap
  • 處理可遍歷類型 如: Set Map Array Object

看完兩件事

  • 歡迎加我微信(iamyyymmm),拉你進技術羣,長期交流學習
  • 關注公衆號「呆鵝實驗室」,和呆鵝一塊兒學前端,提升技術認知

🌈點個贊支持我吧👉​

相關文章
相關標籤/搜索