淺談js深拷貝

前言

最近在整理js相關的文檔,回想起一個前端說難不難,說簡單又能考驗職業素養(基本功)紮實程度的問題:js之深淺拷貝的問題,相信前端的同窗都或少或多的瞭解及懂得其根由,在這裏我就很少贅述,我們先簡單的拋出幾個問題:javascript

  • 如何理解深淺拷貝?
  • 怎麼實現一個較爲完善的深拷貝?

咱們先來聊下如何理解深淺拷貝。前端

值和引用

咱們知道,許多變成語言,賦值和參數傳遞能夠經過值複製(value-copy)或者引用複製(reference-copy)來完成,這取決於語法的不一樣。vue

JavaScript 中引用指向的是值。若是一個值有不止一個引用,這些引用都指向的是同一值,他們相互之間沒有引用 / 指向關係。java

JavaScript 對值和引用的賦值 / 傳遞在語法上沒法區分,徹底取決於值得類型來決定。數組

來看一下下面的列子:緩存

var a = 3;
var b = a; // b是a值的一個副本
b++;
a = 3;
b = 4;

var c = [0, 1, 2];
var d = c; // d是[0, 1, 2]的一個引用
d.push(3);
c; // [1, 2, 3, 4]
d; // [1, 2, 3, 4]

簡單值(即標量基本類型)老是經過值複製的方式來賦值 / 傳遞, 包括 nullundefined字符串number布爾值和 ES6 中的 symbol 以及最新的 bigint 七種基本類型。數據結構

複合值(compound value)—— 對象(包括數組和封裝對象)和函數,老是經過引用複製的方式來賦值 / 傳遞。函數

上例中 3 是一個標量基本數據類型,因此變量 a 持有該值的一個副本,b 持有它的另外一個副本。b 更改時,a 值保持不變。工具

c 和 d則分別指向同一個符合值 [0, 1, 2] 的兩個不一樣引用。請注意,c 和 d 僅僅是指向值 [0, 1, 2], 並不是持有。因此它們更改的是同一值(調用 push 方法),隨後它們都指向了更改後的新值 [0, 1, 2, 3]。性能

因爲引用指向的是值自己而非變量,因此一個引用沒法更改另外一個引用的指向。

var a = [1, 2, 3];
var b = a;
a; // [1, 2, 3]
b; // [1, 2, 3]

b = [4, 5, 6];
a; // [1, 2, 3]
b; // [4, 5, 6]

b=[4, 5, 6] 並不影響 a 指向 [1, 2, 3],除非 b 指向的不是數組的引用,而是指向 a 的指針,這樣 b 的賦值就會影響到 a ,可是 JavaScript 中不存在這種狀況。

注意:JavaScript 中沒有指針的概念,引用的工做機制也不盡相同,在 JavaScript 中變量不可能成爲指向另外一個變量的引用。

<img :src="$withBase('/棧內存.jpg')" alt="foo">

咱們再看一個例子:

var m = [1, 2, 3];

function fn(n) {
    n.push(4);
    n; // [1, 2, 3, 4]
    
    n = [4, 5, 6];
    n.push(7);
    n; // [4, 5, 6, 7]
}
fn(m);
m; // 是[1, 2, 3, 4] 而不是 [4, 5, 6, 7]

在調用函數傳遞參數的時候,其實是將引用 m 的一個副本賦值給 n,因此在當調用 push(4) 操做的時候,由於都指向同一對象 [1, 2, 3, 4] ,可是當手動改變 n 的引用的時候,這時候並不影響 m,因此纔會出現最終的 m 是[1, 2, 3, 4] 而不是 [4, 5, 6, 7]。

若是咱們不想要函數外的變量受到牽連,能夠先建立一個複本,這樣就不會影響原始值。例如:

fn(n.slice())

不帶參數的slice() 方法會返回當前數組的一個淺複本,因爲傳遞給函數的是指向該副本的引用,因此內部操做n 就再也不影響 m 。

目前爲止,咱們大體理解了不一樣類型的數據在複製的時候可能會形成相互的影響,因此實現一個深拷貝就顯得頗有必要了。

接下來咱們將會逐步的展開介紹,最終實現一種較爲完善的深拷貝。

序列化

沒錯,這也是最容易想到的一種,僅僅經過序列化和反序列化實現。

JSON.parse(JSON.stringify());

