# [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`** 就是很重要的課題。

(當 `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())` 達到深拷貝)。但是如果沒有更新的話其實就不需要再開新的記憶體保存,可以沿用舊有的資料。
回到開頭的流程圖:

==保持 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/)