# [閱讀筆記] React reconciliation: how it works and why should we care
###### tags `閱讀筆記`
<div style="display: flex; justify-content: flex-end">
> created: 2023/11/05
</div>
## Reconciliation 是什麼?
> 1. Reconciliation 就是當元件渲染後生成新的 Virtual DOM 與上一次渲染 aka 舊的 Virtual DOM 比對後,將不同之處 aka 需要更新的地方,更新至 Real DOM 的流程
> 2. 如何比對新舊 Virtual DOM 不同的地方?透過 diffing 演算法
> 3. 所以在 Reconciliation 中會執行 diffing 演算法,從這個演算法得知不同的地方後,React 會針對不同的地方(Vitrual DOM)更新至 Real DOM(react-dom 這個模組會更新 Real DOM)

### 從元件來模擬整個流程
```javascript=
function App() {
const [counter, setCounter] = useState(0);
const handleIncraseCounter = () => {
setCounter((prev) => prev + 1);
};
const handleDecreaseCounter = () => {
setCounter((prev) => prev - 1);
};
return (
<div className="App">
<button onClick={handleIncraseCounter}>Increse</button>
<span>{counter}</span>
<button onClick={handleDecreaseCounter}>Decrease</button>
</div>
);
}
```
當我透過按鈕觸發事件更新 `state` 後,頁面渲染新的 `counter` 值,會經歷下列的步驟:
1. 透過 setter 傳入新的 `state`,React 會透過 `Object.is()` 檢查新舊 `state` 是否不同:
- 若不同:元件會重新叫用(渲染),並執行 Reconciliation
- 若相同:元件並不會重新叫用(渲染),所以就會結束流程
2. 取得此次渲染的到的 Virtual DOM,執行 Reconciliation 比對新舊 Virtaul DOM 不同的地方
3. 取得 diffing 判斷不同的地方,透過 `react-dom` 更新必要的 Real DOM(==只會更新必要的部分,並不是重新 remove 後又重新 append==)

(可以發現只會更新 `<span></span>`,而不是整個 `<div class="App"></div>`)
## 元件渲染會得到 Virtual DOM,那麼 Virtual DOM 是什麼?
> Virtual DOM 是使用物件的方式建立的
以下列的元件:
```javascript=
const Input = ({ placeholder }) => {
return <input type="text" placeholder={placeholder} />
}
```
就會得到下列的 Virtual DOM 物件:
```javascript=
{
type: "input", // type of element that we need to render
props: {
placeholder: ....
}, // input's props like id or placeholder
... // bunch of other internal stuff
}
```
若是元件回傳許多個 elements:
```javascript=
const Input = ({ placeholder }) => {
const label = 'test'
const id = 'test-id'
return (
<div>
<label htmlFor={id}>{label}</label>
<input type="text" id={id} />
</div>
)
}
```
那麼 Virtual DOM 就會中就會有 `children`,且為陣列的形式保存:
```javascript=
{
type: 'div',
props: {
children: [
{
type: 'label',
... // other stuff
},
{
type: 'input',
props: {
placeholder: ''
}
... // other stuff
}
]
}
}
```
若是元件回傳另一個元件:
```javascript=
const Input = ({ placeholder }) => {
const label = 'test'
const id = 'test-id'
return (
<div>
<label htmlFor={id}>{label}</label>
<input type="text" id={id} />
</div>
)
}
const Component = () => {
return <Input />
}
```
那麼就會得到下列的 Virtual DOM(會將該元件 aka 函式放入 `type` 的屬):
```javascript=
{
type: Input, // reference to that Input function
... // other stuff
}
```
然後 React 會繼續執行該元件(函式),直到取得該函式完整的 Virtual DOM 結構:
```javascript=
{
type: 'div',
props: {
children: [
{
type: 'label',
... // other stuff
},
{
type: 'input',
props: {
placeholder: ''
}
... // other stuff
}
]
}
}
```
最後 react-dom 會拿 type 當作 HTML tag,再透過 `appendChild()` 新增至 Real DOM 上。
> 1. 若 type 為一般 string,那麼最後會以該 type 的名稱新增對應的 HTML 元素
> 2. 若 type 為元件 aka 函式,那麼 React 會叫用該函式,直到取得該函式最終全部的 Virtaul DOM 物件(當層數很多的時候 = Virtaul DOM 也隨之龐大)
## 因為 React 只會更新必要的部分,所以有時候會發現「懶惰的代價」
有一個簡單的表單,希望切換 checkbox 狀態(`isPrivate`)時,因為是不同的狀態,因此 input 需要重新處理:
```javascript=
const InputController = ({ id, placeholder }) => {
useEffect(() => {
console.log("input controller mounted");
}, []);
return (
<div>
<label htmlFor={id}>Input:</label>
<input id={id} placeholder={placeholder} type="text" />
</div>
);
};
const Form = () => {
const [isPrivate, setIsPrivate] = useState(false);
return (
<form>
<div>
<label htmlFor="private">
Private:
<input
type="checkbox"
id="private"
value={isPrivate}
onChange={() => setIsPrivate((prev) => !prev)}
/>
</label>
</div>
{isPrivate ? (
<InputController
id={"isPrivateInput"}
placeholder={"isPrivateInputPlaceholder"}
/>
) : (
<InputController
id={"isNotPrivateInput"}
placeholder={"isNotPrivateInputPlaceholder"}
/>
)}
</form>
);
};
```
但是當我切換 checkbox 時,input 卻不會重新處理:

