[React] 了解 immutable state 與 immutable update 方法

Monica
31 min readApr 25, 2024

--

《React 思維進化》 筆記系列
1. [React] DOM, Virtual DOM 與 React element
2. [React] 了解 JSX 與其語法、畫面渲染技巧
3. [React] 單向資料流介紹以及 component 初探
4. [React] 認識狀態管理機制 state 與畫面更新機制 reconciliation
5. [React] 在子 component 觸發父 component 資料更新、深入理解 batch update 與 updater function
6. [React] 了解 immutable state 與 immutable update 方法
7. [React] 認識 component 的生命週期、了解每次 render 都有自己版本的資料
8. [React] React 中的副作用處理、初探 useEffect
9. [React] 對 hooks 的 dependencies 誠實,以維護資料流的連動
10. [React] React 18 effect 函式執行兩次的原因及 useEffect 常見情境
11. [React] 認識 useCallback、useMemo,了解 hooks 運作原理

前言

承接上篇 [React] 在子 component 觸發父 component 資料更新、深入理解 batch update 與 updater function,此篇主要敘述的是《React 思維進化》 3–3 ~ 3–4 章節的筆記,若有錯誤歡迎大家回覆告訴我~

什麼是 immutable、什麼是 mutate

在 React 的 state 資料中,我們可儲存任何型別的資料,但如果是存放物件或陣列型別的資料,想要更新時,應該根據更新需求重新產生新的物件或陣列,為什麼呢~?接下來就先來看看什麼是 immutable、什麼是 mutate吧!

在 JavaScript 中,原始型別(primirive)的資料是 immutable (不可變的) 的,所謂 immutable 是指這些值本身不能被修改,若希望更新資料,只能「產生一個新值來取代舊的」。

補充:JavaScript 的原始型別
- 包含:String(字串)、Number(數字)、Boolean(布林值)、Undefined(未定義)、Null(空值)、Symbol(符號)、BigInt(大整數)
- 儲存的是「值」本身
- 原始型別的值在賦予給其他變數時,會複製值,而非複製參考
- 賦值給原始型別變數時,變數原有的值不會被修改,而是被新的值取代

舉例而言,當我們嘗試修改字串值內容時,字串不會有任何反應或改變:

嘗試修改字串值內容,但字串沒有任何改變

如果我們想要更新字串資料,要重新產生新字串來取代舊字串,才能順利更新:

重新產生新字串來取代舊字串

其中,str = 'Heact'; 是對 str 這變數「重新賦值」,改的是 str 這變數要指向哪個字串,而非改變字串 'React' 這值本身的內容,示意圖如下:

同樣的,在 React 更新原始型別的 state 資料,一樣會產生並指定新值來取代舊值:

const [number, setNumber] = useState(0);
const [name, setName] = useState('React');
const [isActive, setIsActive] = useState(false);

setNumber(1); //產生一個全新的數字值 1,並傳給 setNumber 來取代舊的值 0,原本的值 0 本身沒有被修改
setName('Foo'); //產生一個全新的字串值 'Foo',並傳給 setName 來取代舊的值 'React',原本的值 'React' 本身沒有被修改
setIsActive(true); //產生一個全新的布林值 true,並傳給 setIsActive 來取代舊的值 true,原本的值 false 本身沒有被修改

物件與陣列

相對於上述的原始型別,在 JavaScript 中,物件與陣列是以「參考(reference)」形式存在的資料,物件或陣列本身的內容是可變(mutable)的,修改其屬性或項目內容的操作稱為「mutate」。

const position = {x: 0, y: 0};
const names = ['HTML', 'CSS', 'JavaScript'];

position.x = 10; //mutate position 的屬性
names[0] = 'React'; //mutate names 的項目

mutate 物件或陣列的屬性內容時,此變數的參考對象不會改變,只是內容被修改,示意圖如下:

如果要改變變數的參考、或避免改變舊有物件或陣列內容,應產生全新的物件或陣列來取代舊的。

