
瀏覽器渲染流程:從 DOM、CSSOM 到 Layout 與 Paint
透過前面的 HTML、CSS、Script 與 Event Loop,我們已經知道資源被發現、下載和執行的時機會彼此影響。當瀏覽器拿到這些內容後,Renderer Process 還要把它們一步步轉換成使用者看得到的畫面。粗略來說,流程會經過 DOM 建構、樣式計算、Layout、Paint、Rasterization 跟 Compositing;不過這些階段並不是每次更新都會全部重跑,實際執行時也可能彼此重疊。
這篇會先把重點放在 DOM、CSSOM、Layout 與 Paint,建立整體渲染流程的概念,再回頭看畫面更新時,哪些變動會觸發 Reflow、Repaint 或 Compositing。至於 Chromium 如何把 Layer 拆成 Tile、Rasterize 成 pixel 資源,再透過 Viz 顯示到螢幕,會留到下一篇 Chromium Compositing 的運作機制繼續深入。
DOM Tree
DOM Tree 並不是 HTML tag 本身,而是瀏覽器解析 HTML 後建立的文件結構。Renderer 收到 HTML bytes 之後,會把它們解碼成字元、轉換成 token,再建立 node 並組成 DOM Tree;JavaScript 也可以透過 DOM API 讀取或修改這份結構。

這個階段主要描述文件結構,不會把最終的 computed styles 直接存進 DOM。即使網頁沒有提供任何 CSS,瀏覽器後續仍會套用瀏覽器的預設樣式,所以並不是畫面完全沒有樣式喔!
CSSOM Tree
CSS 會經過解析並建立 CSSOM,但它的組成方式跟 DOM Tree 不完全一樣。CSSOM 保存 stylesheet 中的 rule,瀏覽器接著會比對 selector、處理 cascade 與 inheritance,替 DOM node 算出 computed style。

這張圖就在說 body 的 font-size 會被子元素繼承,而 p、span 與 img 也會套用各自符合的 CSS rule。到這裡可以先把 DOM Tree 和 CSSOM 理解成兩份不同的資料,再由樣式計算把元素與最終樣式連起來。
Render Tree
那在 DOM Tree 跟 CSSOM 都準備好之後,是不是就可以直接把兩棵 Tree 合起來了?其實這時候還要先注意 JavaScript,因為它可以修改 DOM,也可以調整 CSS。假如 HTML parser 遇到一般的 <script>,就可能先停下來執行 script,等執行完成後再繼續解析;不過 async、defer 和 module script 的執行時機又不太一樣,所以並不是所有 JavaScript 都會固定卡在這個位置。
簡單來說,我們真正要關心的不是 JavaScript 一定在哪一個階段執行,而是它有沒有改變 DOM 或 style。只要內容發生變化,瀏覽器就會重新判斷後面的 Layout、Paint 或 Compositing 要不要更新。
接著把 DOM 結構跟 computed styles 放在一起之後,就會得到許多教學中所說的 Render Tree。可以先把它想成一份「哪些內容需要參與畫面呈現」的整理結果,這樣後面的 Layout 才知道要替哪些內容計算尺寸和位置。
不過這邊要先補充一下:現代 Chromium 的文件不一定會把 Render Tree 當成一個固定名稱的單一資料結構,而是直接說明瀏覽器會根據 DOM 與 computed styles 建立 Layout Tree。所以這篇保留 Render Tree 這個名稱,主要是方便我們理解 HTML 結構跟 CSS 樣式是怎麼接在一起,不需要太糾結它在 Chromium 原始碼中是不是真的有一棵叫做 Render Tree 的 Tree。