(發現只有 attributes 會更換)
### 回到 Virtual DOM
當此元件渲染後,會得到下列的 Virtual DOM 物件:
```javascript=
// 省略外層 <form> 標籤等等其他階層,這裡只專注在根據 isPrivate 渲染的元件
/** 當 isPrivate = true */
{
type: InputController,
props: {
// 其餘屬性,包含 id, placeholder props...
}
}
/** 當 isPrivate = false */
{
type: InputController,
props: {
// 其餘屬性,包含 id, placeholder props...
}
}
```
### 執行 Reconciliation
進入 Reconciliation 後,結束 diff 的比對,發現兩次 Virtual DOM 的 `type` 未改變(因為來源是同一個 `InputController` 元件),只有其 `for`, `id` 及 `placeholder` 要改變,所以 react-dom 只會針對需要改變的東西改變,其餘皆保留。(這也是為什麼用 dev tool 只會看到 `<input />` 的屬性更換,以及 `InputController` 並沒有重新 mounted)
## 可以自行回傳 `null` 解決
> When a component returns null , **it tells React not to render anything in the DOM for that component**.
> 當一個元件回傳 `null`,就是告訴 React 該元件不會渲染任何東西至 Real DOM。
可以透過回傳 `null`,讓 React 知道此處 DOM 必須重新銷毀:
```javascript=
// ...
{isPrivate ? (
<InputController
id={"isPrivateInput"}
placeholder={"isPrivateInputPlaceholder"}
/>
) : null}
{!isPrivate ? (
<InputController
id={"isNotPrivateInput"}
placeholder={"isNotPrivateInputPlaceholder"}
/>
) : null}
```
這樣子的結構會得到下列的 Virtual DOM 物件:
```javascript=
// 省略外層 <form> 標籤等等其他階層,這裡只專注在根據 isPrivate 渲染的元件
/** 當 isPrivate = true */
[
{
type: InputController,
props: {
// 其餘屬性,包含 id, placeholder props...
}
},
null
]
/** 當 isPrivate = false */
[
null,
{
type: InputController,
props: {
// 其餘屬性,包含 id, placeholder props...
}
}
]
```
### 執行 Reconciliation
diff 發現兩次 Virtual DOM 有不同(因為有回傳 `null`,代表該 element 不需要東西,所以就會整個移除),從 dev tool 可以發現該區塊整個重新移除又插入,也可以發現 `InputController` 確實重新 unmounted -> mounted

## 可以更優雅地使用 `key` 讓供 React 認知目前是哪一個
透過 `key` 可以供 diff 更了解目前 Virtual DOM 哪裡不同(即便 `type` 是相同的,只要 `key` 不同就會被當作需要建立新的範疇):
```javascript=
{isPrivate ? (
<InputController
id={"isPrivateInput"}
placeholder={"isPrivateInputPlaceholder"}
key="is-private"
/>
) : (
<InputController
id={"isNotPrivateInput"}
placeholder={"isNotPrivateInputPlaceholder"}
key="is-not-private"
/>
)}
```
這樣子的元件會得到下列的 Virtual DOM:
```javascript=
// 省略外層 <form> 標籤等等其他階層,這裡只專注在根據 isPrivate 渲染的元件
/** 當 isPrivate = true */
{
type: InputController,
props: {
// 其餘屬性,包含 id, placeholder props...
},
key: 'is-private'
}
/** 當 isPrivate = false */
{
type: InputController,
props: {
// 其餘屬性,包含 id, placeholder props...
},
key: 'is-not-private'
}
```
### 執行 Reconciliation
因為 `key` 的幫忙,所以 diff 會發現兩個 `InputController` 是不同的,因此 react-dom 會針對該區塊(也就是 `InputController` 所回傳的 Virtual DOM)替換至 Real DOM。

