該來談談淺拷貝(Shallow copy)與深拷貝(Deep copy)了

以前沒有想過我會被這個東西給雷到。

簡述

在談這兩個東西以前,先來聊聊我是怎麼踩到這個地雷的吧。

這是我當初在做「五子棋」的時所發生的情境:

為了儲存棋盤的狀態,我開了一個像這樣的資料結構:

1
2
3
4
5
6
7
const pieces = [
[null, null, null, null, ...],
[null, null, null, null, ...],
[null, null, null, null, ...],
[null, null, null, null, ...],
...
]

簡單來說就是一個用來表示 19 x 19 棋盤的二維陣列,第一層代表 y 軸,第二層代表 x 軸,所以 pieces[0][0] 代表第一排的第一支棋,piecesp[1][0] 代表第二排的第一支棋,以此類推。

當時要做的事情很簡單,就是在下棋時根據「被點擊的座標位置」來更新 state。

會講 state 這個詞就暗示我當時是用 React 來寫的,所以並不能用這種 piecesp[y][x] 的方式來改,而是得產生一個新的 state 才行。

所以,最簡單的作法就是先複製一份原本的陣列,再把要改的地方改掉就好,結果我就寫了這玩意兒:

1
2
3
4
5
6
// 複製一份原本的陣列
const newPieces = pieces.slice();
// 改成黑棋或白棋的狀態
newPieces[changedY][changedX] = isBlackNext ? "black" : "white";
// 更新 state
setPieces(newPieces);

接著就出現了這幅詭異的現象:

bug

當時的我感到懷疑人生,明明我給的座標就是 newPieces[0][18](最右上角),怎麼會所有的 newPieces[n][18](整個直排) 都一起改變了?

