js 深拷貝 vs 淺拷貝

sunshine小小倩發表於2017-09-03

本文主要講一下 js 的基本資料型別以及一些堆和棧的知識和什麼是深拷貝、什麼是淺拷貝、深拷貝與淺拷貝的區別,以及怎麼進行深拷貝和怎麼進行淺拷貝。

本文思維導圖如下:

本文思維導圖
本文思維導圖

本文首發於我的個人部落格:cherryblog.site/

堆和棧的區別

其實深拷貝和淺拷貝的主要區別就是其在記憶體中的儲存型別不同。

堆和棧都是記憶體中劃分出來用來儲存的區域。

棧(stack)為自動分配的記憶體空間,它由系統自動釋放;而堆(heap)則是動態分配的記憶體,大小不定也不會自動釋放。

ECMAScript 的資料型別

在將深拷貝和淺拷貝之前,我們先來重新回顧一下 ECMAScript 中的資料型別。主要分為

基本資料型別(undefined,boolean,number,string,null

基本資料型別主要是:undefined,boolean,number,string,null

基本資料型別存放在棧中

存放在棧記憶體中的簡單資料段,資料大小確定,記憶體空間大小可以分配,是直接按值存放的,所以可以直接訪問。

基本資料型別值不可變

javascript中的原始值(undefined、null、布林值、數字和字串)與物件(包括陣列和函式)有著根本區別。原始值是不可更改的:任何方法都無法更改(或“突變”)一個原始值。對數字和布林值來說顯然如此 —— 改變數字的值本身就說不通,而對字串來說就不那麼明顯了,因為字串看起來像由字元組成的陣列,我們期望可以通過指定索引來假改字串中的字元。實際上,javascript 是禁止這樣做的。字串中所有的方法看上去返回了一個修改後的字串,實際上返回的是一個新的字串值

基本資料型別的值是不可變的,動態修改了基本資料型別的值,它的原始值也是不會改變的,例如:

    var str = "abc";

    console.log(str[1]="f");    // f

    console.log(str);           // abc複製程式碼

這一點其實開始我是比較迷惑的,總是感覺 js 是一個靈活的語言,任何值應該都是可變的,真是圖樣圖森破,我們通常情況下都是對一個變數重新賦值,而不是改變基本資料型別的值。就如上述引用所說的那樣,在 js 中沒有方法是可以改變布林值和數字的。倒是有很多操作字串的方法,但是這些方法都是返回一個新的字串,並沒有改變其原有的資料。

所以,記住這一點:基本資料型別值不可變

基本型別的比較是值的比較

基本型別的比較是值的比較,只要它們的值相等就認為他們是相等的,例如:

    var a = 1;
    var b = 1;
    console.log(a === b);//true複製程式碼

比較的時候最好使用嚴格等,因為 == 是會進行型別轉換的,比如:

    var a = 1;
    var b = true;
    console.log(a == b);//true複製程式碼

引用型別

引用型別存放在堆中

引用型別(object)是存放在堆記憶體中的,變數實際上是一個存放在棧記憶體的指標,這個指標指向堆記憶體中的地址。每個空間大小不一樣,要根據情況開進行特定的分配,例如。

var person1 = {name:'jozo'};
var person2 = {name:'xiaom'};
var person3 = {name:'xiaoq'};複製程式碼

堆記憶體
堆記憶體

引用型別值可變

引用型別是可以直接改變其值的,例如:

    var a = [1,2,3];
    a[1] = 5;
    console.log(a[1]); // 5複製程式碼

引用型別的比較是引用的比較

所以每次我們對 js 中的引用型別進行操作的時候,都是操作其物件的引用(儲存在棧記憶體中的指標),所以比較兩個引用型別,是看其的引用是否指向同一個物件。例如:


    var a = [1,2,3];
    var b = [1,2,3];
    console.log(a === b); // false複製程式碼

雖然變數 a 和變數 b 都是表示一個內容為 1,2,3 的陣列,但是其在記憶體中的位置不一樣,也就是說變數 a 和變數 b 指向的不是同一個物件,所以他們是不相等的。

引用型別在記憶體中的儲存
引用型別在記憶體中的儲存

(懶癌晚期,不想自己畫圖了,直接盜圖)

傳值與傳址

瞭解了基本資料型別與引用型別的區別之後,我們就應該能明白傳值與傳址的區別了。
在我們進行賦值操作的時候,基本資料型別的賦值(=)是在記憶體中新開闢一段棧記憶體,然後再把再將值賦值到新的棧中。例如:

var a = 10;
var b = a;

a ++ ;
console.log(a); // 11
console.log(b); // 10複製程式碼

基本資料型別的賦值
基本資料型別的賦值

所以說,基本型別的賦值的兩個變數是兩個獨立相互不影響的變數。

但是引用型別的賦值是傳址。只是改變指標的指向,例如,也就是說引用型別的賦值是物件儲存在棧中的地址的賦值,這樣的話兩個變數就指向同一個物件,因此兩者之間操作互相有影響。例如:

var a = {}; // a儲存了一個空物件的例項
var b = a;  // a和b都指向了這個空物件

a.name = 'jozo';
console.log(a.name); // 'jozo'
console.log(b.name); // 'jozo'

b.age = 22;
console.log(b.age);// 22
console.log(a.age);// 22

console.log(a == b);// true複製程式碼

引用型別的賦值
引用型別的賦值

淺拷貝

在深入瞭解之前,我認為上面的賦值就是淺拷貝,哇哈哈,真的是圖樣圖森破。上面那個應該只能算是“引用”,並不算是真正的淺拷貝。
一下部分參照知乎中的提問: javascript中的深拷貝和淺拷貝

賦值(=)和淺拷貝的區別

那麼賦值和淺拷貝有什麼區別呢,我們看下面這個例子:

    var obj1 = {
        'name' : 'zhangsan',
        'age' :  '18',
        'language' : [1,[2,3],[4,5]],
    };

    var obj2 = obj1;


    var obj3 = shallowCopy(obj1);
    function shallowCopy(src) {
        var dst = {};
        for (var prop in src) {
            if (src.hasOwnProperty(prop)) {
                dst[prop] = src[prop];
            }
        }
        return dst;
    }

    obj2.name = "lisi";
    obj3.age = "20";

    obj2.language[1] = ["二","三"];
    obj3.language[2] = ["四","五"];

    console.log(obj1);  
    //obj1 = {
    //    'name' : 'lisi',
    //    'age' :  '18',
    //    'language' : [1,["二","三"],["四","五"]],
    //};

    console.log(obj2);
    //obj2 = {
    //    'name' : 'lisi',
    //    'age' :  '18',
    //    'language' : [1,["二","三"],["四","五"]],
    //};

    console.log(obj3);
    //obj3 = {
    //    'name' : 'zhangsan',
    //    'age' :  '20',
    //    'language' : [1,["二","三"],["四","五"]],
    //};複製程式碼

先定義個一個原始的物件 obj1,然後使用賦值得到第二個物件 obj2,然後通過淺拷貝,將 obj1 裡面的屬性都賦值到 obj3 中。也就是說:

  • obj1:原始資料
  • obj2:賦值操作得到
  • obj3:淺拷貝得到

然後我們改變 obj2name 屬性和 obj3name 屬性,可以看到,改變賦值得到的物件 obj2 同時也會改變原始值 obj1,而改變淺拷貝得到的的 obj3 則不會改變原始物件 obj1。這就可以說明賦值得到的物件 obj2 只是將指標改變,其引用的仍然是同一個物件,而淺拷貝得到的的 obj3 則是重新建立了新物件。

然而,我們接下來來看一下改變引用型別會是什麼情況呢,我又改變了賦值得到的物件 obj2 和淺拷貝得到的 obj3 中的 language 屬性的第二個值和第三個值(language 是一個陣列,也就是引用型別)。結果見輸出,可以看出來,無論是修改賦值得到的物件 obj2 和淺拷貝得到的 obj3 都會改變原始資料。

這是因為淺拷貝只複製一層物件的屬性,並不包括物件裡面的為引用型別的資料。所以就會出現改變淺拷貝得到的 obj3 中的引用型別時,會使原始資料得到改變。

深拷貝:將 B 物件拷貝到 A 物件中,包括 B 裡面的子物件,

淺拷貝:將 B 物件拷貝到 A 物件中,但不包括 B 裡面的子物件

-- 和原資料是否指向同一物件 第一層資料為基本資料型別 原資料中包含子物件
賦值 改變會使原資料一同改變 改變會使原資料一同改變
淺拷貝 改變會使原資料一同改變 改變會使原資料一同改變
深拷貝 改變會使原資料一同改變 改變會使原資料一同改變

深拷貝

看了這麼半天,你也應該清楚什麼是深拷貝了吧,如果還不清楚,我就剖腹自盡(ಥ_ಥ)

深拷貝是對物件以及物件的所有子物件進行拷貝。

那麼問題來了,怎麼進行深拷貝呢?

思路就是遞迴呼叫剛剛的淺拷貝,把所有屬於物件的屬性型別都遍歷賦給另一個物件即可。我們直接來看一下 Zepto 中深拷貝的程式碼:

    // 內部方法:使用者合併一個或多個物件到第一個物件
    // 引數:
    // target 目標物件  物件都合併到target裡
    // source 合併物件
    // deep 是否執行深度合併
    function extend(target, source, deep) {
        for (key in source)
            if (deep && (isPlainObject(source[key]) || isArray(source[key]))) {
                // source[key] 是物件,而 target[key] 不是物件, 則 target[key] = {} 初始化一下,否則遞迴會出錯的
                if (isPlainObject(source[key]) && !isPlainObject(target[key]))
                    target[key] = {}

                // source[key] 是陣列,而 target[key] 不是陣列,則 target[key] = [] 初始化一下,否則遞迴會出錯的
                if (isArray(source[key]) && !isArray(target[key]))
                    target[key] = []
                // 執行遞迴
                extend(target[key], source[key], deep)
            }
            // 不滿足以上條件,說明 source[key] 是一般的值型別,直接賦值給 target 就是了
            else if (source[key] !== undefined) target[key] = source[key]
    }

    // Copy all but undefined properties from one or more
    // objects to the `target` object.
    $.extend = function(target){
        var deep, args = slice.call(arguments, 1);

        //第一個引數為boolean值時,表示是否深度合併
        if (typeof target == 'boolean') {
            deep = target;
            //target取第二個引數
            target = args.shift()
        }
        // 遍歷後面的引數,都合併到target上
        args.forEach(function(arg){ extend(target, arg, deep) })
        return target
    }複製程式碼

在 Zepto 中的 $.extend 方法判斷的第一個引數傳入的是一個布林值,判斷是否進行深拷貝。

$.extend 方法內部,只有一個形參 target,這個設計你真的很巧妙。
因為形參只有一個,所以 target 就是傳入的第一個引數的值,並在函式內部設定一個變數 args 來接收去除第一個引數的其餘引數,如果該值是一個布林型別的值的話,說明要啟用深拷貝,就將 deep 設定為 true,並將 target 賦值為 args 的第一個值(也就是真正的 target)。如果該值不是一個布林型別的話,那麼傳入的第一個值仍為 target 不需要進行處理,只需要遍歷使用 extend 方法就可以。

這裡有點繞,但是真的設計的很精妙,建議自己打斷點試一下,會有意外收穫(玩轉 js 的大神請忽略)。

而在 extend 的內部,是拷貝的過程。

參考文章:

相關文章