從圖中可以看到,display: none 的 span 不會產生 Layout Box,也就不會進入後面的 Layout;但 visibility: hidden 只是把內容藏起來,原本占用的空間還是會保留。反過來說,帶有內容的 ::before、::after 雖然不在 DOM 裡,卻還是可能參與後續的 Layout。
所以 DOM 裡有沒有這個 node,跟它最後會不會占用畫面空間,其實是兩件不同的事。
Layout、Paint、Layer
那知道哪些內容要參與呈現之後,接下來就可以進入 Layout。Layout 要處理的事情很直接:算出每個元素到底多大、要放在哪裡,以及彼此該怎麼排列。以下面的 HTML 來說,瀏覽器會根據 Viewport、Box Model、字型與內容,算出 header、section 和 .modal 實際占用的空間。
不過這些元素不是各算各的。假如文字換行讓 section 變高,後面的內容位置也可能一起往下移動;也就是說,一個元素的 Layout 結果,很可能會影響其他元素。
<header>Header</header>
<main>
<section>文章內容</section>
<div class="modal">彈窗</div>
</main>完成 Layout 之後,Paint 會依照正確的繪製順序建立 display items,記錄背景、文字與其他內容要先畫誰、後畫誰。z-index 與 stacking context 影響的疊放順序,主要就是在這裡反映到繪製結果中。
接著 Blink 會把 Paint 結果整理成可交給 Compositor 的 Layer 與相關屬性。Layer 不等於 DOM element,也不只是用來決定誰蓋住誰;它是瀏覽器為了 Rasterization 與 Compositing 所建立的內部單位。以下面的概念圖來說,假設網頁被拆成四個 Layer:
- Layer 1:一般頁面內容
- Layer 2:固定在畫面上方的 header
- Layer 3:正在做動畫的卡片
- Layer 4:浮在最上面的 modal
這只是一個方便理解的例子,不代表只要使用 position: fixed、動畫或 modal 就一定會各自產生獨立 Layer。瀏覽器會依照內容、屬性與 Compositing Reasons 決定實際的 Layer 結構,再把這些 Layer 組成我們操作瀏覽器時看得到的畫面。
Rasterization 與 Compositing
Main Thread 完成 Paint 資訊與 Layer 後,會透過 Commit 把需要的資料交給 Compositor Thread。接著 Rasterization 會執行 Paint 留下的繪製指令,把內容轉成 bitmap 或 GPU texture 等 pixel 資源;Compositing 再決定這些資源要放在哪裡、以什麼順序疊合,最後組成使用者看到的畫面。
所以 Rasterization 跟 Compositing 是兩個不同的動作:Rasterization 負責「產生 pixel 資源」,Compositing 則負責「使用這些資源組出 Frame」。有些流程圖會把 Rasterization 一起畫在廣義的 Compositing 階段中,因為兩者都在準備最後要送出的畫面;但把它們分開理解,整個流程會清楚很多!
這篇先掌握兩者在整體渲染流程中的位置就好,接著回到日常開發中更常遇到的問題:當 CSS 發生變動時,瀏覽器究竟需要重新執行哪些階段?
如果要優化渲染流程的話,該怎麼做呢?
前面講的是畫面初次載入並渲染完成的過程,但網頁在互動或播放動畫時,也可能讓瀏覽器重新更新畫面。這時不一定會把整條 pipeline 全部重跑,而是看這次變動影響到哪個階段:有些會經過 Layout、Paint 與 Compositing,有些可以略過 Layout,還有一些只需要 Compositing。
這裡常聽到的 reflow,通常是在說重新執行 Layout;repaint 是重新執行 Paint;compositing 則是重新組合 Layer。它們不是三選一的獨立工作:如果變動影響 Layout,受影響的內容通常還要接著 Paint 與 Compositing;如果只影響 Paint,就能略過 Layout;只有 Compositor 能直接處理的更新,才有機會略過前面兩個階段。
所以優化時的方向是避免不必要的 Layout 與 Paint。尤其是每一幀都會執行的動畫,如果能走 Compositor-only 路徑,就能減少 Main Thread 的工作;但實際有沒有走到這條路徑,還是要以瀏覽器的執行結果為準。
Reflow
當 CSS 變動影響元素的尺寸、位置或排版關係時,通常就需要重新 Layout,也就是常說的 Reflow。例如改變 margin、padding、width、height,或是定位元素的 top、right、bottom、left。
影響範圍不一定是整個頁面,瀏覽器會盡量只更新受影響的部分;不過一個元素的尺寸或位置改變,仍可能連帶影響周圍甚至後續元素的 Layout。
Repaint
如果變動不影響元素的尺寸、位置或排版關係,卻改變了元素要怎麼被畫出來,通常可以略過 Layout,直接進入 Paint。例如修改 background-image、color 或 box-shadow。Paint 完成後,瀏覽器仍要把更新後的內容重新合成到畫面上。
Compositing
當更新只改變 Compositor 能處理的屬性,例如 transform 或 opacity,而且需要的 Layer 與 pixel 資源都已經準備好時,瀏覽器就有機會略過 Layout 與 Paint,直接重新組合 Frame。
這類更新可以由 Compositor Thread 處理,不必每一幀都回到 Main Thread,這樣就可以盡量降低 Main Thread 的負擔!
要怎麼看 CSS 屬性到底會觸發到什麼階段呢?
想先理解常見屬性的分類,可以瀏覽 CSS Triggers;不過網站也明確提醒目前的 browser data 已經過時,所以不要把它當成實際效能依據。比較可靠的方式,是用 Chrome DevTools 的 Performance 面板 錄製互動,再查看 Main track 裡有沒有出現 Recalculate Style、Layout、Paint 或 Composite Layers。
主原則還是一樣:製作高頻率動畫時,可以優先考慮 transform 或 opacity,再透過實際量測確認效果,而不是只看屬性名稱就直接下結論!
到這裡,我們已經能從 CSS 變動一路判斷它可能經過哪些渲染階段。接下來就可以進入下一篇 Chromium Compositing 的運作機制,看看 Compositor 如何把 pixel 資源組成 Frame,以及這套機制為什麼會影響動畫效能。