# [閱讀筆記] 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) ![截圖 2023-11-05 14.14.29.png](https://hackmd.io/_uploads/B1f0M3NQT.png) ### 從元件來模擬整個流程 ```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==) ![CleanShot 2023-11-05 at 14.45.41.gif](https://hackmd.io/_uploads/SkNrchE7p.gif) (可以發現只會更新 `<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 卻不會重新處理: ![CleanShot 2023-11-05 at 16.18.00.gif](https://hackmd.io/_uploads/By3RyRVXp.gif) (發現只有 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 ![CleanShot 2023-11-05 at 20.29.25.gif](https://hackmd.io/_uploads/S1K0q-SXp.gif) ## 可以更優雅地使用 `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。 ![CleanShot 2023-11-05 at 20.45.43.gif](https://hackmd.io/_uploads/H1E5CWHmT.gif) (可以發現整個區塊都背重新替換,以及 `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> ); } ``` ![CleanShot 2023-11-05 at 21.25.50.gif](https://hackmd.io/_uploads/BJngdMBX6.gif) (在未給 `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 // 還有其他內部屬性 } ] ``` ![CleanShot 2023-11-05 at 21.27.30.gif](https://hackmd.io/_uploads/S11F_MHm6.gif) (給 `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> ); } ``` ![CleanShot 2023-11-05 at 21.45.14.gif](https://hackmd.io/_uploads/S1gK3GHXT.gif) (可以發現即便在 `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 ![截圖 2022-10-06 22.06.28.png](https://hackmd.io/_uploads/ByabmXSma.png) 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)