React Reconciliation
一個找出新舊 Virtual DOM Tree 差異的演算法,來決定畫面要如何更新
Deep dive React re-render系列文
1. React Reconciliation
2. Deep dive React re-render
最近對 React 底層 re-render 機制有很大突破的認識,連以前想不透的 bug 都突然茅塞頓開想通了!本來標題為 Deep dive React re-render,但寫一寫發現篇幅實在太長,就決定把其中重要的概念 Reconciliation 拆出先發佈在這篇, 其他步驟則會在 Deep dive React re-render 再分享。
1. Reconciliation?
2. Element
3. 新舊 Virtual DOM Tree diffing 比較
Reconciliation?
Reconciliation 就是一個演算法,找出新舊 Virtual DOM Tree 的差異
React 從呼叫 setState 到最後看到畫面真的發生改變,中間的流程如下
// 範例
const Parent = () => {
const [state, setState] = useState()
// ...skip some code here
return <Child/>
}
setState
發生,觸發 re-render 並開始以下流程- 再次 Call
Parent
這個 function - React Build a tree of Virtual DOM of
<Child/>
element - 新舊 Virtual DOM Tree diffing 比較 (Reconciliation):
// Before //After
const Parent = { const Parent = {
[type: Child] [type: Child]
} }
使用 shallow compareObject.is(<Child />, <Child/>)
比較結果為 false,且 type 相同所以會觸發Child
這個 component re-render
5. 更新到瀏覽器: 將 diffing 的差異結果移交 react-dom
,並更新到真實的 DOM Tree 中,以完成瀏覽器畫面的更新
第四點的步驟就是 Reconciliation,此篇會很詳細說明 React 是如何 diffing 的,這概念非常重要因為攸關寫法到底會不會 re-render、實際是更新哪裡,若觀念不清處很容易寫出效能不佳或是有 bug 的應用程式。
在開始之前我們先來科普一下 Element,因為 React 就是去比較前後 Virtual DOM tree 裡的 element 是否相同才決定如何更新瀏覽器畫面。
Element
也稱之為 Virtual DOM element,實際上只是一個描述真實 DOM element 預計要長什麼樣子的 object。
// Below is element, and it's also an object.
const b = <B />
// Below is componet, because it's return element
const a = () => <B/>
How to create element?
createElement()
:使用 React 提供的createElement()
- JSX: 使用 createElement() 的 syntax sugar JSX
// Using createElement
const Element = React.createElement(
'h1', // type
{className: 'greeting'}, // attributes
'Hello, World!' // children
);
// Using JSX
const element = (
<h1 className="greeting">
Hello, World!
</h1>
);
What’s inside the element object?
你可以 console.log
element,會發現它只是一個普通的 JavaScript object,用來描述對應於「真實 DOM」的節點資料與結構。
const element = {
type: 'h1',
props: {
className: 'greeting',
children: 'Hello, world!'
}
};
其中的 type 尤其是重要,因為他是決定要不要 re-render 的關鍵點,type 通常會有兩種
- type is function (eg.
type: Child
): it call the function and iterates over the tree until it eventually gets the entire tree of DOM nodes that are ready to shown - type is string (eg.
type: ‘h1’
): React will convert to actual DOM element directly
開發者只需要專心寫 Components,接下來 React 會自動進行渲染轉換成相對應的 DOM 再送到實際的瀏覽器畫出來
<h1 class="greeting">Hello, world!</h1>
React Element vs DOM element
- React Element: 在交給 React covert to DOM 之前就只是個簡單的 JavaScript 物件,用來讓開發者描述並定義,希望到時候產生的真實 DOM 會長成什麼樣子
- DOM element: 是一個真實的 DOM node,由 React 基於接收到的 React element 內容來幫你自動產生的對應結果
新舊 Virtual DOM Tree diffing 比較
將新的 Virtual DOM Tree 與前一次 render 的舊 Virtual DOM Tree,進行 diffing 比較
講完 element 再回到 Reconciliation 就容易多了,以下就是 React 如何 diffing 的圖
React 會把改動前後的 element 做一個 diff,
- 若完全一樣 (using
React.memo
) 那就不動作。 - 若不一樣就繼續比較 type 屬性,若一樣那就 re-render 只更新改動的資料; 若 type 不同就移除舊元件(unmount) 並新增一個全新元件 (mount)。
Note. Re-render 效能是大大高於 Re-mounting (需要重新 create component ) 的,但在實務上也是需要避免不必要的 re-render
re-mounting
直接來看一個簡單範例
const Parent = () => {
const [state, setState] = useState(false);
return (
<>
<button onClick={() => setState(!state)}>Click</button>
{state ? <input placeholder="a" /> : null}
</>
);
}
點 setState 後會觸發 re-render,然後 React 會 build 出新的 Virtual DOM tree 並找出改動前後的差異
// Before //After
const Parent = { const Parent = null
type: 'input',
props: {
placeholder: 'a',
}
};
首先會比較 input
跟 null
,他們不一樣所以繼續比較 type,結果也不一樣所以 unmount input
以及清除跟他相關的 state 等等。
no re-render ?
const Parent = () => {
const [state, setState] = useState(false);
return (
<>
<button onClick={() => setState(!state)}>Click</button>
<Child />
</>
);
}
乍看之下會覺得這是 no re-render,因為 <Child />
完全沒變? 實際上每一次重新執行 Parent 這個元件都會產生全新 <Child />
所以比較結果其實是 false 不相等
const a = () => {}
const b = () => {}
Object.is(a, b). // always is false
既然不相等就繼續比較 type,發現他們一樣所以 re-render
// Before // After
const Parent = { const Parent = {
type: Input, type: Input,
}; };
在這個範例其實 <Child />
是很不必要的,所以可以加上 React.memo
避免重新渲染,不過這不在此篇重點
How to fix the bug below?
const Parent = () => {
const [state, setState] = useState(false);
return (
<>
<button onClick={() => setState(!state)}>Click</button>
{state ? <input placeholder="a" /> : <input placeholder="b" />}
</>
);
}
若我要做一個功能,有兩個完全不一樣的 <input/>
,由 state 來決定哪個 <input/>
要出現,但我發現一個 bug,就是我先在 a <input/>
上打 “Hannah” 然後換到 b <input/>
,b <input/>
竟然也出現 “Hannah” ?! 這不是我想要的啊,畢竟他們是兩個完全不一樣的 <input/>
。
會發生此 bug 原因就是當 react diffing 時,前後 element 的 type 是相同的,所以觸發 re-render 並只更改有變動的部分 (placeholder),其他 state 或是相關全部保留,這就是為什麼 “Hannah” 還繼續存在原因
// Before //After
const Parent = { const Parent = {
type: 'input', type: 'input',
props: { props: {
placeholder: 'a', placeholder: 'b',
} }
}; };
要解決此原因,只需要改成以下
const Parent = () => {
const [state, setState] = useState(false);
return (
<>
{state ? <Input placeholder="a" /> : null}
{!state ? <Input placeholder="b" />: null}
</>
)
}
// Before //After
const Parent = [ const Parent = [
{type: null}, {type: Input},
{type: Input} {type: null}
]; ];
React 就會依照 children 順序一個一個比較,null !== Input 所以會 mount Input, Input!==null,所以會 unmount Input
另一種更簡單解法 add key
,原理就放在下一篇解釋啦
const Parent = () => {
const [state, setState] = useState(false);
return (
<>
{state ? <input key="a" placeholder="a" /> : <input key="b" placeholder="b" />}
</>
)
}