找了半天找不到原因,甚至後來直接把值寫死來測試,但還是得到一樣的結果,整個人都不好了(X

直到後來查了一些資料以後,我才理解原來是「淺拷貝」在背後作怪。

淺拷貝與深拷貝的差別

在講差別之前,我們可以先來看一下淺拷貝的範例,理解它會產生什麼問題:

1
2
3
4
5
6
7
8
const array = ['A', 'B', ['C']];
// 最簡潔的淺拷貝(展開運算子)
const copyArray = [...array]; // 可以想成是把 'A', 'B', ['C'] 放到 [] 裡
// 接著把巢狀的部分更新
copyArray[2][0] = 'Z';

console.log(copyArray); // ['A', 'B', ['Z']];
console.log(array); // ???

不了解淺拷貝的話就會很直覺的說:

「當然是 ['A', 'B', ['C']] 呀!」

可是真的是這樣嗎?其實正確答案是:['A', 'B', ['Z']]

如果你試著把這兩個值做比對的話,就會發現結果出乎意料:

1
console.log(copyArray[2] === array[2]); // true

要記得像 Array 或 Object 這種 non-primitive 的變數儲存的都是「Reference」,不是真正的值,所以當你去改兩個指向同一個 reference 的變數,當然就會兩個一起改變。

好像拉得有點遠,所以能告訴我淺拷貝是什麼了嗎?

  • 淺拷貝就是只能複製第一層的陣列或物件
  • 淺拷貝就是只能複製第一層的陣列或物件
  • 淺拷貝就是只能複製第一層的陣列或物件

以剛剛的例子來說,如果你這樣子做:

1
2
console.log(copyArray === array) // false
console.log(copyArray[2] === array[2]) // true

會發現如果只比較第一層的陣列,得到的結果會是 false,代表兩個東西是不一樣的,因為淺拷貝本來就可以複製「第一層」的東西。但如果是第二層的話就沒辦法了,它還是會指向同一個 Reference,所以第二個結果才會是 true

怎麼分辨淺拷貝與深拷貝?

這部分就要靠你自己搜尋了,Google 一下就蠻多答案了,總之大部分你常用的 slicemap... 都屬於淺拷貝。

而深拷貝因為還蠻麻煩的,所以如果你跟我一樣沒被雷過的話可能根本沒用過深拷貝這玩意兒。

總之呢,深拷貝大多數會依賴套件來做,因為它比較複雜一點,這邊隨便貼幾個我在網路上找到的範例:

1. 透過 jQuery 的 $.extend

1
2
3
4
5
6
7
8
9
let obj = {name: '王康寶', age:{child: 18}}
let copy = $.extend(true, {}, obj); //使用 jquery.extend

copy.name = '盧卡斯';
copy.age.child = 99; //更改 copy.age.child 的值

//輸出
console.log(obj); //{name: "王康寶", age:{child: 18}}
console.log(copy); //{name: "盧卡斯", age:{child: 99}}

2. 用 lodash(也是某個套件)

1
2
3
4
5
6
7
8
9
let obj = {name: '王康寶', age:{child: 18}}
let copy = _.cloneDeep(obj); //使用 lodash.cloneDeep

copy.name = '盧卡斯';
copy.age.child = 99; //更改 copy.age.child 的值

//輸出
console.log(obj); //{name: "王康寶", age:{child: 18}}
console.log(copy); //{name: "盧卡斯", age:{child: 99}}

最後是一個純 JavaScript 的方式,就拿我們剛剛的範例來做吧:

1
2
3
4
5
6
7
8
const array = ['A', 'B', ['C']];
const copyArray = JSON.parse(JSON.stringify(array));

copyArray[2][0] = 'Z'; // 更新值

console.log(copyArray); // ['A', 'B', ['Z']]
console.log(array); // ['A', 'B', ['C']]
console.log(array[2] === copyArray[2]); // false

這方法還蠻妙的,不過要注意沒辦法用內容有 function 的值,而且效能也不是那麼好。

最後的最後

所以回到一開始五子棋的例子,為什麼在我點了 newPieces[0][18] 以後會把所有的 newPieces[n][18] 給更新?

不如直接寫個範例來測試吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
const array = [
[null, null, null, null, null, null, null, null, null, null],
[null, null, null, null, null, null, null, null, null, null],
[null, null, null, null, null, null, null, null, null, null],
[null, null, null, null, null, null, null, null, null, null],
];
const shallowCopy = [...array];

// 把第一排最後面的值改掉
shallowCopy[0][9] = 'black';

console.log(shallowCopy);
console.log(array);

結果就會發現兩個 array 的第一排最後一個值都同時被改了。

這邊要先提醒,我們在 React 裡複製原本的 state 如果是淺拷貝,代表改東西的話也會動到原本的 state

這可麻煩大了,畢竟 React 非常強調 state 是「Immutable(不可以變)」的,所以去動到原本的 state 絕對會有一些問題。

雖然我還不明確知道實際發生了什麼,但至少知道當我們用淺拷貝時,會改到原本的 state,因此一定會產生一些問題,先知道這些就夠了。

附註

後來我發現上面的講法是錯的,實際的問題跟改了 React 的 state 沒有太大關連,而是因為 Array.prototpye.fill 這個內建函式引起的問題。

首先呢,根據 MDN 的說明:

當 fill 方法獲得一個傳入的物件,會將傳入的物件位置進行複製,並把其參考值(reference)之拷貝填入陣列中。

意思就是說如果你 fill 裡面放的是屬於「Object」的東西,那產生出的值都是 Reference,不是各自獨立的值,舉個例子:

1
2
3
var arr = Array(5).fill(null) // [null, null, ...]
var arr2 = Array(5).fill(arr) // [[null, null, ...], ...]
arr2[0].push("TEST");

這邊先用 fill 產生了二維陣列,接著再對二維陣列裡的第一個陣列 push 一個新的值,依照常理會認為只有第一個會新增,但結果卻是:

1
2
3
4
5
6
7
[
[null, null, null, null, null, "TEST"],
[null, null, null, null, null, "TEST"],
[null, null, null, null, null, "TEST"],
[null, null, null, null, null, "TEST"],
[null, null, null, null, null, "TEST"],
];

這就上面提到的 reference,不管是 arr2[0]arr[1] 還是 arr[2],它們指向的都是同一個 reference,這就是為什麼明明只改了一個,卻每個都被改變了的原因。

mentor-program-day123 在 React 使用 fontawesome 的方法
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×