課程:JavaScript 與 React 底層原理 第 10 堂:React 核心設計哲學
32 Virtual DOM 的設計理由
在上一節中,我們拆解了 React Element 的結構,發現 Virtual DOM(虛擬 DOM)本質上只是幾個簡單的 JavaScript 物件,用來描述 UI 長什麼樣子。你可能會產生一個疑問:既然最終還是要顯示在畫面上,為什麼不直接操作真實的 DOM 節點就好?多建一層「虛擬」的層級,難道不會消耗更多記憶體嗎?
事實上,這正是 React 最具革命性的設計決策之一。這不單是為了「效能」,更是為了讓「宣告式 UI」能夠在現實的瀏覽器環境中高效運作。要理解為什麼我們需要 Virtual DOM,我們必須先回到瀏覽器的底層,看看「直接操作 DOM」究竟有多昂貴。
瀏覽器的昂貴代價:渲染流水線
在 JavaScript 中建立一個普通的物件(如 { type: 'div' })速度極快,因為這僅僅是在記憶體(Heap)中分配一小塊空間。然而,建立一個真實的 DOM 元素(如 document.createElement('div'))則完全是另一回事。
一個真實的 DOM 節點包含數百個屬性(如 style, offsetWidth, childNodes, event listeners 等),它是瀏覽器渲染引擎內部的複雜實體。當你修改 DOM 時,瀏覽器必須啟動一個稱為**渲染流水線(Rendering Pipeline)**的連鎖反應。
渲染流水線的五個階段
- JavaScript 執行:你的程式碼修改了某個 DOM 屬性。
- 樣式計算 (Style):瀏覽器重新計算哪些 CSS 規則套用在哪些元素上。
- 佈局 (Layout / Reflow):這是最昂貴的一步。瀏覽器計算每個元素在螢幕上的幾何位置(寬度、高度、座標)。因為一個元素的變動可能會影響到它的父元素、子元素甚至是兄弟元素,這通常會導致整棵樹的連鎖計算。
- 繪製 (Paint):將元素轉換成像素(填色、陰影、文字渲染)。
- 合成 (Composite):將不同的層(Layers)重疊在一起,最終顯示在螢幕上。
效能瓶頸:佈局抖動 (Layout Thrashing)
如果你在一段程式碼中頻繁地「讀取」再「寫入」DOM,就會觸發佈局抖動。
想像一下,你寫了一個迴圈:
// 命令式程式碼的常見陷阱
for (let i = 0; i < items.length; i++) {
const width = list.offsetWidth; // 讀取 (觸發強制的 Layout 計算)
const newItem = document.createElement('div');
newItem.style.width = (width + 10) + 'px'; // 寫入 (標記為需要重新 Layout)
list.appendChild(newItem);
}
在上面的例子中,瀏覽器為了給出正確的 offsetWidth,必須確保之前的變動都已經計算完畢。這迫使瀏覽器在每一輪迴圈中都執行一次昂貴的 Layout 運算,而不是等到最後再一次處理。這種頻繁的同步計算是導致網頁卡頓(Jank)的首要原因。
Virtual DOM 的出現,就是為了在開發者與這條昂貴的流水線之間建立一個緩衝區。
Virtual DOM 的緩衝機制:從「直接操作」到「批次更新」
Virtual DOM 的核心設計理念是:盡可能減少對真實 DOM 的操作次數,並確保這些操作是以最有效率的方式進行。
我們可以把這想像成「建築藍圖」與「實際工地」的關係。
如果你想改動大樓的設計,直接請工人拆牆、砌磚(操作真實 DOM)是非常昂貴且難以回頭的。聰明的作法是在辦公室裡修改藍圖(Virtual DOM)。你可以反覆修改、塗抹、重新設計,這些過程都在記憶體中完成,速度極快。等到最終設計稿定案了,你再一次性地把清單交給工人,讓他們用最少的工時完成改動。
批次更新 (Batching) 的魔法
React 利用 Virtual DOM 實作了批次更新 (Batching)。當你在 React 中呼叫 setState 時,React 並不會立刻去更改真實的 DOM。相反地,它會:
- 標記變更:將這個組件標記為「髒(Dirty)」。
- 產生新樹:在記憶體中快速建立一棵全新的 Virtual DOM 樹。
- 計算差異:將這棵新樹與舊的(上一次渲染的)Virtual DOM 樹進行比對。
- 一次性套用:在同步程式碼執行完畢後(通常是微任務階段),React 會彙整所有的差異,產生一份「最小化更新清單」,並在一次瀏覽器重繪循環中,將這些變更套用到真實 DOM。
這樣一來,即使你在一個事件處理函數中連續更新了 10 次狀態,React 也只會觸發一次真實 DOM 的渲染流水線,徹底避免了佈局抖動。
Diffing 演算法:在記憶體中尋找最短路徑
既然有了新舊兩棵 Virtual DOM 樹,React 就需要一套演算法來找出它們之間的差異。這個過程稱為 Reconciliation(協調),而核心的比對邏輯就是 Diffing 演算法。
如果使用傳統的樹狀結構比對演算法(如 Levenshtein distance),時間複雜度會高達 $O(n^3)$。這意味著如果你的頁面有 1000 個元素,比對一次可能需要十億次計算,這顯然是不可接受的。
React 的 Diffing 演算法基於兩個聰明的假設(Heuristics),將複雜度降低到了 $O(n)$:
- 不同類型的元素會產生不同的樹:如果
<div>變成了<span>,React 不會浪費時間比對子節點,而是直接拆掉舊的,換上新的。 - 開發者可以透過
**key**屬性來提示哪些子元素在不同的渲染中是穩定的:這讓 React 在處理列表(List)時,能精確地知道哪些項目只是移動了位置,而不需要重新建立。
預覽:Compute Diff 的過程
想像一下你的 Virtual DOM 樹發生了如下變化:
- 舊樹:
<button class="blue">點我</button> - 新樹:
<button class="red">點我</button>
React 的 Diffing 引擎會迅速發現:
- 標籤類型沒變(都是
button)。 - 內容沒變(都是「點我」)。
- 只有
**class**從**blue**變成了**red**。
於是,React 只會產生一條 DOM 操作指令:el.className = 'red'。這就是在記憶體中運算的威力——它極大地減少了跨越「JavaScript 世界」到「瀏覽器 DOM 世界」這道昂貴橋樑的次數。
跨越效能的迷思:Virtual DOM 的真正價值
這是一個面試中常被討論的問題:Virtual DOM 真的比直接操作 DOM 快嗎?
答案可能會讓你驚訝:不一定。
如果你是一個效能專家,針對特定場景手寫最優化的原生 JavaScript DOM 操作,你的程式碼理論上一定會比 React 快。因為 React 除了要執行你的邏輯,還需要額外花時間建立 Virtual DOM 樹並進行 Diffing 比對,這本身就是一種「開銷(Overhead)」。
那麼,既然它不一定比較快,為什麼我們還要用它?
1. 確保了效能的「下限」
Virtual DOM 的價值在於,它能確保即便是一個經驗不足的開發者,在不了解瀏覽器渲染原理的情況下,寫出的程式碼也能擁有「足夠好」的效能。它自動幫你處理了批次更新與最小化 DOM 操作,防止你寫出導致佈局抖動的糟糕程式碼。
2. 讓「宣告式 UI」成為可能
這是最重要的理由。如果沒有 Virtual DOM,要實作宣告式 UI(UI = f(state))會非常痛苦。
想像一下,如果你要用原生 JS 實作:當 count 變動時,更新畫面。
你得手動找到那個 span,更新它的 innerText。如果 count > 10 要多顯示一個警告標籤,你還得手動建立元素、插入 DOM。
有了 Virtual DOM,你只需要寫:
return (
<div>
<span>{count}</span>
{count > 10 && <Warning />}
</div>
);
你只需描述「不同狀態下的最終產物」,不需要關心「如何從狀態 A 轉換到狀態 B」。Virtual DOM 扮演了那個勤奮的翻譯官,負責把你的「意圖」轉譯成底層的「操作」。
3. 跨平台的能力(解耦)
因為 Virtual DOM 本質上只是普通的 JavaScript 物件,它與瀏覽器的真實 DOM 是解耦的。這意味著:
- 如果你把這棵「虛擬樹」交給瀏覽器渲染器,它就是 React DOM。
- 如果你把它交給行動裝置的原生渲染器,它就是 React Native(渲染成 iOS/Android 的原生元件)。
- 甚至可以交給伺服器渲染成字串,這就是 SSR (Server Side Rendering)。
Virtual DOM 提供了一層強大的抽象,讓 React 的開發模式能夠跨越不同的終端環境。
總結:為什麼 React 選擇了虛擬層?
React 之所以設計 Virtual DOM,並不是因為它在物理速度上絕對勝過真實 DOM,而是因為它解決了開發效率與運作效能之間的矛盾。
- 它解決了瀏覽器的效能瓶頸:透過批次更新與最小化 Patch,避免了昂貴的 Layout 與繪製次數。
- 它降低了開發心智負擔:開發者只需專注於數據邏輯(Data-driven),而不必在複雜的 DOM API 中掙扎。
- 它定義了現代前端的標準:將 UI 轉化為可計算、可預測的數據結構,為後續的 Concurrent Mode 與複雜的調度機制奠定了基礎。
理解了 Virtual DOM 是如何作為「緩衝與翻譯官」存在後,下一個關鍵問題是:React 怎麼知道什麼時候該啟動這個 Diffing 過程?這就涉及到數據是如何流動的。
數據流與更新的驅動力
在 React 的世界裡,並非所有的數據變動都會立即觸發 Virtual DOM 的重新產生。為了讓更新過程可預測且高效,React 引入了嚴格的單向資料流與不可變性(Immutability)原則。
在下一節中,我們將探討為什麼 React 強烈要求你「不要直接修改狀態」,以及這與 JavaScript 的參考型別(Reference Type)有什麼深層的關聯。這將幫助你理解,為什麼正確地使用展開運算子(Spread Operator)是寫好 React 的必修課。
關鍵 takeaway: Virtual DOM 是一個記憶體中的緩衝層。它透過將多次狀態變更合併為一次「批次更新」,並利用 Diffing 演算法計算「最小變更量」,從而保護瀏覽器不被頻繁的渲染流水線操作所拖垮。它最重要的價值在於:讓開發者能以宣告式的方式編寫 UI,同時享有自動化的效能優化。