React Reconciliation

一個找出新舊 Virtual DOM Tree 差異的演算法,來決定畫面要如何更新

Hannah Lin
Hannah Lin
11 min readJul 16, 2024

--

Reconciliation: 將新產生的 Virtual DOM Tree 並與舊的進行差異比較,再到真實 DOM Tree 更新

最近對 React 底層 re-render 機制有很大突破的認識,連以前想不透的 bug 都突然茅塞頓開想通了!本來標題為 Deep dive React re-render,但寫一寫發現篇幅實在太長,就決定把其中重要的概念 Reconciliation 拆出先發佈在這篇, 其他的則會等之後整理完再分享。

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/>
}
  1. setState 發生,觸發 re-render 並開始以下流程
  2. 再次 Call Parent 這個 function
  3. React Build a tree of Virtual DOM of <Child/> element
  4. 新舊 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)。

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',
}
};

首先會比較 inputnull,他們不一樣所以繼續比較 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" />}
</>
)
}

--

--