# [Day 17] Immutable update 的 nested reference clone 誤解 ###### tags: `閱讀筆記` `iT 邦` `一次打破 React 常見的學習門檻與觀念誤解` ## immutable 改變的目的?! 因為 React 得知 `state` 改變後就會使用 `Object.is()` 比較新的 `state` 與上次 `state` 是否相同,如果不同才會重新叫用該 functional component 讓 UI 根據 `state` 更新。所以**避免更動原本的 `state` 且正確地的更新 `state` 供 React 比較新舊 `state`** 就是很重要的課題。 ![](https://hackmd.io/_uploads/HkB7yxNNn.png) (當 `setStateFunction` 被觸發後會大概進行的流程) ## 基本型別不用特別在意 在 JavaScript 中,除了物件型別外,其餘皆是基本型別(string, number, boolean, null, undefined and symbol)。基本型別是 immutable(保存在 `Call Stack`),所以變動值的話一定不會改變原本的存放的記憶體,**自然也不會更動到上次的 `state`**,因此在對待基本型別時並不用特別在意。 ## 物件型別需要注意 但是物件型別(object, array, function)就必須要注意(保存在 `Heap`),因為在 JavaScript 中這些是 mutable 的,因此操作不當是時就會改變原本的資料,也就是上次的 `state` 被更動成想要更新的樣子了,造成 React 認為 `state` 並無更新,就不會重新叫用 component 了。 ```javascript! // ... const App = () => { const [test, setTest] = useState({ x: 1 }); const handleChange = () => { test.y = 100 console.log(test) // { x: 1, y: 100 } // 物件型別是 mutable,即便我們新增 test.y,Object.is() 比對兩個 state 都是一樣的東西 setTest(test) } return ( <div> <p>test{JSON.stringify(test)}</p> <button onClick={handleChange}>Change</button> </div> ) } // ... ``` ## 單層物件 / 陣列可以安全地建立淺拷貝後更改 > 單層就是每個 element 都是基本型別的。 透過 ES6 spread operator 的語法,建立淺拷貝後再 immutable 改變,可以安全地改變資料: ```javascript! const originalArr = [1, 2, 3, 4] const shallowCopiedArr = [...originalArr, 5] console.log(shallowCopiedArr) // [1, 2, 3, 4, 5] console.log(Object.is(originalArr, shallowCopiedArr)) // false -> 所以 React 會叫用 component 更新 UI ``` ```javascript! const originalObj = { x: 1, y: 2 } const shallowCopiedObj = { ...originalObj, z: 100 } console.log(shallowCopiedObj) // { x: 1, y: 2, z: 100 } console.log(Object.is(originalObj, shallowCopiedObj)) // false -> 所以 React 會叫用 component 更新 UI ``` ## nested 物件 / 陣列需要注意前拷貝的層數 > 1. nested 就是每個 element 不是基本型別。 > 2. element(物件型別的話)也是擁有自己的 `Heap` 存放的地方。 > 3. 心法:Copying All Levels of Nested Data. (確實拷貝每一層 nested data) > 4. 淺拷貝只有一層的深度。 ### 確實拷貝每一層的範例 ```javascript! // ref. https://redux.js.org/usage/structuring-reducers/immutable-update-patterns function updateVeryNestedField(state, action) { return { // 拷貝既有的資料(外層) ...state, // 拷貝下一層 first: { // 拷貝下一層資料 ...state.first, // 再拷貝下一層 second: { // 拷貝下一層資料 ...state.first.second, // 再拷貝下一層 [action.someId]: { // 拷貝下一層資料 ...state.first.second[action.someId], // 實際要更新的資料... fourth: action.someValue } } } } } ``` ```javascript! const oldObj = { a: 1, b:2, c: { foo: 8, bar: 9 } }; const shallowCopiedObj = { // STEP 1: 先拷貝外層 ...oldObj, c: { // STEP 2: 要更動 c,因此再拷貝 c 的資料層 ...oldObj.c, foo: 100, bar: 200 }, // STEP 3: 在外層再新增 d d: 300 } console.log(oldObj) // { a: 1, b: 2, c: { foo: 8, bar: 9 }} console.log(shallowCopiedObj) // { a: 1, b: 2, c: { foo: 100, bar: 200 }, d: 300 } console.log(Object.is(oldObj.c, shallowCopiedObj.c)) // false -> 因為有再淺拷貝一層 Object.is(oldObj.a, shallowCopiedObj.a) // true -> 因為 property a 並未再被拷貝一層,所以會拿到相同的記憶體 ``` ### 新增 新增的話就比較單純,不會有更動原本資料的需求,可以直接拷貝後再新增就好。 以下的例子可以看出來淺拷貝只有一層深度的效果: ```javascript! const originalArr = [{ productId: "foo", quantity: 1 }, { productId: "bar", quantity: 2 }] // 使用 spread operator 淺拷貝既有 element 後再新增一個 element const shallowCopiedArr = [...originalArr, { productId: "zoo", quantity: 3 }] // 淺拷貝最外層的深度,也就是 [],所以使用 Object.is() 比對最外層會被判定為不同的東西 console.log(Object.is(originalArr, shallowCopiedArr)) // false // 但是內層的 element 其實還是指向相同的記憶體 console.log(Object.is(originalArr[0], shallowCopiedArr[0])) // true // 所以更改值的話,shallowCopiedArr 也是會被更改的 originalArr[0].quantity = 200 console.log(shallowCopiedArr[0]) // { productId: 'foo', quantity: 200 } ``` ### 編輯的話要怎麼處理? 編輯的話就需要特別注意,要避免更動到原本的資料。 以下的例子是錯誤的,因為會更動到原本的資料: ```javascript! const originalArr = [{ productId: "foo", quantity: 1 }, { productId: "bar", quantity: 2 }] // 使用 spread operator 淺拷貝既有 elements const shallowCopiedArr = [...originalArr] shallowCopiedArr[1].quantity = 100 console.log(originalArr[1]) // { productId: 'bar', quantity: 100 } console.log(Object.is(originalArr, shallowCopiedArr)) // false -> 淺拷貝只會有一層的效果,這裡比對的是最外層的 [] console.log(Object.is(originalArr[1], shallowCopiedArr[1])) // true -> nested elements 為物件型別的話還是會指向相同的記憶體 ``` 上方的例子在 React 中就會有問題,因為我們已經更改到歷史的 state 了: ```javascript! const App = () => { const [products, setProducts] = useState([{ productId: "foo", quantity: 1 }, { productId: "bar", quantity: 2 }]) const handleChangeProducts = () => { const copiedProducts = [...products] copiedProducts[1].quantity = 100 // 注意:這邊是錯誤的操作,因為是透過 render 才可以讀取當前 state,因為使用錯誤的操作造成當前 state 被更改了! console.log(products[1]) // { prodcutsId: 'bar', quantity: 100 } // 這邊還是會觸發 react re-render,因為 react 會使用 Object.is(products, copiedProducts),但淺拷貝確實開新的記憶體存放第一層 [],所以 Object.is(products, copiedProducts) 會得到 false setProducts(copiedProducts) } return ( ... ) } ``` 所以必須遵照淺拷貝的限制(只有第一層),必須拷貝每一層後再修改,這樣子才不會修改到原本的資料: ```javascript! const originalArr = [{ productId: "foo", quantity: 1 }, { productId: "bar", quantity: 2 }] // 使用 spread operator 淺拷貝既有 elements const shallowCopiedArr = [...originalArr] // 再用 spared operator 拷貝下一層(目標) const shallowCopiedEle = { ...originalArr[1], quantity: 100 } shallowCopiedArr[1] = shallowCopiedEle console.log(originalArr[1]) // { productId: 'bar', quantity: 2 } console.log(Object.is(originalArr, shallowCopiedArr)) // false -> 淺拷貝只會有一層的效果,這裡比對的是最外層的 [] console.log(Object.is(originalArr[1], shallowCopiedArr[1])) // false -> 開新 {} 後淺拷貝物件 properties ``` 再 React 中就會以下方的方式操作: ```javascript! const App = () => { const [products, setProducts] = useState([{ productId: "foo", quantity: 1 }, { productId: "bar", quantity: 2 }]) const handleChangeProducts = () => { const copiedProducts = [...products] const copiedProductItem = { ...products[1], quantity: 100 } copiedProducts[1] = copiedProductItem // 因為有再往下一層淺拷貝,因此不會改變到當前(歷史)state // 注意:這邊是錯誤的操作,因為是透過 render 才可以讀取當前 statcc console.log(pro // 淺拷貝第一層 [] 後塞入原本拷貝的資料 + 更動後的新資料 // 淺拷貝第一層 [] 後塞入原本拷貝的資料 + 更 } return ( ... ) } ``` 不過實務中一定不會這麼 hardcoded,一定會開 `parameter` 動態地接受需要改變的 `id` 或者 `index` 等,因此可以使用 `Array.map()` 遍歷每個 element + 取得回傳的 shallow copied element: ```javascript! const App = () => { const [products, setProducts] = useState([{ productId: "foo", quantity: 1 }, { productId: "bar", quantity: 2 }]) const handleChangeProducts = (targetIndex, updatedValue) => { // Array.map() 會遍歷每個 elements 並回傳每個 shallow copied element const updatedProducts = products.map((productItem, index) => { if (index === targetIndex) { // 符合條件就淺拷貝既有的 state 並透過 spread operator 合併物件達到更新 properties 的目標 return { ...productItem, ...updatedValue } } // 不符合條件就直接拿原本的記憶體 return productItem }) setProducts(updatedProducts) } return ( ... ) } ``` ### 刪除的話該怎麼處理? 刪除的話可以用 `Array.filter()` 直接排除不需要的 element,但要注意 `Array.filter()` 與 `Array.map()` 相同,是會回傳淺拷貝的 element: ```javascript! // ref. https://redux.js.org/usage/structuring-reducers/immutable-update-patterns function removeItem(array, action) { return array.filter((item, index) => index !== action.index) } ``` ### 插入的話要怎麼處理? 插入新的 element 可以透過 `Array.slice(startIndex, endIndex)` 處理: > `Array.slice(startIndex, endIndex)` -> 回傳從 `startIndex` 開始不包含 `endIndex` 淺拷貝 elements ```javascript! const App = () => { const [products, setProducts] = useState([{ productId: "foo", quantity: 1 }, { productId: "bar", quantity: 2 }]) const handleChangeProducts = (targetIndex, insertedItem) => { // 取得 0 -> 插入 index - 1 的 shallow copied elements // 插入 element // 取得 插入 index 之後的 elements // 合併! const updatedProducts = [ ...products.slice(0, targetIndex), insertedItem, ...products.slice(targetIndex) ] setProducts(updatedProducts) } return ( ... ) } ``` ## 其他常見錯誤 以下的錯誤參考至 [Redux - Immutable Update Patterns](https://redux.js.org/usage/structuring-reducers/immutable-update-patterns): ### New variables that point to the same objects 只是多宣告一個變數,但是其保存的記憶體是跟原本的 `state` 相同,因此一定會改變到原本的 `state`: ```javascript! function updateNestedState(state, action) { let nestedState = state.nestedState // ERROR: this directly modifies the existing object reference - don't do this! nestedState.nestedField = action.data return { ...state, nestedState } } ``` ### Only making a shallow copy of one level 因為淺拷貝只有拷貝一層,所以巢狀的資料其實還是指向同一個記憶體,因此還是會更新到原本的 `state`: ```javascript! function updateNestedState(state, action) { // Problem: this only does a shallow copy! // 只有拷貝最外層,其他內層的資料還是一樣的記憶體 let newState = { ...state } // ERROR: nestedState is still the same object! newState.nestedState.nestedField = action.data return newState } ``` ## 所以 immutable 是需要資料的「每一個角落」都需要與原本的資料不同嗎? 不用,如果要每一個資料都與原本的資料不同,就只能用「深拷貝」(在 JavaScript 中只有 `JSON.parse(JSON.stringify())` 達到深拷貝)。但是如果沒有更新的話其實就不需要再開新的記憶體保存,可以沿用舊有的資料。 回到開頭的流程圖: ![](https://hackmd.io/_uploads/HkB7yxNNn.png) ==保持 immutable update 是要讓 React 可以比對(透過 `Object.is()`)新的 `state` 及歷史(上一次 render 後的結果)後知道要不要再次 render 的手段,所以我們只要告知 React「哪裡」不同,React 發現不同後再 render 更新 UI,因此只要為「更新後」的資料開新記憶體,其他未更動的資料就保持原來的記憶體就可以了。== ## 文章開頭的程式碼問題 [It - React - [Day 17] Immutable update 的 nested reference clone 誤解](https://codesandbox.io/s/it-react-day-17-immutable-update-de-nested-reference-clone-wu-jie-k2o4js) ## Recap - 物件型別是 mutable,基礎型別的 immutable - immutable update 是避免直接更動歷史的 `state`,讓 React 不會在比對時發生 bug - immutable 不是要完全開新的記憶體,只要為更新的資料開新記憶體,其他未改變的就直接拿來用即可 - nested 物件的更新心法:完完全全拷貝每一層 - object -> property -> key + value -> value 的型別決定處理資料的複雜度 - 在 React 中,如果 `state` 為物件型別,變更時要先淺拷貝,而不是藉由物件 mutable 的特性直接修改物件 ## 參考資料 1. [[Day 17] Immutable update 的 nested reference clone 誤解](https://ithelp.ithome.com.tw/articles/10303033) 2. [Immutable Update Patterns](https://redux.js.org/usage/structuring-reducers/immutable-update-patterns) 3. [Pros and Cons of using immutability with React.js](https://reactkungfu.com/2015/08/pros-and-cons-of-using-immutability-with-react-js/)