這種寫法雖然能夠應對大部分的應用場景,可是它仍是有很大缺陷的,好比拷貝拷貝函數、循環引用結構、其餘類型的對象(Reg、Map)等狀況。

使用JSON.stringify注意:

  1. undefined、任意的函數以及 symbol 值,在序列化過程當中會被忽略
  2. 對包含循環引用的對象(對象之間相互引用,造成無限循環)執行此方法,會拋出錯誤
  3. 其餘類型的對象,包括 Map/Set/WeakMap/WeakSet,僅會序列化可枚舉的屬性

因此使用這種方法僅適用數據格式簡單的對象。

屬性複製(淺複製)

若是是淺拷貝的話,相似於 jqery 裏面的extend,很簡單僅僅作的是遍歷屬性進行賦值:

function clone(target) {
    let _target = {};
    for (const key in target) {
        _target[key] = target[key];
    }
    return _target;
};

若是是深拷貝的話,而且不知道嵌套對象的層級結構,咱們可使用遞歸來實現:

function deepClone(target) {
    if (typeof target === 'object') {
        let _target = {};
        for (const key in target) {
            _target[key] = deepClone(target[key]);
        }
        return _target;
    } else {
        return target;
    }
};

雖然這裏基本實現了一個深拷貝的 demo,可是咱們應該會想到缺乏點什麼。沒錯,就是Array ,其實考慮到Array 的狀況也很簡單,咱們稍微該一下。

數組和對象字面量

其實思路很簡單,就是在咱們每次遍歷建立新對象的時候對數組進行兼容就 ok 了:

function deepClone(target) {
    if (typeof target === 'object') {
        let _target = Array.isArray(target) ? [] : {};
        for (const key in target) {
            _target[key] = deepClone(target[key]);
        }
        return _target;
    } else {
        return target;
    }
};

目前爲止,咱們基本上實現一個深拷貝的例子。可是,就像咱們會考慮到數組的狀況,還有一種狀況不常見,但卻不能忽視的一個問題: 循環引用(circularReference)。

循環引用

咱們執行下面這樣一個測試用例:

const target = {
    refer: 'circularReference'
};
target.refer = target;

咱們在控制檯上輸出一下:

<img :src="$withBase('/circleReference.jpg')" alt="foo">

能夠看到一個無限展開的結構(即對象的屬性間接或直接的引用了自身的狀況)。

首先來分析一下循環引用結構:若是咱們不對存在循環引用的結構作處理的話,每次遞歸都會指向自身對象,這樣下去就會形成內存泄漏的問題。解決這個問題咱們就從根本點出發,針對於循環結構咱們能夠再每次循環的時候找一個chche 存儲當前對象,下次拷貝的時候就能夠去cache 中查找有無當前對象,有就返回,沒有就繼續遍歷拷貝,咱們先來實現一下這個:

function deepClone(target, cache = []) {
      if (target === null || typeof target !== 'object') {
       return target
    }
    
    let circleStructure = cache.filter(e => e.original === target)[0]
    if (circleStructure) {
        return circleStructure.copy
    }
    
    let copy = Array.isArray(target) ? [] : {}
    cache.push({
        original: target,
        copy
    })
    
    Object.keys(target).forEach(key => {
        copy[key] = deepClone(target[key], cache)
    })
    
    return copy
}

該方法缺陷:

  1. 只能克隆 {} 格式的對象,對於擁有有原型鏈的對象卻無能爲力
  2. 不能克隆其它類型的對象(可迭代的集合、RegExpSymbolDateTypedArray)等等

針對目前的缺陷咱們尋找解決方案:

  • 既然針對 {} 類型對象不能拷貝原型鏈,咱們能夠拷貝它的原型對象而且擴展其熟悉
  • 針對於可迭代的集合(Map、Set)由於Object.keys()沒法對其進行遍厲,那咱們可使用它們自身的構造器
  • 針對其它類型的對象咱們同要可使用它們各自的構造器進行拷貝

要區分對象類型,咱們首先要找到一個能夠嚴格判斷對象類型的方法。以前由於看vue源碼的時候看到一個嚴格判斷對象類型的方法,經過Object.toString方法能夠返回對象的具體類型:

function getPlainObjType(obj) {
    return Object.prototype.toString.call(obj)
}

