為什麼 React 中的 state 必須是 immutable?
2025-07-05 00:21:18
> 備注:
> immutable = 資料不可變性
> mutable = 資料可變性
## 從變數說起:理解記憶體的秘密
### Q1:請問會印出 `x` 還是 `5` 呢?
```js
let x = 5;
let y = x;
y = 10;
console.log(x); // ?
```
**A:5。**
當 `let y = x` 執行時,是將 `x` 的值(5)**複製**到變數 `y` 的記憶體位置。此時 `x` 和 `y` 是兩個獨立的變數,各自儲存自己的數值。
> `x` 就像你的筆記本,第一頁寫著 `5`
> `y` 是你朋友的筆記本,他抄了一份 `5`
> 當朋友把他的筆記本改成 `10`,你的筆記本還是 `5`
### Q2:為什麼 `array1` 的第一個元素會變成 `5`?
```js
const array1 = [1, 2, 3];
const array2 = array1;
array2[0] = 5;
console.log(array1); // [5, 2, 3]
```
**A:因為這是參考型別的「共享記憶體」行為。**
`array2 = array1` 是把 `array1` 的\*\*記憶體參考(reference)\*\*賦值給 `array2`,代表兩者指向的是同一個儲存資料的位址。
> `array1` 是一把倉庫的鑰匙
> `array2` 是你複製出來的鑰匙
> 這兩把鑰匙都能打開同一間倉庫,改變倉庫內容後,兩者都會看到變化
## 原始型別 vs 參考型別
JavaScript 中,資料型別分為兩大類:
| 類型 | 特性 | 範例 |
| ---- | --- | ----------------------------- |
| 原始型別 | 不可變 | Number, String, Boolean, etc. |
| 參考型別 | 可變 | Object, Array, Function, etc. |
原始型別是「值的複製」,參考型別是「記憶體參考的複製」。
> 特殊案例:`null` 是原始型別,但 `typeof null === "object"` 是 JavaScript 歷史的 bug。
## 資料不變性(Immutability):不是不能變,而是不直接改
```js
const user = { name: "Alice", age: 25 };
const updatedUser = user;
updatedUser.age = 26;
console.log(user.age); // !!變成 26
```
在這段範例中,我們**以為**複製了一份 `user` 資料,但其實只是複製了它的參考。當我們修改 `updatedUser` 時,其實也是在改原本的 `user`。
這就是參考型別的陷阱,會導致:
* 意料之外的副作用
* 其他使用者無端被影響
* 無法追蹤哪些資料真的被改動
### ✅ 正確作法:創建新資料
```js
const user = { name: "Alice", age: 25 };
const updatedUser = { ...user, age: 26 };
console.log(user.age); // 25
console.log(updatedUser.age); // 26
```
透過展開運算子(spread),你就創造了一份新的資料,兩者互不影響。
## React 的痛點:如何知道什麼時候該重新渲染?
React 中最關鍵的設計之一,就是**狀態改變 → UI 更新(Re-render)**。
為了讓這個機制運作得輕量又快速,React 選擇了一條重要的設計原則:
> 只要 state 是不可變的,React 就能用最小的代價判斷「資料有沒有改變」
### React 中 setState 的判斷機制
React 使用 `Object.is()` 來判斷 state 是否改變:
```js
const setState = (newValue) => {
if (!Object.is(currentState, newValue)) {
currentState = newValue;
scheduleReRender(); // -> 觸發畫面更新
}
};
```
這個判斷不會比較資料內容,而是**直接比較記憶體位置(reference)是否相同**。
### 為什麼不用深層比較(deep equality)?
| 比較方式 | 優點 | 缺點 |
| ------------- | --------------- | ------------------ |
| `Object.is()` | 快速、效能好、記憶體低耗 | 只判斷 reference,不看內容 |
| 深層比較(deep) | 可以精準比較所有層資料是否一致 | 非常慢、複雜、效能差 |
所以 React 的立場是:
> ❗你幫我維持資料不可變,我就幫你判斷是否該重繪。
### 錯誤範例:只改內容,沒有改 reference
```js
function MyComponent() {
const [user, setUser] = useState({ name: "Alice", age: 25 });
const badUpdate = () => {
user.age = 26;
setUser(user); // 參考沒變,React 不會 re-render
};
}
```
這段程式碼表面上資料有改,但因為記憶體位置沒變,React 不會覺得「你真的更新了 state」,所以畫面不會更新。
## 正確更新方式
```tsx
function MyComponent() {
const [number, setNumber] = useState(0);
const [user, setUser] = useState({ name: "Alice", age: 25 });
const goodUpdate1 = () => setNumber(5); // 原始型別,自然 immutable
const goodUpdate2 = () => setUser({ ...user, age: 26 }); // 新 reference
}
```
## 為什麼 React 使用 `Object.is()`?
React 的設計目標是:**可預測、效能穩定、維護簡單**。使用 `Object.is()` 有以下好處:
1. **效能極高**:Reference 比較速度快,不需深層遞迴。
2. **搭配 immutable 資料結構效果最佳**:只要你創造新資料,React 就能瞬間知道有變動。
> 記得:**不變性不是限制你不能修改資料,而是強迫你用更安全的方式修改它。**
## 淺拷貝(Shallow Copy)還是手動深層更新?
在 React 中,僅僅知道要「創建新物件」還不夠,更關鍵的是要理解:你更新的路徑上,每一層都必須是新的參考。 這就是不可變性 (Immutability) 的核心精神。
來看一個實務上常見的巢狀資料更新範例:
```ts
// ✅ 正確寫法:沿途建立新參考
select: (data) => {
return {
// 1. 建立新的頂層物件
...data,
// 2. 建立新的 data 陣列
data: data?.data?.map((item) => ({
// 3. 建立新的 item 物件
...item,
division: { label: item.divName, value: item.divNoReg },
})),
};
}
```
### 拆解分析:
這段程式碼完美地遵循了不可變性原則,讓我們一層層來看:
1. { ...data }:這是一個淺拷貝。它創建了一個全新的最外層物件。然而,在這一刻,它內部的 data.data 屬性仍然指向舊的陣列參考。
2. data.data.map(...):.map() 方法永遠會回傳一個全新的陣列。我們用這個新陣列覆蓋了 data 屬性,確保了第二層也是新的。
3. { ...item, ... }:在 map 的每一次迭代中,我們都用展開語法創建了一個全新的 item 物件,而不是去修改原始的 item。這保證了陣列中的每個元素也都是新的參考。
4.
結論:
因為從根物件到目標資料的每一層都換上了新的參考,React 的 Object.is 比較能夠輕鬆偵測到變化,從而正確地觸發重新渲染。
### 錯誤寫法:一個隱藏的陷阱
```ts
// ❌ 錯誤寫法:直接修改 (mutate) 了原始資料
data: data?.data?.map((item) => {
item.division = { label: item.divName, value: item.divNoReg }; // 危險!
return item;
});
```
這個寫法是個非常常見的陷阱。它之所以看似有效(觸發了重新渲染),純粹是因為 `.map()` 回傳了一個新陣列參考。但它同時也污染了原始的 state,犯了 React 的大忌:直接修改 (mutation)。
`item` 是 `data.data` 原始陣列中物件的直接參考。`item.division = ...` 這行程式碼像一把手術刀,直接劃開並修改了原始物件。這種做法會破壞 React 的單向資料流和可預測性,最終導致:
- 無法預期的 UI 行為:元件可能在不該更新時更新,或在該更新時沒反應。
- 效能優化機制失效:`React.memo`、`useMemo` 或 `shouldComponentUpdate` 都依賴乾淨的參考比較,直接修改會讓它們全部失靈。
-難以追蹤的 Bug:當 state 被四處意外修改,除錯將會變成一場惡夢。
## 總結:
1. **理解型別差異**
原始型別值會複製,參考型別只複製記憶體參考。
2. **Immutable 的精神**
改資料前,請先創造新的記憶體位置。
3. **React 重新渲染依據**
使用 `Object.is()` 比較 reference,只要記憶體位置沒改,就不會 re-render。
4. **警惕淺拷貝陷阱**
表面看似「改了資料」,實際卻沒產生新 reference,會讓 React 判斷失誤。
## 參考資料:
1. [Primitive vs Reference Data Types in JavaScript](https://www.freecodecamp.org/news/primitive-vs-reference-data-types-in-javascript/)
2. [Object.is()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)
3. [維持 React 資料流可靠性的核心關鍵:Immutable state](https://ithelp.ithome.com.tw/articles/10301603)
點擊複製文章連結