JavaScript物件的深拷貝以及淺拷貝分析

船長_發表於2018-12-13

前置知識:

    說到深淺拷貝首先要了解的知識是資料型別,那麼js中會有兩個資料型別分別是 基本型別引用型別。那麼這兩種資料型別又有什麼區別呢,簡單來說他們的區別在於儲存的位置,基本型別是在棧裡儲存而引用型別就是在堆裡了,那麼堆疊的儲存有什麼區別呢?這個暫且放在後面講,那麼有的同學可能會問了,常見的 Object Number 不是也是資料型別嗎,其實 Object Number 這些資料型別和上面講的基本型別以及引用型別是包含關係我們用下面的圖來做說明。

JavaScript物件的深拷貝以及淺拷貝分析

補充一點:基本型別又叫原始型別,可能還會有人會問 NaN 是個啥,NaN 在資料型別上資料 Number,只不過它比較狠,是個特殊的 Number,特殊到他自己都不等於他自己。

那麼我們再來看一個問題堆和棧的儲存有什麼區別?先看下面這段程式碼的輸出:

var a = 1
console.log(a)  // 1
var b = a
console.log(b)  // 1
b = 2
console.log(a)  // 1
console.log(b)  // 2
/* b的改變並沒有影響到a */

var c = [1,2]
console.log(c)  // [1,2]
var d = c        
console.log(d)  // [1,2]
d.push(3)     
console.log(c)  // [1,2,3]
console.log(d)  // [1,2,3]
/* d的改變影響到了c */

複製程式碼

下面我們一步一步分析上面的程式碼:

var a = 1 這一步會在棧裡開闢一個空間 a 空間裡面儲存值 1

JavaScript物件的深拷貝以及淺拷貝分析
var b = a 這一步會繼續在棧裡開闢一個空間 b 空間裡面儲存由 a 複製而來的值
JavaScript物件的深拷貝以及淺拷貝分析
b = 2 這時把b的值進行修改,因為基本資料型別是按值訪問的,所以直接修改了空間 b 裡面的內容。
JavaScript物件的深拷貝以及淺拷貝分析
下面我們來建立一個引用型別的值 var c = [1,2] ,我們來分析這一步的過程:首先在棧裡開闢一個 c 空間,c 空間儲存的不是資料 [1,2] ,而是形如 0x0901 這種格式的地址,這個地址會指向堆裡面的一個空間,堆裡面的這個空間才會儲存實際的資料 [1,2] , 用圖表示就是下面這個樣子。
JavaScript物件的深拷貝以及淺拷貝分析
當執行 var d = c 的時候首先在棧裡面開闢一個 d 空間,d 空間存放由 c 複製而來的內容,這個時候要注意由 c 複製而來的是地址而不是實體資料。這個時候 c 和 d 裡面存放的是同一個地址,指向堆裡面共同的區域。
JavaScript物件的深拷貝以及淺拷貝分析
現在執行最後一步 d.push(3) 對 d 進行操作的時候因為 d 是引用型別,所以不會直接操作棧裡面的空間 d,而是通過空間 d 裡面的地址去找到堆裡面儲存的資料進行相應的操作,這也就解釋了為什麼對 d 進行 push 操作的時候 c 會改變了,因為他們通過相同的地址指向相同的空間,引用了相同的資料。
JavaScript物件的深拷貝以及淺拷貝分析

堆疊相關的內容已經敘述完了,我們再來看文章開始講到的資料型別,null 和 undefined 他倆是屬於基本型別,那他倆是用來幹什麼的,undefined 用於初始化未賦值的變數,null 用來主動釋放物件,主動釋放的物件無法找回。

有同學會問了為什麼要釋放物件,還要主動,這裡又得引入一個概念垃圾回收機制!

我們來看下面的操作:

var a = [1,2,3]
a = [4,5,6]
複製程式碼

這兩句複製語句的執行原理是這樣的:

棧中建立一個變數空間 名為a 堆中建立一個儲存空間 地址假設為0x0901 棧中變數中儲存的地址是0x0901 堆空間中儲存的是實際資料 a變數引用了堆中的一個陣列物件

JavaScript物件的深拷貝以及淺拷貝分析

當執行 a = [ 4,5,6 ] 的時候 會在堆中新建立一個陣列物件 將新物件地址儲存到原a變數中替換舊地址 舊陣列物件被釋放

JavaScript物件的深拷貝以及淺拷貝分析

下面這段話引自《JavaScript權威指南(第四版)》

    由於字串、物件和陣列沒有固定大小,所有當他們的大小已知時,才能對他們進行動態的儲存分配。JavaScript程式每次建立字串、陣列或物件時,直譯器都必須分配記憶體來儲存那個實體。只要像這樣動態地分配了記憶體,最終都要釋放這些記憶體以便他們能夠被再用,否則,JavaScript的直譯器將會消耗完系統中所有可用的記憶體,造成系統崩潰。

    這段話解釋了為什麼JavaScript需要垃圾回收,那麼JavaScript的直譯器可以檢測到何時程式不再使用一個物件了,當他確定了一個物件是無用的時候,他就知道不再需要這個物件,可以把它所佔用的記憶體釋放掉了。那怎麼確定一個物件什麼時候無用呢,就是上面提到的“被釋放”。要注意的是這個過程是有延時的,也就是說回收要比釋放晚。