相信不少小夥伴在閱讀其餘組件庫的時候該方法能夠隨處可見。其次咱們想想(先把function 排除在外)針對於集合類型,咱們可使用鍵-值對的map進行存儲,可是若是使用對象做爲映射的鍵,這個對象即使後來全部的引用被解除了,某一時刻(GC)開始回收其內存,那map自己仍然會保持其項目(值對象),除非手動的移除項目(clear)來支持 GC

這個時候WeakMap的做用便顯現出來,其實它們兩者外部的行爲特性基本同樣,區別就體如今了內存分配的工做方式。

WeakMap接受對象做爲鍵,而且這些對象是被弱持有的,也就是說若是鍵對象自己被垃圾回收的話,那麼WeakMap中的這個項目也會被自動移除,這也是爲何WeakMap在這方面會優於Map。咱們看個例子:

var wm = new WeakMap();

var x = {id: 1},
    y = {id: 2},
    z = {id: 3},
    w = {id: 4};

wm.set(x, y);

x = null;       // {id: 1} 可被垃圾回收
y = null;       // {id: 2} 可被垃圾回收, 實際上 x = null; weakMap裏面的項目也就被回收了

wm.set(z, w);

y = null;         // {id: 4} 並未被回收,由於鍵還軟關聯着 {id: 4} 這個對象

接下來完善一下上面的deepClone方法,lodash上實現了比較全面的深拷貝,咱們能夠借鑑一下lodash的思路,實現一個簡化版的:

  • 聲明克隆須要的幾種工具函數
  • cache 替換爲WeakMap
  • 若是判斷是基礎類型的數據,直接返回
  • 聲明deepInit
  • 若是是map或者是set使用它們自身的添加方法拷貝
  • 若是是數組或者是{}使用Object.keys遍歷拷貝屬性
  • 若是是包裝類型對象或者是Date、RegExp、Symbol類型的對象使用它們的構造器進行拷貝
var boolTag = '[object Boolean]',
    dateTag = '[object Date]',
    errTag = '[object Error]',
    mapTag = '[object Map]',
    arrTag = '[object Array]',
    objTag = '[object Object]',
    numberTag = '[object Number]',
    regexpTag = '[object RegExp]',
    setTag = '[object Set]',
    stringTag = '[object String]',
    argsTag = '[object Arguments]',
    symbolTag = '[object Symbol]';


function getPlainObjType(obj) {
    return Object.prototype.toString.call(obj)
}

// 判斷對象類型
function isObject(obj) {
    var type = typeof obj;
    return obj != null && (type == 'object' || type == 'function');
}

// 其它(內置)引用類型對象
function isReferObj(type) {
    return ~([dateTag, errTag, regexpTag, symbolTag].indexOf(type))
}

function isSet(type) {
    return type === '[object Set]'
}

function isMap(type) {
    return type === '[object Map]'
}

// 返回傳入對象構造器,這樣就能夠拷貝原型鏈屬性
function deepInit(obj) {
    const Ctor = obj.constructor;
    return new Ctor();
}

function cloneObjByTag(object, tag) {
    var Ctor = object.constructor;
    switch (tag) {
      case dateTag:
        return new Ctor(+object);

      case errTag:
        return new Ctor(object);

      case regexpTag:
        return cloneRegExp(object);

      case symbolTag:
        return cloneSymbol(object);
  }
}

function cloneRegExp(object) {
    let reFlags = /\w*$/;
    let result = new object.constructor(object.source, reFlags.exec(object));
    result.lastIndex = object.lastIndex;
    return result;
}

function cloneSymbol(object) {
    return Object(Symbol.prototype.valueOf.call(object));
}

function deepClone(target, wm = new WeakMap()) {
    if (!isObject(target)) {
        return target;
    }
    
    let type = getPlainObjType(target);
    let copy = deepInit(target);
    
    // 判斷是否存在循環引用結構
    let hit = wm.get(target);
    if (hit) {
        return hit;
    }
    wm.set(target, copy);
    
    if(isReferObj(type)) {
        copy = cloneObjByTag(target, type);
        return copy;
    }
    
    if (isSet(type)) {
        target.forEach(value => {
            copy.add(deepClone(value));
        });
        return copy;
    }
    
    if (isMap(type)) {
        target.forEach((value, key) => {
            copy.set(key, deepClone(value));
        });
        return copy;
    }
    
    Object.keys(target).forEach(key => {
        copy[key] = deepClone(target[key], wm)
    });
    
    return copy;
}
注意: 由於拷貝對象屬性的時候使用的是 Object.keys 暫且先不考慮 typedArray 類型對象和 Function,咱們將列舉出來的對象類型分爲可遍歷對象和不遍歷代對象(內置對象),使用不一樣的遍歷方法進行屬性複製, MapSet類型可使用其自帶的 forEach遍歷,對象、數組使用 Object.keys進行遍歷,其它內置的引用類型對象直接使用其構造器從新生成新對象。