(可以發現整個區塊都背重新替換,以及 `InputController` 會 unmounted -> mounted 以建立新的範疇 aka 實例)
> 所以可以透過 `key` 強迫 React re-mounted 一個元件。
## 動態的陣列也是需要 `key`
React 是透過 Reconciliation 比對需要更新的部分,而且只針對需要更新的部分更新 Real DOM(以節省效能)。所以在動態的陣列中需要多給 `key` 給 diff 演算法比對,要不然 React 會無法有效率地渲染動態列表的元件,有可能會發生 React 認為資料未有不同而不更新:
```javascript=
[
{
type: SomeComponent,
...
},
{
type: SomeComponent,
...
},
{
type: SomeComponent,
...
},
]
// 元件 state 更新觸發元件重新渲染(也許這邊需要更新其中一個 element),但因為沒給 key 所以進入 Reconciliation 會被認作兩次 Virtual DOM 都一樣,所以就不會更新 Real DOM
[
{
type: SomeComponent,
...
},
{
type: SomeComponent,
...
},
{
type: SomeComponent,
...
},
]
```
### 實際的元件範例
```javascript=
function App() {
const [isReverse, setIsReverse] = useState(false);
const [list, setList] = useState(
Array.from({ length: 3 }).map((_, index) => index + 1)
);
const dataList = isReverse ? list.slice().reverse() : list;
const handleAddItem = () => {
setList((prev) => [...prev, prev.length + 1]);
};
return (
<div className="App">
<button onClick={handleAddItem}>Add</button>
<label>
<input
type="checkbox"
value={isReverse}
onChange={() => setIsReverse((prev) => !prev)}
/>
Set list is reversed.
</label>
{dataList.map((i) => (
// 試著把 key 移除,輸入值後再切換 isReverse state,會發現已輸入的值並不會跟著交換位置,因為經過比對後 React 只會更新有需要的部分,這裡因為沒給 key,所以就是只針對每個 input 中的 id, for 及 placeholder 更新
<InputController id={i} placeholder={i} />
))}
</div>
);
}
```

(在未給 `key` 的情況下,已經輸入的資料並不會隨著 input 順序移動)
這個情況下 Reconciliation 可以想像成:
```javascript=
// 當 isReverse = true
[
{
type: InputController,
props: { ... } // id, placeholder
// 還有其他內部屬性
},
{
type: InputController,
props: { ... } // id, placeholder
// 還有其他內部屬性
},
{
type: InputController,
props: { ... } // id, placeholder
// 還有其他內部屬性
}
]
// 當 isReverse = false
[
{
type: InputController,
props: { ... } // id, placeholder
// 還有其他內部屬性
},
{
type: InputController,
props: { ... } // id, placeholder
// 還有其他內部屬性
},
{
type: InputController,
props: { ... } // id, placeholder
// 還有其他內部屬性
}
]
```

