React 畫面更新的流程機制:reconciliation
1. React 畫面更新的流程機制:Reconciliation
在 React 中,當你的應用程式狀態(state)或屬性(props)發生變化時,React 會透過一個稱為 reconciliation(協調) 的過程來決定如何高效地更新畫面。Reconciliation 是 React 的核心機制,它負責比較「舊的虛擬 DOM(Virtual DOM)」和「新的虛擬 DOM」,找出需要更新的部分,並以最小的成本更新真實的 DOM。
React 的畫面更新流程可以分為兩個主要階段:
-
Render Phase(渲染階段)
-
Commit Phase(提交階段)
接下來,我會詳細說明這兩個階段,以及 reconciliation 在其中的角色。
2. Render Phase 與 Commit Phase
React 的畫面更新流程分為兩個階段:Render Phase 和 Commit Phase。這兩個階段共同完成從狀態改變到畫面更新的過程。
Render Phase(渲染階段)
Render Phase 是 React 執行「渲染」的階段,負責生成新的虛擬 DOM 並進行比較。在此階段,React 會:
-
觸發元件的 render 函數:
-
當某個元件的 state 或 props 改變時(例如透過 setState 或父元件傳遞新的 props),React 會重新執行該元件的 render 函數,生成新的虛擬 DOM 樹。
-
虛擬 DOM 是一個輕量級的 JavaScript 物件,代表真實 DOM 的結構。
-
-
執行 Reconciliation(協調):
-
React 會將新的虛擬 DOM 與之前的虛擬 DOM 進行比較,這個比較過程就是 reconciliation。
-
Reconciliation 會找出兩棵虛擬 DOM 樹之間的差異(diffing),例如哪些元素被新增、刪除或修改。
-
React 使用高效的 diffing 演算法,只比較同層級的節點,減少計算量。
-
-
決定需要更新的部分:
-
根據比較結果,React 會標記需要更新的元件和 DOM 節點。
-
在這個階段,React 不會直接操作真實 DOM,因此這個過程是高效且非同步的。
-
注意:
-
Render Phase 是純計算階段,不會產生副作用(side effect),例如不會直接改變瀏覽器的畫面。
-
如果元件使用了 React.memo 或 shouldComponentUpdate 等優化方式,React 可能會跳過某些子元件的重新渲染。
Commit Phase(提交階段)
Commit Phase 是 React 將變更應用到真實 DOM 的階段。在此階段,React 會:
-
更新真實 DOM:
-
React 根據 Render Phase 的比較結果,將必要的變更應用到真實的 DOM 上,例如新增、移除或修改 DOM 節點。
-
這是唯一會直接影響瀏覽器畫面的階段。
-
-
執行副作用(Side Effects):
- 在 Commit Phase 中,React 會觸發像 useEffect 或 componentDidMount、componentDidUpdate 等生命週期方法或 Hook,這些方法通常用來執行 DOM 操作、發送網路請求等副作用。
-
完成更新:
- 一旦真實 DOM 更新完成,畫面就會反映最新的狀態。
注意:
-
Commit Phase 通常很快,因為 React 已經在 Render Phase 中計算好最小的變更範圍。
-
Commit Phase 是同步的,確保畫面更新不會中斷。
3. Reconciliation(協調)
Reconciliation 是 React 高效更新畫面的核心機制。它的主要工作是比較新舊虛擬 DOM 樹,找出差異,並決定如何以最小的成本更新真實 DOM。以下是 Reconciliation 的運作細節:
-
虛擬 DOM 的作用:
-
虛擬 DOM 是 React 內部的資料結構,用來表示 UI 的狀態。
-
每次狀態改變時,React 會生成一棵新的虛擬 DOM 樹。
-
-
Diffing 演算法:
-
React 使用高效的比較演算法來找出新舊虛擬 DOM 的差異。
-
主要原則包括:
-
同層級比較:React 只比較同一層級的節點,不會跨層級比較。
-
不同類型節點:如果兩個節點的類型(例如 div 變成 span)不同,React 會直接重建該節點及其子樹。
-
key 屬性:在列表渲染時,React 使用 key 屬性來識別元素,減少不必要的重新渲染。
-
-
-
高效更新:
-
透過 diffing,React 會計算出最小的變更集合(例如只更新某個文字內容或新增一個節點)。
-
這些變更會在 Commit Phase 中應用到真實 DOM。
-
程式碼範例(展示 Reconciliation 的基本流程):
假設你有一個簡單的計數器元件,當點擊按鈕時,狀態改變並觸發畫面更新:
import React, { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // 觸發狀態更新
};
console.log("渲染 Counter 元件");
return (
<div>
<p>目前計數:{count}</p>
<button onClick={handleClick}>加 1</button>
</div>
);
}
export default Counter;
運作流程:
-
初始渲染:
-
React 執行 Counter 元件的 render 函數,生成虛擬 DOM(包含
<div>
、<p>
和<button>
)。 -
在 Commit Phase 中,React 將虛擬 DOM 轉換為真實 DOM,顯示在畫面上。
-
-
點擊按鈕:
-
呼叫 setCount,改變 count 的值。
-
Render Phase:
-
React 重新執行 Counter 的 render 函數,生成新的虛擬 DOM。
-
Reconciliation 比較新舊虛擬 DOM,發現只有
<p>
內的文字內容改變(從 目前計數:0 變成 目前計數:1)。
-
-
Commit Phase:
- React 只更新
<p>
節點的文字內容,不會重新創建<div>
或<button>
。
- React 只更新
-
-
畫面更新:
- 瀏覽器顯示新的計數值。
4. setState 觸發的 re-render 會連帶觸發子 component 的 re-render
當父元件的 state 改變時,React 會重新渲染父元件,並預設也會重新渲染所有的子元件,即使子元件的 props 沒有改變。這是因為 React 的預設行為是「當父元件重新渲染時,子元件也會跟著重新渲染」。
為什麼會這樣?
-
React 的 Reconciliation 過程會從父元件開始,逐層檢查所有子元件。
-
如果子元件的 props 或 state 沒有改變,React 仍然會執行子元件的 render 函數,生成新的虛擬 DOM,然後比較是否需要更新真實 DOM。
-
雖然這種重新渲染通常不會影響性能(因為虛擬 DOM 比較很快),但在大型應用程式中,過多的重新渲染可能導致性能問題。
程式碼範例(展示父子元件重新渲染):
以下是一個父元件 Parent 和子元件 Child,當父元件的狀態改變時,子元件也會重新渲染:
import React, { useState } from "react";
// 子元件
function Child({ name }) {
console.log(`渲染 Child 元件,name: ${name}`);
return <p>我是子元件,名稱:{name}</p>;
}
// 父元件
function Parent() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // 改變父元件狀態
};
console.log("渲染 Parent 元件");
return (
<div>
<p>父元件計數:{count}</p>
<button onClick={handleClick}>加 1</button>
<Child name="小明" /> {/* 子元件,props 不變 */}
</div>
);
}
export default Parent;
運作流程:
-
初始渲染:
-
React 渲染 Parent 元件,生成虛擬 DOM。
-
接著渲染 Child 元件,生成子元件的虛擬 DOM。
-
最終將完整的虛擬 DOM 轉為真實 DOM,顯示在畫面上。
-
-
點擊按鈕:
-
呼叫 setCount,改變 count 的值。
-
Render Phase:
-
React 重新執行 Parent 的 render 函數,生成新的虛擬 DOM。
-
因為 Parent 包含 Child,React 也會重新執行 Child 的 render 函數,即使 Child 的 name props 沒有改變。
-
Reconciliation 比較新舊虛擬 DOM,發現只有 Parent 的
<p>
文字內容改變(例如從 父元件計數:0 變成 父元件計數:1)。
-
-
Commit Phase:
- React 只更新
<p>
的文字內容,Child 的真實 DOM 不會改變(因為 name 沒變)。
- React 只更新
-
-
觀察結果:
- 在控制台中,你會看到 渲染 Parent 元件 和 渲染 Child 元件,name: 小明 都被印出,證明即使 Child 的 props 沒變,它仍然被重新渲染。
如何避免不必要的子元件重新渲染?
為了優化性能,你可以使用以下方法避免不必要的子元件重新渲染:
-
使用 React.memo:
-
React.memo 是一個高階元件(HOC),可以讓函數式元件只在 props 改變時重新渲染。
-
修改 Child 元件如下:
-
import React, { memo } from "react";
// 使用 React.memo 包裹子元件
const Child = memo(({ name }) => {
console.log(`渲染 Child 元件,name: ${name}`);
return <p>我是子元件,名稱:{name}</p>;
});
export default Child;
- 現在,當 Parent 的 count 改變時,Child 不會重新渲染,因為它的 name props 沒有改變。
-
使用 useMemo 或 useCallback:
-
如果父元件傳遞的是複雜的物件或函數作為 props,可以使用 useMemo 或 useCallback 確保這些 props 的引用不變。
-
範例:
-
import React, { useState, useCallback } from "react";
import { memo } from "react";
// 子元件
const Child = memo(({ name, onClick }) => {
console.log(`渲染 Child 元件,name: ${name}`);
return <p onClick={onClick}>我是子元件,名稱:{name}</p>;
});
// 父元件
function Parent() {
const [count, setCount] = useState(0);
// 使用 useCallback 確保函數引用不變
const handleChildClick = useCallback(() => {
console.log("子元件被點擊");
}, []);
const handleClick = () => {
setCount(count + 1);
};
console.log("渲染 Parent 元件");
return (
<div>
<p>父元件計數:{count}</p>
<button onClick={handleClick}>加 1</button>
<Child name="小明" onClick={handleChildClick} />
</div>
);
}
export default Parent;
- 在這個例子中,useCallback 確保 handleChildClick 的引用不變,結合 React.memo,Child 只有在 name 或 onClick 改變時才會重新渲染。
總結
-
Reconciliation 是 React 高效更新畫面的核心,透過比較新舊虛擬 DOM,找出最小的變更集合。
-
Render Phase 負責生成新的虛擬 DOM 並執行 diffing,不會直接影響真實 DOM。
-
Commit Phase 將變更應用到真實 DOM,並執行副作用。
-
setState 觸發的重新渲染 會導致父元件及其子元件重新執行 render,即使子元件的 props 沒變。
-
使用 React.memo、useMemo 或 useCallback 可以避免不必要的子元件重新渲染,提升性能。
實作建議
-
如果你正在開發 React 應用程式,建議在開發初期就考慮使用 React.memo 來優化子元件渲染。
-
使用 Chrome 的 React Developer Tools,可以檢視哪些元件被重新渲染,幫助你找出性能瓶頸。
-
當傳遞複雜的 props(如物件或函數)時,記得使用 useMemo 或 useCallback 來穩定引用。