(補充:關於 JavaScript 的傳值或傳址,可參考這篇:你不可不知的 JavaScript 二三事#Day26:程式界的哈姆雷特 — — Pass by value, or Pass by reference?

保持 state 的 immutable

在 React,不該 mutate 一個物件或陣列型別的 state 資料,而是應該產生新物件或陣列來取代舊的,這種「資料一旦被建立後就不會再被事後修改」就稱為「immutable(不可變的)」。

保持 state 資料 immutable 是 React 的重要守則,在 JavaScript 中,物件與陣列都是 mutable 的,若要以 immutable 的方式操作物件或陣列,需要開發者自己建立安全手段來操作資料,自行維持 immutable state 的原則。

為何需要保持 state 的 immutable ?因為 React element 代表某個歷史時刻的畫面結構,一經建立後就不該被修改,而 state 作為原始資料同理,是用來表示 component 某個歷史時刻的資料,一旦被建立後就不該被修改,以維持單向資料流可靠性。

那如果不維持 state 的 immutable 會怎麼樣呢?接著來看看不維持 state 的 immutable 可能會產生的問題~

不維持 state 的 immutable 會怎麼樣? 👽 呼叫 setState 方法時的新舊資料檢查需求

如果我們有一個 state 以物件形式儲存文章的基本資料,期待在點擊按鈕後觸發文章資料的更新,並呼叫 re-render 來更新畫面。

以下是錯誤嘗試 1:以 mutaue 方式修改物件資料,在點擊按鈕後,不會觸發畫面更新:

import { useState } from "react";

export default function App() {
const [articleInfo, setArticleInfo] = useState({
title: "My titile",
descirption: "this is my article",
category: "React",
});

const handleClick = () => {
//⛔️ 以下是錯誤的 state 更新方式
articleInfo.title = "My NEW titile";

};

return (
<div>
<div>title: {articleInfo.title}</div>
<div>descirption: {articleInfo.descirption}</div>
<div>category: {articleInfo.category}</div>
<button onClick={handleClick}>clicke me</button>
</div>
);
}

讀者可能想說,這很正常呀,因為沒有呼叫 setArticleInfo 來觸發 re-render,畫面當然不會更新。

因此有了錯誤嘗試 2:加上 seArticleInfo 的呼叫,仍無法觸發畫面更新:

import { useState } from "react";

export default function App() {
const [articleInfo, setArticleInfo] = useState({
title: "My titile",
descirption: "this is my article",
category: "React",
});

const handleClick = () => {
//⛔️ 以下是錯誤的 state 更新方式
//因為 articleInfo 存的參考的值仍相同,透過 Object.is() 比較後發現相同,因此不會觸發 re-render
articleInfo.title = "My NEW titile";
setArticleInfo(articleInfo);
};

return (
<div>
<div>title: {articleInfo.title}</div>
<div>descirption: {articleInfo.descirption}</div>
<div>category: {articleInfo.category}</div>
<button onClick={handleClick}>clicke me</button>
</div>
);
}

為何不會更新?我們之前提過,reconciliation 過程中,當 setState 被呼叫後,React 會先以 Object.is() 來檢查新舊 state 值是否相同,若相同則判定資料沒更新,會直接中斷後續流程。而因為 articleInfo.title = “My NEW titile”; 是 mutate 既有物件, articleInfo 本身存的參考沒變,呼叫 setArticleInfo(articleInfo); 傳入的 articleInfo 還是舊的物件參考,因此 Object.is() 比較後會發現新舊值都指向同一個參考,判定相同、因而中斷後續流程。

那要如何讓畫面如預期更新?要在呼叫 setArticleInfo 時傳入新產生的物件:

import { useState } from "react";

export default function App() {
const [articleInfo, setArticleInfo] = useState({
title: "My titile",
descirption: "this is my article",
category: "React",
});

const handleClick = () => {
//✅ 建立新物件作為新 state 資料,沒有修改既有 articleInfo 物件
//newArticleInfo 會得到新的參考位址,和既有的 articleInfo 參考不同,因此會觸發 re-render
const newArticleInfo = {
title: "My NEW titile",
descirption: "this is my article",
category: "React",
};
setArticleInfo(newArticleInfo);
};

return (
<div>
<div>title: {articleInfo.title}</div>
<div>descirption: {articleInfo.descirption}</div>
<div>category: {articleInfo.category}</div>
<button onClick={handleClick}>clicke me</button>
</div>
);
}

setState 方法被呼叫後,React 會嘗試判定一個物件或陣列 state 是否改變,判定方式是看既有資料和新資料的「參考」是否相同,而不會檢查物件或陣列內的資料內容是否不同;因此即使傳給 setState 與原資料內容完全相同、但參考不同的物件或陣列,React 仍判定資料有更新,會繼續 re-render。

因此,不維持 state 的 immutable 會怎麼樣?會導致呼叫 setState 方法時,React 無法透過資料參考是否相同來判定新舊值是否相同,進而導致setState方法無法如我們預期的觸發 re-render、導致資料與畫面不一致。

不維持 state 的 immutable 會怎麼樣? 👽 過去 render 的舊 state 仍有被讀取的需求

在應用程式的商業邏輯中,我們可能需要讀取過去 render 的舊 state 資料,如果 mutate 了舊 state,可能會丟失資料的歷史紀錄、進而導致後續的邏輯失效。

以一個非同步事件的程式碼為例:

import { useState } from 'react';

export default function App() {
const [player, setPlayer] = useState({
position: { x: 0, y: 0 }
});

const moveToRight = () => {
//⛔️ 以下是錯誤的 state 更新方式
player.position.x += 1;
setPlayer({ ...player });
};

const alertCurrentPosition = () => {
setTimeout(
() => {
alert(`x: ${player.position.x}, y: ${player.position.y}`)
},
3000
);
}
return (
<div>
x: {player.position.x}, y: {player.position.y}
<div>
<button onClick={moveToRight}>move to right</button>
<button onClick={alertCurrentPosition}>
alert current position after 3 secs
</button>
</div>
</div>
);
}

在這範例中有兩個按鈕,各自綁定了事件處理,我們預期在點擊 「move to right」按鈕後, x 值能增加 1,而 y 不變;也預期在點擊 「alert current position after 3 secs」按鈕後,3 秒後會 alert 顯示點擊此按鈕瞬間的 position 資訊。

舉例來說,當位置為 0, 0 時,我們預期點擊 alert 按鈕後,會固定跳出 x: 0, y: 0 的資訊,但如果我們在位置為 0, 0 時,點擊 alert 按鈕,接著快速的在三秒內點 “move to right” 移動位置,最後跳出的 alert 會是x: 3, y: 0,而非預期結果x: 0, y: 0。示意動畫如下:

在位置為 0, 0 時,點擊 alert 按鈕,預期三秒後要顯示 x: 0, y: 0;但因為中間點了“move to right” 移動位置,導致三秒後顯示出 x: 3, y: 0

為何會有這個非預期結果?因為我們在 player.position.x += 1; 這裡錯誤 mutate 既有物件內容,導致每次更新 state 時,也一併修改了過去 render 的舊版 state 資料,因此 alertCurrentPosition 事件取出的舊版本 position資料已被修改過,導致最後出來的結果不如預期。

在非同步事件中,事件可能在 component 已 re-render 後才去讀舊 render 的 state,而因為我們用 mutate 的方式修改舊有資料,導致拿到的歷史資料已被修改,進而導致商業邏輯出錯。

除了非同步事件外,其他需要讀取舊 render 資料來進行後續邏輯處理的情境如:文章編輯的 undo / redo 功能、訂單狀態由 A 變為 B 後要進行特定處理…等,如果我們竄改了舊 state 資料,就會導致依賴這些資料的邏輯無法運作。

補充:非同步事件只是一種 mutate state 可能會有問題的情境,不是判斷要不要 immutable update 的依據,無論有沒有在非同步事件讀取 state,在所有情境都應該保持 state immutable

因此,不維持 state 的 immutable 會怎麼樣?會導致需要讀取舊 render 資料來進行判斷或處理的商業邏輯出錯,導致應用程式的行為不如預期。

不維持 state 的 immutable 會怎麼樣?👽 React 效能優化機制的參考檢查需求

React 很多效能優化機制會以資料的參考是否相同作為判斷依據,機制如:

  • useEffect
  • useCallback
  • useMemo
  • React.memo

若直接修改既有物件或陣列 state 資料,這些機制可能不會意識到資料有變化(因為內容改變,但參考沒變),機制運作會產生異常。

因此,不維持 state 的 immutable 會怎麼樣?會導致 React 的效能優化機制運作異常,導致某些時刻應用出現非預期錯誤。

💡小結,以 React 開發時,開發者要謹記,要維持資料處於「只要參考沒變就代表內容沒變,只要參考有變就代表內容有變」 的狀態

Immutable update

說了這麼多要以 immutable 的方式更新 state 資料,那實務上我們該用什麼方式來 immutable 更新物件或陣列資料呢?接下來要介紹如何以 immutable 的方式更新物件或陣列資料,immutable update 不限於 React 的 state 情境,所以我們先從純 JavaScript 來看~

物件資料的 immutable update 方法

以 spread 語法來複製物件的內容,並加上新屬性或更新既有屬性
以 immutable 方式新增或修改物件的屬性,可分為兩步驟:

  1. 建立一個全新物件,把既有物件的全部屬性複製到新物件中
  2. 在新物件加上新屬性,或覆蓋想修改的屬性的值

而我們可以用 JavaScript ES6 的 spread 語法來複製物件所有屬性到另一個物件,並在後面加上想要覆蓋或新增的屬性:

const oldObj = {a: 10, b: 20, c: 30};
const newObj = {...oldObj, a: 100, d: 400};

console.log(oldObj); //{a: 10, b: 20, c: 30}
console.log(newObj); //{a: 100, b: 20, c: 30, d: 400}

而如果遇到巢狀物件結構,則需要在有涉及屬性更新的每一層物件都做對應的 spread 屬性複製

const oldObj = {
a: 1,
b: 2,
innerObj1: {c: 3, d: 4},
innerObj2: {e: 5}
};

const newObj = {
...oldObj,
innerObj1: {...oldObj.innerObj1, d: 100}
}

console.log(oldObj);
//{a: 1, b: 2, innerObj1: {c: 3, d: 4}, innerObj2: {e: 5}}

console.log(newObj);
//{a: 1, b: 2, innerObj1: {c: 3, d: 100}, innerObj2: {e: 5}}

console.log(Object.is(oldObj, newObj));//false
console.log(Object.is(oldObj.innerObj1, newObj.innerObj1)); //false
console.log(Object.is(oldObj.innerObj2, newObj.innerObj2)); //true

步驟分別為:

  1. 建立新物件 newObj 並複製 oldObj 的所有屬性
  2. 因為想更新 oldObj 內的 innerObj1 物件內的屬性 d,因此也需建立一個新的 innerObj1 物件,並複製 oldObjinnerObj1 屬性,最後加上想覆蓋的屬性 d
  3. 沒有要更新 innerObj2 的內容,因此不須另外產生新的物件參考,只需沿用既有的物件即可

此操作滿足了 immutable update 的要求:

  • 既有資料所有層級的所有屬性值或參考都不能有任何改變
  • 想更新既有資料中任何一層物件或陣列內容時,就要為了新資料產生對應的新參考

以解構賦值配合 rest 語法來剔除物件的特定屬性
用解構賦值搭配 rest 語法,能以 immuatble 方式剔除既有物件某一屬性:

const oldObj = { a: 1, b: 2, c: 3};
const {b, ...newObj} = oldObj; //取出 b屬性,而 newObj 是除了 b 屬性以外的其他屬性所組成的物件

console.log(oldObj); //{a: 1, b: 2, c: 3}
console.log(newObj); //{a: 1, c: 3}

將想剔除的屬性 b 用解構賦值的方式單獨提出,將剩下的屬性用 rest 語法 集中到新物件 newObj 中,也就是除了想剔除的屬性外,複製既有物件的其餘屬性到新物件中。

陣列資料的 immutable update 方法

以 spread 語法來插入陣列項目

  • 在陣列的開頭插入新項目
const oldArr = ['A', 'B', 'C'];
const newArr = ['New Item', ...oldArr]; //先在新陣列放上要新增的項目,再用 spread 複製既有陣列的項目

console.log(oldArr); //['A', 'B', 'C']
console.log(newArr); //['New Item', 'A', 'B', 'C']
  • 在陣列結尾插入新項目
const oldArr = ['A', 'B', 'C'];
const newArr = [...oldArr, 'New Item']; //先在新陣列放上 spread 複製的既有陣列項目,再接著放要新增的項目

console.log(oldArr); //['A', 'B', 'C']
console.log(newArr); //['A', 'B', 'C', 'New Item']
  • 在陣列中間插入新項目
    以陣列的 slice 方法來複製兩段舊有陣列,因 slice 方法會回傳只包含部分項目的新陣列,且不 mutate 原陣列,可在 immutable update 時使用。
//想在 index 為 1 和 index 為 2 的項目中間插入新項目,代表目標插入位置是 index 2,
//會將原先在 index 為 2 的項目 'C' 往後擠到 index 3

const oldArr = ['A', 'B', 'C', 'D'];

const insertTargetIndex = 2;
const newArr = [
...oldArr.slice(0, insertTargetIndex), //slice 不會 mutate 原有陣列本身,而會回傳新陣列,這裡會回傳 ['A', 'B']
'New Item',
...oldArr.slice(insertTargetIndex), //這裡會回傳 ['C', 'D']
];

console.log(oldArr); //['A', 'B', 'C', 'D']
console.log(newArr); //['A', 'B', 'New Item', 'C', 'D']

操作步驟:

  1. oldArr.slice(0, 2) 取得既有陣列 index 2 (不包含 index 2)之前的所有項目,也就是 ['A', 'B'],將這段 spread 到新陣列開頭
  2. 擺上要新增的項目 'New Item'
  3. 再呼叫 oldArr.slice(2) 取得既有陣列從 index 2 (包含 index 2)以後的所有項目,也就是 ['C', 'D'],將這段 spread 到新陣列尾端

剔除陣列項目
以陣列的 filter 方法來剔除陣列中的特定項目,filter 通常用來過濾要保留的項目,轉換思路後,可想成是將不符合剔除條件的項目保留下來。

const oldArr = ['A', 'B', 'C', 'D'];

const removeTargetIndex = 2;
const newArr = oldArr.filter((item, index) => index !== removeTargetIndex);

console.log(oldArr);//['A', 'B', 'C', 'D']
console.log(newArr);//['A', 'B', 'D']

更新或取代陣列項目
以陣列的 map 方法來更新或取代陣列項目:

const oldArr = ['A', 'B', 'C', 'D'];
const newArr = oldArr.map((item, index) => (index == 2) ? 'NEW Item' : item);
//如果項目 index 符合目標條件就回傳新項目來取代,否則回傳原有項目

console.log(oldArr);//['A', 'B', 'C', 'D']
console.log(newArr);//['A', 'B', 'NEW Item', 'D']

也可用 map 作項目的進一步計算:

const oldArr = [550, 680, 250, 300];
const newArr = oldArr.map(number => number * 0.9);

console.log(oldArr);//[550, 680, 250, 300]
console.log(newArr);//[495, 612, 225, 270]

排列陣列項目
🚨 陣列內建的 sort 方法會 mutate 既有陣列,不能直接用 oldArr.sort() 來排序:

const oldArr = [5, 34, 22, 2];
//⛔️ mutate 到舊陣列
const newArr = oldArr.sort((a, b) => a - b);//直接以既有陣列進行由小到大排序,並回傳一個新陣列

console.log(oldArr);//[2, 5, 22, 34],既有陣列沒有保持 immutable,被 sort 方法 mutate 了
console.log(newArr);//[2, 5, 22, 34]

✅ 要以 immutable 的方式排序,先複製一份新陣列,針對新陣列 sort(),才能保持原陣列的 immutable:

const oldArr = [5, 34, 22, 2];
const newArr = [...oldArr]; //用 spread 複製既有陣列的所有項目
newArr.sort((a, b) => a - b); // sort 新陣列項目

console.log(oldArr);//[5, 34, 22, 2],既有陣列維持原樣,沒有被 mutate
console.log(newArr);//[2, 5, 22, 34],新陣列被 sort() mutate 過

而反轉陣列順序的 reverse方法也會 mutate 原陣列,一樣要先複製一份新陣列再 reverse 新陣列:

const oldArr = [5, 34, 22, 2];
const newArr = [...oldArr]; //用 spread 複製既有陣列的所有項目
newArr.reverse(); // reverse 新陣列項目

console.log(oldArr);//[5, 34, 22, 2],既有陣列維持原樣,沒有被 mutate
console.log(newArr);//[2, 22, 34, 5],新陣列被 reverse() mutate 過

巢狀式參考型別的複製誤解

immutable update 巢狀物件或陣列資料時,常會出現錯誤的操作。範例程式碼如下:

import { useState } from "react";

export default function App() {
const [cartItems, setCartItems] = useState([
{ productId: 'foo', quanity: 1 },
{ productId: 'bar', quanity: 8 },
{ productId: 'fizz', quanity: 3 },
])

const handleCarItemQuanityChange = (targetIndex, quanity) => {
//更新 cartItems state 陣列中位於 targetIndex 這個 index 的物件的 quanity 屬性
//我們預期在呼叫 handleCarItemQuanityChange 後,更新位於 targetIndex 這個 index 的物件的 quanity 屬性
}

此時多數人會以以下這種錯誤方式來更新 state,此方法仍會 mutate 原state 資料

import { useState } from "react";

export default function App() {
const [cartItems, setCartItems] = useState([
{ productId: 'foo', quanity: 1 },
{ productId: 'bar', quanity: 8 },
{ productId: 'fizz', quanity: 3 },
])

const handleCarItemQuanityChange = (targetIndex, quanity) => {
//⛔️ 注意:以下是錯誤的、錯誤的、錯誤的 immutable update 方法!請不要讓它在你腦海停留
const newCartItems = [...cartItems];
newCartItems[targetIndex].quanity = quanity;

setCartItems(newCartItems)
}

巢狀式參考型別的 immutable update

這個操作哪裡有錯?雖然用 spread 複製出新陣列,但陣列中每個項目都是物件,而物件以參考形式存在,因此新陣列中每個物件項目都和舊陣列的物件項目是同一個參考,mutate 新陣列中的物件項目,也會同時 mutate 既有陣列中的物件。

我們可以用 Object.is() 來查看 cartItems[targetIndex]newCartItems[targetIndex] 是否指向同一個參考:

const handleCarItemQuanityChange = (targetIndex, quanity) => {
//⛔️ 注意:以下是錯誤的、錯誤的、錯誤的 immutable update 方法!請不要讓它在你腦海停留
const newCartItems = [...cartItems];

console.log(Object.is(cartItems, newCartItems)); //false
console.log(Object.is(cartItems[targetIndex], newCartItems[targetIndex])); //true

newCartItems[targetIndex].quanity = quanity;
//newCartItems[targetIndex] 和 cartItems[targetIndex] 指向同一個物件參考,mutate newCartItems[targetIndex] 的 quanity 屬性就等同去 mutate cartItems[targetIndex] 的 quanity 屬性

setCartItems(newCartItems)
}

因為 newCartItems 是新陣列,在 setState 後以 Object.is() 檢查時,還是能被判定為不同參考而繼續 re-render,但實際上舊的 cartItems[targetIndex] 物件還是有被更改到,這也讓這個錯誤操作更難被察覺與除錯。

因此,在操作物件或陣列資料時,建議使用本來就是 immutable 的操作方法,如:mapfilter、spread、rest。

以下是正確操作巢狀參考型別資料的方式,複製最外層,如果要更新內層的物件或陣列時,也都要複製並產生新的參考,而沒有要更新的則沿用即可:

const handleCarItemQuanityChange = (targetIndex, quanity) => {
const newCartItems = cartItems.map((cartItem, index) => (
//✅ 在 targetIndex 的位置產生新物件,複製既有物件的所有屬性,再覆蓋上 quanity 屬性的新值
(index === targetIndex) ? {...cartItem, quanity} : cartItem
));

console.log(Object.is(cartItems, newCartItems)); //false
console.log(Object.is(cartItems[targetIndex], newCartItems[targetIndex])); //false

setCartItems(newCartItems);
};

補充:sortreverse 可以在複製陣列後呼叫而不擔心 mutate 的問題,是因為 sortreverse 只會 mutate 陣列中項目的排列順序,不會 mutate 項目的內容,所以複製新陣列後再 sortreverse 是安全的👌。

Spread 複製的是值還是參考

用 spread 語法複製物件屬性時:

  • 如果是複製原始型別的屬性,會複製出獨立的新值
  • 如果是複製陣列或物件的屬性時,會複製記憶體中的位址(參考),而非複製完整內容

以以下範例來說:

 const oldObj = {a: 10, b: 20, c: { foo: 8, bar: 9}};
const newObj = {...oldObj, d: 400};

console.log(newObj); //{a: 10, b: 20, c: { foo: 8, bar: 9}, d: 400}
console.log(Object.is(oldObj.c, newObj.c)); //true

複製屬性 ab 時,因為 ab 屬性是原始型別,所以是複製值本身,而屬性 c 是物件,所以是複製參考,因此 oldObj.cnewObj.c 會指向同一個參考,mutate newObj.c 的同時也會修改到 oldObj.c

若要改動 oldObj.c,應該先複製一份 oldObj.c,再覆蓋新的屬性值:

const newObj = {
...oldObj,
c: {...oldObj.c, foo: 800, buzz: 600},
d: 100
}

JavaScript 中,陣列或物件資料中每一層參考都是獨立的,spread 語法只會做單層的 shallow clone

補充:shallow clone 和 deep clone 都是指複製物件或陣列的方式,但深度不同
- shallow clone (淺複製):只複製物件第一層屬性,如果屬性內容是原始型別則複製實際的值;如果屬性內容是物件型別則複製參考,複製的參考仍指向原物件。JavaScript 的 spread 語法是常用的 shallow clone 方式。
- deep clone (深複製):複製物件的所有層級,若遇到巢狀物件或陣列結構,會遍歷並複製每一層的每一個值。

Immutable update 不需要且不應使用 deep clone

immutable update 巢狀陣列或物件時,不需修改的部分只需沿用舊的參考,因為 immutable 重點不是整包資料完整複製或獨立,不需要做 deep clone,重點是「有內容更新需求時,就要建立全新的參考;而沒有更新需求的就可沿用參考」,只要讓既有資料能永遠對應特定歷史時刻的狀態即可。

補充:不推薦以 deep clone 來 immutable update React 的 state 資料,會導致以下缺點:
- 消耗多餘效能
- 造成不必要的複製,導致記憶體和效能的浪費
- 失去參考相等性,React 效能優化機制依賴於參考的相等性,deep clone 會導致沒更新的地方也產生新的參考,被 React 誤以為資料有更新,導致優化機制出錯

當物件或陣列結構複雜後,可能需要以多層的 spread 來複製,會降低程式碼可讀性和可維護性,因此可透過第三方套件來協助我們處理 immutable update(但初學還是建議先從基本概念和操作學起):

Reference:

如有任何問題歡迎聯絡、不吝指教✍️

--

--