談談 JavaScript 的深拷貝及淺拷貝

基本型別及物件型別

JavaScript上所有的東西可以分成兩大類:純值 Primitive Types物件型別 Object Type

基本型別aka純值 (Primitive Types)

  • 數字 number
  • 字串 string
  • 布林值 boolean
  • undefined
  • null
  • 符號 symbol (ES6)

物件型別 (Object Type)

JavaScript 中除了 Primitive Types 以外的東西,全都是物件型別!

傳值 (by value)

基本型別為傳值特性,將純值 b 指向純值 a,當中 a 或 b 進行修改時,不會互相影響。

1
2
3
4
5
6
var a = 1;
var b = a;
b = 2;

console.log(a); // 1
console.log(b); // 2

傳參考 (by reference)

物件型別特性是傳參考,將兩個物件互相指向,修改任一物件內部的屬性,都會影響另一個物件,因為這兩個變數都被指向同一個記憶體位置。

  • 物件

    1
    2
    3
    4
    5
    6
    var obj1 = { name: 'dylan', age: 18 };
    var obj2 = obj1;

    obj2.age = 21;
    console.log(obj1); // {name: "dylan", age: 21}
    console.log(obj2); // {name: "dylan", age: 21}
  • 陣列

    1
    2
    3
    4
    5
    6
    var arr1 = [1, 2, 3];
    var arr2 = arr1;
    arr2[0] = 10;

    console.log(arr1) // [10, 2, 3]
    console.log(arr2) // [10, 2, 3]

如果要避免傳參考,最簡單的方法可以這麼做。由於物件的指向都是傳參考,而純值為傳值,我們可以一一的去指向物件內的純值來避免這個問題,缺點是非常麻煩。

1
2
3
4
5
6
var obj1 = { name: 'dylan', age: 18 };
var obj2 = { name: obj1.name, age: obj1.age};

obj2.age = 21;
console.log(obj1); // {name: "dylan", age: 18}
console.log(obj2); // {name: "dylan", age: 21}

淺拷貝及深拷貝的定義


來源: Stack Overflow

淺拷貝 深拷貝
只是複製 collection structure,而不是 element 。當在指向第二層物件時會出現傳參考問題 整個複製,包含 element 。所以當我們在使用多層物件時,要盡量用 deep copy

淺拷貝 (Shallow Copy)

以下舉幾個常見的淺拷貝範例

Array.concat & Array.slice

使用這兩個的結果是一樣的,也是傳統較常見的淺拷貝方式。詳細的用法請參考這篇 JavaScript 陣列處理常用方法

  • Array.concat

    1
    2
    var arr1 = [1, 2, 3, {aa: 'aa'}];
    var arr2 = arr1.concat();
  • Array.slice

    1
    2
    var arr1 = [1, 2, 3, {aa: 'aa'}];
    var arr2 = arr1.slice();

看看結果

1
2
console.log(arr1); // [1, 2, 3, {aa: 'aa'}]
console.log(arr2); // [1, 2, 3, {aa: 'aa'}]

試著改變陣列第一層的值,結果並沒有造成傳參考,證明這個拷貝是成功了

1
2
3
4
arr1[0] = 'Dylan';

console.log(arr1); // ["Dylan", 2, 3]
console.log(arr2); // [1, 2, 3]

接著改變陣列中第二層物件內的值,結果發生了傳參考的問題

1
arr2[3].aa = 'bb';
1
2
console.log(arr1); // ["Dylan", 2, 3, {aa: 'bb'}]
console.log(arr2); // ["Dylan", 2, 3, {aa: 'bb'}]

Array.from & Spread

這是兩個都是 ES6 新增的,效果和 sliceconcat 是一樣的,但是可讀性較佳,算是一種語法糖。

  • Array.from

    1
    2
    var arr1 = [1, 2, 3, {aa: 'aa'}];
    var arr2 = Array.from(arr1);
  • Spread

    1
    2
    var arr1 = [1, 2, 3, {aa: 'aa'}];
    var arr2 = [...arr1];

看看結果

1
2
console.log(arr1); // [1, 2, 3, {aa: 'aa'}]
console.log(arr2); // [1, 2, 3, {aa: 'aa'}]

由於同上例子是屬於淺拷貝,結果是一樣的,這裡就簡單示範使用方法,不再作後續實驗。

兩者實際使用差異