我們來總結兩句廢話:

  • 物件在沒有變數“引用”的時候會被垃圾回收程式回收
  • 只要物件還有變數“引用”垃圾回收程式就不會回收

好了,上面粗略的介紹了堆疊概念和垃圾回收,現在可以正式引入深拷貝和淺拷貝這兩個概念了。

深拷貝與淺拷貝有什麼區別?     簡單點來說,就是假設B複製了A,當修改A時,看B是否會發生變化,如果B也跟著變了,說明這是淺拷貝,如果B沒變那就是深拷貝。

淺拷貝沒什麼好展開說的,我們直接來說深拷貝,那麼從堆疊儲存原理上怎麼才能實現深拷貝呢,我們來看下面的圖:

JavaScript物件的深拷貝以及淺拷貝分析

下面我們列舉幾種常見的物件拷貝方法以及各種方法的特點 :

1.大家知道陣列有很多方法會返回一個新陣列,那這些方法是不是可以變相的實現深拷貝呢我們以 slice 為例:

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

看起來可以其實不然,我們接著再看。

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

結局很明朗陣列這些方法只能實現一維陣列的深拷貝多維陣列就無能為力了。

2.Object.assign() 是es6的方法,用來合併物件,詳細的用法請各位同學自行查閱文件,我們來看一下用他來實現深拷貝。

var a = {name: {myName: 'chuan'}, class:'zhang'}
var b = Object.assign({},a)
a.class = 'zhang2'
a.name.myName = 'chuan2'
console.log(a)  // {name: {myName: 'chuan2'}, class:'zhang2'} 
console.log(b)  // {name: {myName: 'chuan2'}, class:'zhang'}
複製程式碼

結局依然明朗還是無法實現深層的拷貝

3.用 JSON.parse( JSON.stringify( obj ) ) 做深拷貝簡單暴力,但是有一個問題就是他不支援function以及一些特殊值的拷貝,比如:

var a = { fun: function(){ alert('1') }, a: undefined, c: NaN }
var b = JSON.stringify(a)
console.log(b)     //   { c: null }
複製程式碼

可以看到只是把 c 的值 NaN 輸出成了 null ,undefined 和 function 都被忽略了,這是因為 JSON.stringify 函式會將一個JavaScript物件轉換成文字化的JSON。不能被文字化的屬性會被忽略。

4.既然上面的方法都不能完成的實現深拷貝,那麼可以自己實現一個方法通過迴圈遍歷來實現深拷貝的功能。

function deepCopy(obj) {
  let result = obj instanceof Array ? [] : {}  // 判斷資料型別
  let keys = Object.keys(obj), key = null, temp = null;
  for (let i = 0; i < keys.length; i++) {
    key = keys[i]
    temp = obj[key]
    result[key] = temp
  }
  return result;
}
複製程式碼

現在的 deepCopy 函式也只是實現了第一層的拷貝,如果有複雜的資料結構還是無法實現深層的拷貝,那麼我們在 result[key] = temp 的時候需要對 temp 做一次判斷如果是 object 型別就再去做一次遍歷,這樣自然的就想到了遞迴。

function deepCopy(obj) {
  let result = obj instanceof Array ? [] : {}
  let keys = Object.keys(obj), key = null, temp = null;
  for (let i = 0; i < keys.length; i++) {
    key = keys[i]
    temp = obj[key]
    if (temp && typeof temp === 'object') {
      result[key] = deepCopy(temp)  // 遞迴
    } else {
      result[key] = temp
    }
  }
  return result;
}
複製程式碼

我們看一下現在是不是已經解決了之前深層拷貝的問題,來模擬一個略複雜的 object 結構。

var obj = {
  name: {
    myName: 'chuan' 
  },
  arr: [1,[2,3]],
  type: undefined,
  empty: null,
  fun: function() {
    console.log('chuan')
  }
}

var obj2 = deepCopy(obj)
obj.name.myName = 'chuan2'
obj.arr.push(5)
obj.arr[1].push(4)
obj.fun = function(){console.log('chuan2') }

console.log(obj2.name.myName) // chuan
console.log(obj2.arr)   // [1,[2,3]]
obj2.fun()  // chuan
複製程式碼

看似完美其實並沒有,如果有一個物件的某個屬性引用了這個物件自身呢,這樣會不會在遞迴中死迴圈下去,我們看下面這個例子:

var obj3 = {
    name: 'chuan'
  }
  obj3.obj = obj3
  deepCopy(obj3)
複製程式碼

華麗的報了一個堆疊溢位的錯誤

image.png

那我們處理一下這個問題簡單做一下判斷就好

function deepCopy(obj) {
    let result = obj instanceof Array ? [] : {}
    let keys = Object.keys(obj), key = null, temp = null;
    for (let i = 0; i < keys.length; i++) {
      key = keys[i]
      temp = obj[key]
      if (temp && typeof temp === 'object') {
        if(temp != obj){  // 解決堆疊溢位
          result[key] = deepCopy(temp)
        }
      } else {
        result[key] = temp
      }
    }
    return result;
  }
複製程式碼

好了大結局了,簡單對堆疊理論和深拷貝做了分析,其實還有很多值的仔細推敲的地方,比如 es6 關於object 的一些新增 api,用 keys 方法做遍歷是不是最優的方案,以及es6一些新增資料型別的拷貝也沒有做測試,正規表示式的拷貝,是不是可以用 call 來實現繼承,資料型別的判斷用 instanceof 是不是更好,這些坑就留給大家自己填了。

相關文章