WeHelp
為什麼 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)