Array.fromSpread 在實際使用上還是有差異的,請參考這篇

Spread Array.from
它不是運算符,僅適用於可迭代( iterable )的陣列 也適用於不可迭代的偽陣列( 具有 length 屬性和索引屬性的陣列 )
1
2
3
4
5
6
7
const arrayLikeObject = { 0: 'a', 1: 'b', length: 2 };

// This logs ['a', 'b']
console.log(Array.from(arrayLikeObject));

// This throws TypeError: arrayLikeObject[Symbol.iterator] is not iterable
console.log([...arrayLikeObject]);

結論是,當你想要將某東西轉換成陣列時,推薦使用 Array.from,因為他更好閱讀。而當你想要連接多個陣列時,Spread 可能較適合,如 ['a', 'b', ...someArray, ...someOtherArray]

參考:ES6 - Spread 展開與其餘參數

Object.assign

Object.assign 的可以傳入兩個參數,第一個參數可以是物件或是陣列,第二個參數為要合併的目標,若第一個參數帶入空物件或空陣列就可以達到淺拷貝的效果。

1
2
3
4
5
var obj1 = { name: 'Dylan', family: {
dad: 'Rob',
mom: 'Mary'
}};
var obj2 = Object.assign({}, obj1)

輸出看看,看似結果一樣

1
2
console.log(obj1);
console.log(obj2);

但是由於淺拷貝的特性,第二層以後的物件還是會有傳參考的問題。

1
2
console.log(obj1 === obj2);               // false
console.log(obj1.family === obj2.family); // true

在來試著改變其中一個物件的第一層純值,結果並不會傳參考。

1
2
3
4
obj2.name = 'Tony';

console.log(obj1); // {name: "Dylan", family: {…}}
console.log(obj2); // {name: "Tony", family: {…}}

再來試著改變物件中的第二層物件,結果就不同了,形成傳參考,這也是淺拷貝的問題。

1
obj2.family.dad = 'Walt';

深拷貝 (Deep Copy)

這邊一樣舉一些例子介紹

JSON.stringify 再 JSON.parse

我們可以使用 JSON.stringify 將陣列或物件轉換成字串後,再用 JSON.parse 轉回來。

1
2
3
4
5
var obj1 = { name: 'Dylan', family: {
dad: 'Rob',
mom: 'Mary'
}};
var obj2 = JSON.parse(JSON.stringify(obj1));
1
2
console.log(obj1 === obj2);               // false
console.log(obj1.family === obj2.family); // false

即使修改第二層物件,也不會影響另一個陣列的內容

1
obj2.family.dad = 'Walt';

這樣就是真正的 Deep Copy,也是這篇介紹唯一不需要使用其他函式庫的深拷貝方式,非常方便,但缺點是只有可以轉成 JSON 格式的物件才可以使用,例如 function 就沒有辦法被轉成 JSON。

1
2
var obj1 = { fn: function(){ console.log('aaa') } };
var obj2 = JSON.parse(JSON.stringify(obj1));

驗證

1
2
3
4
5
console.log(obj1.fn); // ƒ (){ console.log('aaa') }
console.log(obj2.fn); // 'undefined'

console.log(obj1 === obj2); // false
console.log(obj1.fn === obj2.fn); // false

被複製物件內的 function 會消失,所以這個方法只能用在單純只有資料的物件。

jQuery

我們還可以使用 jQuery 的 $.extend 來做到深拷貝

1
2
3
4
5
6
var obj1 = { name: 'Dylan', family: {
dad: 'Rob',
mom: 'Mary'
}};

var obj2 = $.extend(true, {}, obj1);

驗證

1
2
console.log(obj1 === obj2);               // false
console.log(obj1.family === obj2.family); // false

lodash

這是一個非常多人在使用的函式庫,我們可以使用他所提供的 _.cloneDeep 來做到深拷貝,效能佳,且使用簡單易讀。

1
2
3
4
5
6
var obj1 = { name: 'Dylan', family: {
dad: 'Rob',
mom: 'Mary'
}};

var obj2 = _.cloneDeep(obj1);

驗證

1
2
console.log(obj1 === obj2); // false
console.log(obj1.family === obj2.family); // false

參考資料

[Javascript] 關於 JS 中的淺拷貝和深拷貝
關於JAVASCRIPT中的SHALLOW COPY(淺拷貝)及DEEP COPY(深拷貝)