其實寫到這裏至關於完成了一大部分,lodash 作了不少細節上面的優化工做,好比針對於對象層級很是多的時候特地對遍歷這塊作了些手腳:

function arrayEach(array, iteratee) {
  var index = -1,
      length = array == null ? 0 : array.length;

  while (++index < length) {
    if (iteratee(array[index], index, array) === false) {
      break;
    }
  }
  return array;
}
比較 forfor..in while循環,因爲 for..in 會遍歷整個對象上包括(原型鏈)的除 Symbol之外的可枚舉屬性,因此會慢些。可是網上諸多帖子的測試結果發現 forwhile相差很少,總的單純從執行時間長短來說 while 更快一些。

還有一些使用迭代器遍歷的方法,例如:forEacheverysome 它們的惟一區別在於對回調函數返回值的處理方式不一樣:forEach 會遍歷數組中的全部之並忽略回調函數的返回值。every 會一直運行直到 callback 返回 falsysome 會一直運行知道回調函數返回 truthy 。上例即是模仿 every 行爲來進行遍歷。

還有一些遍歷是訪問對象屬性時用到的:Object.keysObject.getOwnPropertyNames。這些和 in 的區別:Object.keys 只會遍厲對象直接包含的可枚舉屬性,Object.getOwnPropertyNames 會遍歷對象直接包含的屬性(不論它們是否可枚舉)。 in 操做符會查找對象(包括原型鏈)屬性是否存在(不管是否能夠遍歷),for..in 會遍歷整個對象上包括(原型鏈)的除Symbol之外的可枚舉屬性。

此外,ES6新增的 for..of 能夠對數組的值進行遍歷(若是對象自身定義了迭代器也能夠進行遍歷)。

能夠很清楚的看到使用while是重寫了forEach,這裏的 array 須要值得注意:

  • 若是遍歷的是對象那麼keyvalue須要對調,由於對象的 key 是數組的值而非下標
  • 自定義迭代回調函數的時候能夠根據不一樣邏輯設置返回值來中斷遍歷

咱們能夠改一下deepClone 中的遍歷的邏輯:

let keys = Array.isArray(target) ? undefined : Object.keys(target);

// Object.keys(target).forEach(key => {
//     copy[key] = deepClone(target[key], wm);
// });

arrayEach(keys || target, (value, key) => {
    if (keys) {
        key = value
    }
    copy[key] = deepClone(target[key], wm);
})

目前來說Function類型和二進制數組的typedArray還未實現深拷貝沒不過目前來說,平常開發使用最多的也仍是序列化和飯序列化版本。並且我也不常用這個封裝的深拷貝,寫這麼些東西只是出於學習和擴展思路用的,真正用的話 lodash 的徹底夠用了,後續還會去繼續研究 functionarrayBuffer的拷貝。

因爲深拷貝須要考慮的edge case太多,相信你們也會有不少探討,寫一個深拷貝不容易。具體孰優孰劣也須要跟業務想結合一下。

參考

小結

  1. 針對於深淺拷貝咱們先引出了值和引用,簡單值(即標量基本類型)老是經過值複製的方式來賦值 / 傳遞複合值(compound value)—— 對象(包括數組和封裝對象)和函數,老是經過引用複製的方式來賦值 / 傳遞,而且引用之間並不相互影響,從而點出深拷貝的相關思路。
  2. 首先使用最普遍的序列化進行拷貝,但限於序列化對function、集合、包裝對象、引用對象自動忽略,由此引出了遞歸拷貝。
  3. 考慮到循環引用問題引出利用緩存避免拷貝陷入死循環。
  4. 因爲未考慮到原型鏈的屬性,引出了利用構造器來拷貝對象,進而引出了更多數據類型的深拷貝。針對於集合形式的對象,咱們引用了對內存分配支持更好的WeakMap來做爲緩存對象。從而引出了像symbol正則、及其餘引用類型的對象的拷貝問題。
  5. 最後分析了lodash對於拷貝時遍歷的性能的優化,給了咱們一個在遍歷數據量很大時一個思路。
相關文章
相關標籤/搜索