(給 `key` 的話就會正確地參照是否顛倒渲染)
這個情況下 Reconciliation 可以想像成:
```javascript=
/** 因為有給 key,所以 React 會一併將已填入的值隨著順序渲染 */
// 當 isReverse = true
[
{
type: InputController,
key: 3,
props: { ... } // id, placeholder
// 還有其他內部屬性
},
{
type: InputController,
key: 2,
props: { ... } // id, placeholder
// 還有其他內部屬性
},
{
type: InputController,
key: 1,
props: { ... } // id, placeholder
// 還有其他內部屬性
}
]
// 當 isReverse = false
[
{
type: InputController,
key: 1,
props: { ... } // id, placeholder
// 還有其他內部屬性
},
{
type: InputController,
key: 2,
props: { ... } // id, placeholder
// 還有其他內部屬性
},
{
type: InputController,
key: 3,
props: { ... } // id, placeholder
// 還有其他內部屬性
}
]
```
## 如果動態陣列跟靜態元素都放在 children 中,靜態元素會被當作動態渲染的一部分嗎?
```javascript=
// ref. https://www.developerway.com/posts/reconciliation-in-react#part2
const data = ['1', '2'];
const Component = () => {
return (
<>
{data.map((i) => <Input key={i} id={i} />)}
<!-- will this input re-mount if I add a new item in the array above? -->
<Input id="3" />
</>
)
}
```
會被認作成這樣子的 Virtual DOM 嗎:
```javascript=
// ref. https://www.developerway.com/posts/reconciliation-in-react#part2
[
{ type: Input, key: 1 }, // input from the array
{ type: Input, key: 2 }, // input from the array
{ type: Input }, // input after the array
]
```
答案是 NO,React 很聰明地:
```javascript=
[
[
{ type: Input, key: 1 }, // input from the array
{ type: Input, key: 2 }, // input from the array
]
{ type: Input }, // input after the array
]
```
## 為什麼不要在一個元件內定義另一個元件?
因為當父層元件 re-render(重新渲染)後,該子層元件每次都會 re-mounted(也就是 unmounted -> mounted)。
```javascript=
function App() {
const [isChecked, setIsChecked] = useState(false);
const Input = () => {
console.log("input render");
useEffect(() => {
// 當 App 因為各種不同的原因 re-render,那麼 <Input /> 元件就會重新 re-mounted
console.log("input is mounted");
}, []);
return <input type="text" />;
};
return (
<div className="App">
<label>
<input
type="checkbox"
value={isChecked}
onChange={() => setIsChecked((prev) => !prev)}
/>
Check me!
</label>
<Input />
</div>
);
}
```

(可以發現即便在 `useEffect` 給予 `[]`,但是當 `isChecked` 改變時還是會執行 effect,因為該元件一直被 re-mounted)
### 因為每次 re-render 時都是不同的 `Input`
元件 re-render 就是建立一個獨立的範疇,因此每次的 `Input` 元件都是不同的:
```javascript=
// 這些 Input 元件都是不同範疇,是不同的
{
type: Input,
....
}
{
type: Input,
...
}
{
type: Input,
...
}
```
就好比成這樣子:
```javascript=
const a = () => {}
const b = () => {}
Object.is(a, b) // false
```
## Recap

1. Reconciliation: 為「新舊 Virtual DOM 比對後,將差異的部分更新至 Real DOM」的這個流程
- 藉由 diff 演算法比對
- 是由 react-dom 更新 Real DOM
- 所以是 Virtual DOM 互相比對 -> 更新 Real DOM,不是 Virtal DOM 跟 Real DOM 比對
2. 為了效能,只會更新必要的,沒必要 React 不會整個元素刪掉重插
3. Virtual DOM 就是一個物件
4. 對於動態陣列來說,若沒給 `key` 的話會有機率在比對時認為每次 Virtual DOM 都是相同的(因為 `type: 的來源一定是同一個`),所以必須給 `key`
## 程式範例
1. [[Note] Reconciliation](https://codesandbox.io/s/note-reconciliation-z8xf4l)
2. [[Note] Reconciliation lazy bug](https://codesandbox.io/s/note-reconciliation-lazy-bug-psp5x5)
3. [[Note] Reconciliation lazy bug (resolved with returning null)](https://codesandbox.io/s/note-reconciliation-lazy-bug-resolved-with-returning-null-pzcm36)
4. [[Note] Reconciliation lazy bug (resolved with key)](https://codesandbox.io/s/note-reconciliation-lazy-bug-resolved-with-key-8gdmm7)
5. [[Note] Array with reconciliation](https://codesandbox.io/s/note-array-with-reconciliation-lydpjd)
6. [[Note] Create component in a component](https://codesandbox.io/s/note-create-component-in-a-component-64tdjy?file=/src/App.js)
## 參考資料
1. [React reconciliation: how it works and why should we care](https://www.developerway.com/posts/reconciliation-in-react)
2. [Youtube - React reconciliation: how it works and why should we care](https://www.youtube.com/watch?v=724nBX6jGRQ)
3. [\[Day 11\] React 畫面更新的核心機制(下):](https://ithelp.ithome.com.tw/articles/10298053)