課程:JavaScript 與 React 底層原理 第 11 堂:Reconciliation 核心機制
35 Tree Diffing 的三個假設
在上一節中,我們宏觀地掃描了 Reconciliation(協調)的整體流程:從狀態更新觸發 Render Phase,到最後的 Commit Phase 將變更應用於真實 DOM。
但在這個過程中,有一個極其關鍵的技術細節:React 是如何「比對」兩棵複雜的虛擬 DOM 樹的?如果我們採用最暴力、最精確的數學演算法,React 可能會慢到讓瀏覽器當機。為了達成毫秒級的響應速度,React 團隊做出了一個大膽的決策:放棄尋求「絕對最優解」,轉而採用基於三個核心假設的「啟發式演算法(Heuristic Algorithm)」。
這一節,我們將拆解這三個假設,看看 React 是如何在性能與精確度之間取得完美的工程平衡。
從 O(n³) 到 O(n) 的大躍進
在深入假設之前,我們先來看一組驚人的數據。
在計算機科學中,將一棵樹轉換為另一棵樹的最少操作步數是一個經典問題。即使是目前已知最優秀的演算法(如 State-of-the-art 樹比對演算法),其時間複雜度也達到了 O(n³),其中 n 是樹中節點的數量。
O(n³) 代表什麼意思?
- 如果你的頁面有 100 個節點(這是一個非常小的元件),運算次數大約是 1,000,000 次。
- 如果你的頁面有 1,000 個節點(中型應用),運算次數會飆升到 1,000,000,000 次(十億次)。
現代顯示器的更新率通常是 60Hz,意味著每一幀只有 16.6 毫秒 的時間。如果 React 每一次 setState 都要執行十億次運算,頁面會直接卡死。
React 的解決方案是:既然全量比對太慢,那我們就「走捷徑」。透過三個基於 Web 開發實踐經驗的假設,React 將複雜度硬生生地降到了 O(n)。這意味著 1,000 個節點只需要約 1,000 次比對,這在毫秒間就能完成。
假設一:同層比較(Level-by-Level)
第一個核心假設是:Web 應用中,DOM 節點跨層級移動的情況非常罕見。
基於這個觀察,React 的 Diffing 演算法只會對兩棵樹中「相同層級」的節點進行比較。它會從根節點開始,一層一層向下遞迴。
運作機制
React 會比對父節點 A 的所有子節點,並與新樹中父節點 A的所有子節點進行對照。它不會試圖去問:「原本在第三層的某個按鈕,是不是被搬到了第五層的某個 Div 裡面?」
工程權衡:如果真的發生了跨層級移動呢?
假設我們有以下的結構變動:
// 變更前
<div>
<section>
<CustomComponent />
</section>
</div>
// 變更後(將 CustomComponent 移出 section)
<div>
<section></section>
<CustomComponent />
</div>
在這種情況下,React 的行為如下:
- 比對第一層(
<div>),發現沒變,繼續向下。 - 比對第二層,發現舊樹的
section裡面有一個CustomComponent,而新樹的section變空了。 - React 會直接**銷毀(Unmount)**舊的
CustomComponent。 - 接著,React 發現新樹的第二層多了一個
CustomComponent,於是它會**重新掛載(Mount)**一個全新的元件。
代價: React 不會執行「移動」操作,而是「刪除再重建」。這雖然在效能上比「移動」稍微昂貴一點點,但因為這種場景在實際開發中出現頻率極低,這種犧牲換取了整體 Diffing 演算法能以線性時間執行的巨大優勢。
假設二:不同類型的元素產生不同樹
第二個核心假設是:如果兩個元素的類型(Type)不同,那麼它們產生的子樹結構極大機率也是完全不同的。
這是一個非常大膽的預判。在數學上,兩個類型不同的節點(例如從 <div> 變成 <span>)下面可能仍有 90% 的結構是相同的,但 React 選擇不去比對那些細節。
運作機制
當 React 在比對過程中發現節點的 type 改變了:
- 立即停止比對: 不再向下追蹤任何子節點。
- 全數銷毀: 卸載(Unmount)舊的節點及其所有後代節點。這意味著所有子元件的狀態(State)都會遺失。
- 重建: 根據新的類型建立全新的 DOM 節點,並掛載全新的子樹。
實際範例
想像你有一個條件渲染:
{isLoggedIn ? (
<AdminDashboard>
<Sidebar />
<MainContent />
</AdminDashboard>
) : (
<UserProfile>
<Sidebar />
<MainContent />
</UserProfile>
)}
儘管 AdminDashboard 和 UserProfile 內部可能都包含了相同的 <Sidebar /> 和 <MainContent />,但因為父層的類型從 AdminDashboard 變成了 UserProfile,React 會直接銷毀整棵 AdminDashboard 樹,然後重新渲染整個 UserProfile 樹。
為什麼這樣設計? 因為在絕大多數的 UI 邏輯中,如果你把一個「導航列」換成了「側邊欄」,裡面的內容幾乎不可能一樣。如果 React 費力地去比對兩個完全不同類型的節點內容,通常只是在浪費 CPU。這種「一刀切」的做法,極大地簡化了演算法邏輯。
假設三:Key 作為穩定識別符
前兩個假設解決了樹的層級與類型比對,但還有一個效能黑洞:列表渲染(List Rendering)。
想像你有一個列表,現在要在清單的最前方插入一個新節點:
// 舊列表
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>
// 新列表(在開頭插入)
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>
如果沒有第三個假設,React 進行同層比對時會發生什麼?
- 比對第一個位置:發現從
Duke變成Connecticut-> 更新(或重建)。 - 比對第二個位置:發現從
Villanova變成Duke-> 更新(或重建)。 - 比對第三個位置:發現多了一個
Villanova-> 插入。
明明只是插入了一個節點,React 卻因為位置對不上,導致後方所有原本不需要變動的節點全部被重新渲染。這就是為什麼我們需要 key。
運作機制
key 是開發者提供給 React 的「提示」。它告訴 React:「這個節點雖然在陣列中的位置變了,但它的身分(Identity)沒變。」
當 React 發現子節點擁有 key 時,它不再單純按順序(Index)比對,而是會建立一個 Map,根據 key 來尋找新舊樹之間是否存在對應關係。
在上面的例子中,如果有了 key:
// 舊
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
// 新
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
React 透過 key 發現 2015 和 2016 只是移動了位置,結構完全沒變,因此它會保留這兩個節點的 DOM 和狀態,僅僅在 DOM 中執行一次 insertBefore,將 2014 插入到最前面。
重點提醒: 這也是為什麼我們強烈建議不要使用陣列的 index 作為 key。如果 index 改變了(例如在開頭插入),key 也會隨之改變,這會讓 React 的 key 比對機制完全失效,退化到最糟糕的逐個更新模式。
工程權衡(Trade-off)的啟示
React 的這三個假設,本質上是一種工程上的權衡:
- 放棄了絕對的最小變更: 在某些極端情況下(如跨層級移動),React 做出的變更比數學最優解多。
- 換取了極致的運行速度: O(n) 的複雜度確保了無論 UI 有多複雜,React 都能在 16ms 內算出差異。
作為開發者,理解這三個假設能幫助我們寫出更高效的 React 代碼。例如,如果你希望某個元件在狀態切換時能夠完全重置(例如切換不同的使用者表單),你可以刻意改變其 type 或更換其 key,主動觸發 React 的「重建」機制。
承上啟下
我們現在理解了 React Diffing 演算法的三個核心原則。正是因為這三個假設,React 才能高效地處理複雜的 UI 更新。
其中,「假設二:不同類型的元素產生不同樹」 對於元件的生命週期與狀態保留有著最直接的影響。在下一部分中,我們將更深入地探討 Element Type 比較規則 (7.3)。我們會看到,當 type 相同與不同時,React 分別會對 DOM 屬性、元件實例與子節點進行哪些精細的操作。這將幫助你徹底理解為什麼有時候你的元件狀態會莫名其妙地消失,或為什麼有些屬性沒有如預期般更新。