瀏覽器渲染機制:從 DOM 樹到 Paint 的完全解剖
Web 瀏覽器是我們日常使用中最熟悉且最複雜的軟體之一。從輸入 URL 到頁面顯示在畫面上,其內部以毫秒為單位進行著龐大的運算與處理。這整個處理流程被稱為 渲染管線 (Rendering Pipeline) 或 關鍵渲染路徑 (Critical Rendering Path) 。
本文將徹底解剖瀏覽器(尤其是 Blink 和 WebKit 等現代渲染引擎)如何解析 HTML、CSS、JavaScript,並最終將其作為顯示器上的像素繪製(Paint)出來的完整機制。
1. 渲染管線全貌
首先,讓我們來了解渲染引擎處理的全貌。瀏覽器從網路接收資料到在畫面上繪製為止的主要步驟如下:
graph TD
A["HTML資料接收"] --> B("HTML解析器");
B --> C["DOM樹"];
D["CSS資料接收"] --> E("CSS解析器");
E --> F["CSSOM樹"];
C --> G("樣式計算");
F --> G;
G --> H["Render樹"];
H --> I("Layout / Reflow");
I --> J["佈局樹"];
J --> K("Paint");
K --> L("Composite / 合成");
L --> M["畫面顯示"];
處理步驟大致可分為以下階段:
- Parsing (解析) :解析 HTML 與 CSS,建構 DOM (Document Object Model) 與 CSSOM (CSS Object Model)。
- Style (樣式計算) :結合 DOM 與 CSSOM,計算套用於各節點的最終樣式。
- Layout (佈局 / 重排) :計算畫面上各元素的準確位置與大小(幾何資訊)。
- Paint (繪製) :產生將元素轉換為像素的繪製指令(Paint Records)並進行光柵化。
- Composite (合成) :將繪製好的多個圖層以正確的順序疊加,產生最終畫面。
接下來,我們將詳細了解每個步驟。
2. Parsing(解析):建構 DOM 樹與 CSSOM 樹
當瀏覽器從伺服器接收到位元組序列(HTML 資料)時,渲染引擎就會開始將其轉換為人類或程式能夠理解的資料結構。
2.1 HTML 的解析與 DOM 樹的建構
HTML 的解析是依照 W3C(現在是 WHATWG)定義的 HTML 解析演算法來進行的。這個過程可以分解為以下 4 個步驟:
- Conversion (轉換) :將從網路接收到的原始資料位元組序列,根據指定的字元編碼(如 UTF-8)轉換為個別的字元(Characters)。
- Tokenization (詞法解析) :將字串轉換為 W3C HTML5 標準中規定的各種「標記(Tokens)」。例如
<html>、<body>等開始標籤、結束標籤、屬性名稱與屬性值等。 - Lexing (語法解析) :將產生的標記轉換為具有屬性與規則的「物件(Nodes)」。
- DOM Tree Construction (樹狀建構) :將建立的物件,根據標籤的巢狀關係連結成樹狀資料結構。這就是 DOM (Document Object Model) 。
sequenceDiagram
participant Net as "網路"
participant Byte as "位元組序列"
participant Char as "字串"
participant Token as "標記"
participant Node as "節點"
participant DOM as "DOM樹"
Net->>Byte: "資料接收 (e.g. 3C 62 6F 64 79 3E)"
Byte->>Char: "編碼轉換 (e.g. \"<body>\")"
Char->>Token: "Tokenizer解析 (StartTag: body)"
Token->>Node: "Node物件產生 (HTMLBodyElement)"
Node->>DOM: "新增至樹"
DOM 樹完整地呈現了文件的結構與內容。然而,此時它並不具備「元素看起來如何」的資訊。
2.2 CSS 的解析與 CSSOM 樹的建構
當 HTML 解析器遇到 <link> 標籤或 <style> 標籤等與 CSS 相關的資訊時,就會開始 CSS 的解析流程。CSS 的解析也遵循與 HTML 非常相似的步驟,最終產生稱為 CSSOM (CSS Object Model) 的樹狀結構。
位元組序列 -> 字串 -> 標記 -> 節點 -> CSSOM
CSSOM 是保存 DOM 樹各個節點應如何套用樣式的結構。CSS 的特點之一是 層疊(Cascade) 。也就是說,對某個元素的樣式定義,可能會從父元素繼承,或者被特異度(Specificity)較高的規則覆蓋。因此,CSSOM 必然會成為樹狀結構。
$$ \text{Specificity}(S_1, S_2) = \begin{cases} S_1 & \text{if } S_1 > S_2 \\\\ S_2 & \text{otherwise} \end{cases} $$CSSOM 的建構會阻塞渲染
重要的一點是, CSS 的解析被視為阻塞渲染的資源 。 DOM 的建構可以不等待外部資源,採用增量(漸進式)方式進行,但瀏覽器必須等待 CSSOM 完全建構完成,才會繼續後續步驟(建構 Render 樹或畫面繪製)。
這是因為,如果以不完整的 CSSOM 開始繪製,每次樣式計算完畢都會導致畫面重新繪製,進而產生畫面閃爍(FOUC: Flash of Unstyled Content)。
2.3 JavaScript 造成的解析阻塞
如果 HTML 中包含 <script> 標籤,瀏覽器的行為會變得更加複雜。
當瀏覽器的解析器遇到 <script> 標籤時,會 暫停(阻塞) DOM 的建構。接著控制權會移交給 JavaScript 引擎,等待腳本下載、解析及執行完成。
為什麼呢?這是因為 JavaScript 可能會使用 document.write() 或 DOM API,來修改正在解析的 DOM 樹或 HTML 本身。
| |
defer 與 async 屬性
為避免這種渲染阻塞並提升效能, <script> 標籤提供了 defer 與 async 兩個屬性。
- async : 在背景非同步下載腳本。下載完成後,會暫停 HTML 解析並執行腳本。執行順序不保證(先下載完的先執行)。適合用於沒有依賴關係的分析腳本等。
- defer : 非同步下載腳本,但會將執行延遲到 HTML 解析完全結束後(DOMContentLoaded 事件觸發前) 。保證會依照 HTML 中的撰寫順序執行,適合依賴 DOM 的腳本。
gantt
title "腳本的載入與執行"
dateFormat s
axisFormat %s
section "一般腳本"
"HTML解析" :active, a1, 0, 2s
"JS下載" :crit, a2, 2s, 4s
"JS執行" :crit, a3, 4s, 6s
"HTML解析恢復" :active, a4, 6s, 8s
section "async屬性"
"HTML解析" :active, b1, 0, 5s
"JS下載" :crit, b2, 2s, 4s
"JS執行" :crit, b3, 5s, 7s
"HTML解析恢復" :active, b4, 7s, 9s
section "defer屬性"
"HTML解析" :active, c1, 0, 6s
"JS下載" :crit, c2, 1s, 4s
"JS執行" :crit, c3, 6s, 8s
(※實際的 async 會在下載完成後立刻執行,因此會中斷解析。)
3. Style(樣式計算):建構 Render 樹
當 DOM 樹和 CSSOM 樹都完成後,瀏覽器會將它們結合起來建構 Render 樹 (Render Tree) 或 樣式樹 (Style Tree) 。
在這個階段,會計算 DOM 樹中的各個節點套用了哪些 CSSOM 的樣式規則,並決定最終的計算後樣式(Computed Style)。
3.1 Render 樹包含與不包含的內容
Render 樹是一個包含 畫面上顯示的所有元素 之視覺資訊的樹狀結構。因此,它與 DOM 樹並非完全一對一對應。
- 不包含的內容 :
<head>、<meta>、<script>等隱藏元素。- CSS 設定為
display: none;的元素(及其子孫元素)。
- 包含的內容 :
- 顯示的 DOM 節點。
- 偽元素(
::before、::after等)。這些不存在於 DOM 中,但會被加入到 Render 樹中。 - 設定為
visibility: hidden;的元素。雖然看不見,但因為會佔用空間(影響佈局),所以會包含在 Render 樹中。
3.2 樣式計算的複雜度
決定元素該套用哪些 CSS 規則的過程,是非常耗費運算資源的。 瀏覽器在進行選擇器比對(Selector Matching)時,是採 由右至左(Right-to-Left) 的方式評估。
例如,假設有以下 CSS 規則:
| |
瀏覽器會先找出所有的 <p> 標籤(這也是最右邊的關鍵選擇器)。接著,往上追溯該 <p> 的父元素樹,檢查是否存在類別為 .item 的元素,然後檢查其父元素是否為 div ,再檢查更上一層的父元素是否為 .container 。
為什麼是由右至左呢?這是因為當 DOM 樹變得非常龐大時,如果由左至右搜尋,將會探索無數個「不符合的子孫元素」,導致效能大幅下降。由右至左搜尋可以更快縮小目標元素的範圍。
因此,像以下這樣過度詳細或冗長的選擇器,將會成為降低樣式計算效能的原因。
| |
4. Layout(佈局 / 重排):元素位置與大小計算
建構出 Render 樹(帶有樣式資訊的節點集合)後,接下來進入 Layout (佈局) 階段。在 WebKit 核心的瀏覽器中,這有時被稱為 Reflow (重排) 。
在這個階段,瀏覽器會以 Viewport(視窗的顯示區域)大小為基準,準確計算 Render 樹中的各個節點在畫面上 應該放置在哪裡 (Position) ,以及多大 (Size) 。
4.1 盒模型與流動佈局
瀏覽器佈局的基礎是 盒模型 (Box Model) 。所有的元素都會被計算為具有內容 (Content)、內距 (Padding)、邊框 (Border)、外距 (Margin) 的矩形盒子。
佈局的計算通常是從 Render 樹的根節點( <html> 元素,初始包含區塊)開始,遞迴地向下遍歷子元素。
- 由父到子 : 父盒子決定自身的寬度,並將可用的寬度告知子盒子。
- 由子到父 : 子盒子決定自身的高度(根據內容),並告知父盒子。父盒子根據子盒子高度的總和來決定自身的最終高度。
這種由上而下單次遍歷就能決定大部分佈局的機制被稱為 流動佈局 (Flow Layout) (※表格或 Flexbox/Grid 等有時需要更複雜的多次遍歷)。
4.2 全域佈局與增量佈局
佈局計算分為重新計算整個畫面的 全域佈局 ,以及只重新計算變更部分的 增量佈局 兩種。
- 全域佈局 : 當視窗大小改變(調整大小)、裝置方向改變、根元素的字體大小改變等情況發生時,就會重新計算整個 Render 樹的佈局。這是非常耗費成本的處理。
- 增量佈局 : 當 JavaScript 改變了部分元素的大小,或是新增/刪除 DOM 節點時,瀏覽器只會將該元素及其可能受到影響的元素(兄弟元素或父元素)標記為「Dirty (髒亂)」,並非同步地只針對該部分重新計算。這稱為 Dirty bit system 。
4.3 佈局抖動 (Layout Thrashing) 與效能
如果使用 JavaScript 改變 DOM 的樣式,並立刻讀取其計算結果(例如高度或寬度),為了確保取得最新資訊,瀏覽器必須將為了最佳化而延遲的佈局計算 強制且立即地 (Synchronous Layout) 執行。
如果在迴圈中連續進行這種操作,就被稱為 佈局抖動 (Layout Thrashing) ,會引起嚴重的效能問題,導致幀率大幅下降。
【引發佈局抖動的錯誤程式碼範例】
| |
【改善方案:分離讀取與寫入 (Batching)】
| |
現今,利用如 FastDOM 這類的函式庫,或是適當地使用 requestAnimationFrame 將 DOM 的讀寫進行批次處理,是相當普遍的手法。
5. Paint(繪製):產生像素
透過佈局階段,各個元素盒子的位置(X、Y 座標)與大小(寬、高)已經確定。然而,畫面上仍然沒有繪製任何東西。接下來進行的就是 Paint (繪製) 階段。
Paint 階段的目的是接收佈局樹(Layout Tree)作為輸入,建立如何將畫面上的像素塗色的步驟(Paint Records),並最終進行光柵化(Rasterization)。
5.1 繪製順序 (Stacking Context)
並不能單純地依照元素在 HTML 中撰寫的順序來繪製。CSS 具有 z-index 、絕對定位( position: absolute; )、不透明度( opacity )、3D 變形等屬性,這些都會影響元素重疊的順序(Z 軸的順序)。
管理這個機制的稱為 堆疊上下文 (Stacking Context: 重疊上下文) 。
瀏覽器會依照 CSS 2.1 規範中定義的嚴格繪製順序來產生繪製指令。一般區塊元素的繪製順序如下:
- background-color(背景顏色)
- background-image(背景圖片)
- border(邊框)
- children(子元素的繪製)
- outline(外框線)
5.2 Paint Records 與 Display List
在近期的現代瀏覽器(如 Chrome 的 Blink)中,Paint 階段並非直接將像素寫入記憶體,而是改為產生 Paint Records (繪製紀錄) 列表(Display List)的處理方式。
Paint Record 是如「在這個座標上,用這個顏色畫一個矩形」、「用指定的字體繪製這段文字」等具體的繪製指令列表。
| |
為什麼要製作成列表呢?這是因為與其每次有微小變更就重新繪製全部,不如保留繪製指令的列表,只針對有變更的部分更新並重新執行指令,這樣會更有效率。
5.3 光柵化 (Rasterization) 與多執行緒化
產生的 Paint Records(Display List)必須實際轉換為像素(點陣圖資料)。這個過程稱為 光柵化 (Rasterization) 。
每次捲動時重新對整個頁面進行光柵化是很沒效率的。因此,瀏覽器會將畫面分割為稱為 圖塊 (Tiles) 的多個小矩形區域(例如 256x256 像素等)來進行管理。
在現在的 Chrome 等瀏覽器中,光柵化並非在主執行緒(執行 JavaScript 或進行 Layout 的執行緒)上進行,而是在專用的 光柵化執行緒 (Rasterizer Threads) 中平行處理(Threaded Rasterization)。此外,許多光柵化工作會利用硬體加速,在 GPU 上高速執行。
6. Composite(合成):圖層疊加
當光柵化完成,各圖塊的像素資料產生後(通常作為紋理儲存在 GPU 記憶體中),就會進入最後一個步驟: Composite (合成) 階段。
在複雜的 Web 頁面中,可能會套用陰影的標頭、固定在最上層的對話方塊、會捲動的背景圖片等,元素會互相重疊。如果將這些元素全部塗在同一個畫布上,每次捲動或執行部分動畫時,就需要大範圍地重新繪製(Paint 與 Rasterization),導致效能下降。
因此,瀏覽器會將頁面分割為多個獨立的 圖層 (Graphics Layers) 來進行管理。
6.1 圖層化的機制
在瀏覽器內部,多個樹狀結構會進行轉換。
- DOM Tree
- Layout Tree (Render Tree) : 視覺元素的幾何資訊
- Paint Tree (Layer Tree) : 基於堆疊上下文等資訊的圖層階層結構
- Graphics Layer Tree : 實際在 GPU 進行合成的獨立圖層群
具有特定 CSS 屬性的元素,會被瀏覽器提升(Promote)為獨立的「Graphics Layer(圖形圖層)」。
產生圖層的主要條件(觸發器)如下:
- 3D 或透視變形(
transform: translateZ(0)、translate3d(...)) <video>或<canvas>元素- 透過 CSS 動畫或轉場,改變不透明度(
opacity)或變形(transform)的元素 - 指定了
will-change屬性的元素(例:will-change: transform;) - 基於重疊的考量,位在已經成為獨立圖層元素上方的元素
6.2 合成執行緒與硬體加速
圖層的合成會在與主執行緒獨立的專用執行緒—— 合成執行緒 (Compositor Thread) 中進行。
每個圖層被光柵化後的點陣圖紋理會傳送給 GPU。合成執行緒會將如「把圖層 A 放在 X 座標 100、Y 座標 200,並把圖層 B 以 0.5 的不透明度疊加在上面」等合成指令(Compositor Frame)傳送給 GPU。GPU 會非常高速地合成這些圖片,並將最終畫面輸出到顯示器。
獨立於主執行緒的捲動與動畫
合成執行緒獨立於主執行緒這件事,在效能上極為重要。
如果 JavaScript 執行時間過長導致主執行緒被阻塞(卡住),當使用者用滑鼠捲動時,合成執行緒只需要將已經在 GPU 上的圖層紋理稍微平移並合成即可。因此,即使在 JavaScript 負擔很重的頁面上,捲動本身仍然能保持流暢(Jank-free)。
最能發揮這點優勢的就是使用 transform 與 opacity 的動畫。
6.3 CSS Trigger:動畫效能最佳化
在 Web 效能最佳化中,最重要的概念之一就是 CSS Triggers 。 當透過 JavaScript 或 CSS 改變元素的樣式時,瀏覽器的渲染管線必須從哪個步驟重新執行(從 Layout 開始、從 Paint 開始,還是從 Composite 開始),取決於所改變的屬性。
- 觸發 Layout (Reflow) 的屬性
width、height、margin、padding、top、left、font-size等。- 因為幾何資訊改變了,所以會重新執行 Layout → Paint → Composite 所有管線。這非常耗費資源,不適合用於動畫。
- 觸發 Paint (Repaint) 的屬性
color、background-color、box-shadow等。- 元素的大小與位置沒有改變,但外觀改變了,因此會重新執行 Paint → Composite。雖然比 Layout 輕量,但因為發生像素的重新繪製,仍會造成負擔。
- 僅觸發 Composite 的屬性
transform(translate,scale,rotate)opacity- 這些屬性不會改變元素的幾何資訊或個別像素的顏色。元素作為獨立的圖層(紋理)已經存在於 GPU 中,所以瀏覽器只需要對 GPU 發出「平移紋理位置並合成(transform)」或「半透明合成(opacity)」的指令即可。由於可以完全跳過主執行緒的 Layout 與 Paint,這成了 實現 60fps 流暢動畫不可或缺 的手法。
graph LR
subgraph "變更所導致的管線重新執行範圍"
direction TB
A["Layout 觸發<br/>width, height 等"] --> L("Layout")
B["Paint 觸發<br/>color, bg-color 等"] --> P("Paint")
C["Composite 觸發<br/>transform, opacity"] --> Co("Composite")
L --> P
P --> Co
end
will-change 屬性的運用
will-change 是一個讓開發者預先向瀏覽器告知「這個元素的特定屬性將來會改變」的 CSS 屬性。
| |
當瀏覽器看到 will-change: transform 時,會在動畫開始「之前」將該元素提升為獨立圖層,並在 GPU 中準備好紋理。這樣可以避免在滑鼠懸停動畫開始的瞬間發生卡頓(由 Paint 引起的延遲)。
不過,建立圖層會消耗記憶體,如果對頁面內所有元素都指定 will-change ,反而會導致瀏覽器崩潰或效能下降。只在必要元素上適當使用是非常重要的。
7. 總結
我們探討了從瀏覽器接收 HTML 到在畫面上繪製像素為止的「從 DOM 樹到 Paint(以及 Composite)的完整機制」。
- Parsing : 解析 HTML/CSS,建構 DOM 與 CSSOM。JavaScript(特別是同步腳本)會阻塞此過程。
- Style : 結合 DOM 與 CSSOM,建構出擁有顯示元素及其樣式的 Render 樹。
- Layout : 計算畫面上各個元素的準確位置(座標)與大小。
- Paint : 建立繪製指令(Paint Records),並在專用執行緒中光柵化為像素。
- Composite : 在 GPU 上合成獨立圖層,並輸出最終畫面。
對前端開發者而言,深入理解這個機制不僅僅是知識的累積。
「為什麼用 width 做動畫會卡頓?」
「為什麼 script 標籤要放在 body 結尾標籤之前,或者要使用 defer ?」
「React 或 Vue 等虛擬 DOM 為什麼能高速運作?(= DOM 存取與 Layout/Paint 的批次化・最小化)」
所有這些問題的答案,都存在於這個渲染管線中。了解其運作機制,將能幫助你打造出效能更好、使用者體驗更佳的 Web 應用程式。
