紀錄工作經驗、相關知識,解決技術相關問題。

HTML, 網站優化

DOM 樹狀結構與渲染引擎揭秘:Reflow (重排) 與 Repaint (重繪) 的效能優化全指南

DOM 樹狀結構與渲染引擎揭秘:Reflow (重排) 與 Repaint (重繪) 的效能優化全指南 特色圖片

💡 文章重點摘要:在日常網頁開發中,我們經常聽到「不要頻繁操作 DOM」或「避免引發過度重排」。但究竟什麼是 DOM 樹狀結構?瀏覽器是如何將文字字串轉換為記憶體樹狀物件?而修改一個 CSS 寬度時,底層又歷經了哪些計算?本文將從 DOM 樹狀結構的誕生過程切入,貫穿瀏覽器關鍵渲染流水線(Critical Rendering Pipeline),帶你徹底釐清 Reflow 與 Repaint 的觸發邊界,並掌握現代前端必備的效能防坑實踐!

在前端開發的日常工作中,我們往往習慣了撰寫 HTML 標籤、綁定事件監聽器,或是透過 JavaScript 修改樣式。然而,當專案規模擴大、頁面節點超過數千個時,使用者在捲動列表或點擊按鈕時卻常出現明顯卡頓(Jank)甚至掉幀。許多工程師翻開 Chrome DevTools,看到滿滿的紅色警告寫著「Forced reflow」,卻不甚理解其物理成因。

要徹底解決這些效能瓶頸,我們不能只停留在「少改樣式」的表層口號,而必須回到源頭:搞懂瀏覽器是如何建構出 DOM 樹狀結構,以及這棵樹在後續渲染管線中如何與 CSS 協同工作。


⚡ 快速簡單使用(30 秒複製即用範本)

如果你正在排查畫面卡頓(Forced Reflow),想立刻將專案中「大量新增 DOM 節點」與「動畫位移」改為零重排的高效能寫法,可直接複製下方實戰範本:

// 1. 批次插入大量節點:使用 DocumentFragment 離線組裝,只觸發 1 次 Reflow
function appendItemsFast(container, items) {
  const fragment = document.createDocumentFragment();
  for (const text of items) {
    const li = document.createElement('li');
    li.textContent = text;
    fragment.appendChild(li); // 離線操作記憶體,不觸發畫面 Reflow
  }
  container.appendChild(fragment); // 一次性掛載上 DOM 樹狀結構
}

// 2. 讀寫分離防抖動:先統一讀取幾何屬性,再放入 requestAnimationFrame 批次寫入
function resizeBoxWithoutThrashing(boxEl) {
  const currentWidth = boxEl.offsetWidth; // 先讀 (Read)
  requestAnimationFrame(() => {
    // 後寫 (Write):優先使用 transform 走 GPU 合成層,完全避開 Reflow 與 Repaint
    boxEl.style.transform = `translateX(${currentWidth * 0.5}px)`;
  });
}

💡 3 秒修改重點:

  1. 迴圈內嚴禁直接 appendChild 到真實 DOM,一律先塞進 document.createDocumentFragment()。
  2. 嚴禁在迴圈中交替讀取 offsetWidth / getBoundingClientRect() 與修改 style,避免引發強制同步重排(Layout Thrashing)。

快速索引:Reflow (重排) vs Repaint (重繪) 核心對照表

在動手寫程式碼之前,我們先透過對照表釐清這兩者的運算開銷、觸發條件與硬體加速途徑:

指標 / 概念Reflow (重排 / 重新佈局)Repaint (重繪)
核心定義瀏覽器重新計算元素幾何尺寸、位置與全頁佈局結構元素外觀改變,但幾何尺寸與空間佔位未發生任何變更
運算成本極高(常伴隨父元素與相鄰節點的連鎖重算,易引發卡頓)中等(僅重新繪製視覺樣式像素,通常不波及鄰居)
典型觸發屬性width, height, margin, padding, fontSize, displaycolor, background, visibility, outline, boxShadow
連帶影響Reflow 必定引發後續的 Repaint(結構變了,外觀必須重畫)Repaint 不一定伴隨 Reflow(顏色改了,位置依然不變)
硬體加速途徑優先改用 transform 與 opacity 走合成層 (Composite)善用 will-change 或現代 CSS contain: paint 限制重繪範圍

從上表可以看出,Reflow 是效能的大魔王,也是造成頁面掉幀的主因。但要理解為何某些屬性會引發 Reflow,我們必須先回答那個最根本的提問:瀏覽器眼裡的「DOM 樹」到底是什麼?


一、 究竟什麼是 DOM 樹?從文字標籤到記憶體物件模型

我們在文字編輯器裡撰寫的 HTML 檔案,本質上只是一串「純文字位元組(Raw Bytes)」。網路線將這些字串送到使用者的電腦後,瀏覽器渲染引擎(如 Chromium 的 Blink 或 WebKit)是無法直接對一串字串進行樣式計算、位置測量與 JavaScript 互動的。

瀏覽器必須將這些平鋪直敘的文字,轉化為一種具有階層關聯、能在記憶體中被隨機存取的樹狀物件模型,這就是所謂的 DOM (Document Object Model,文件物件模型) 樹。

1.1 從 HTML 原始碼到 DOM 樹的 4 步蛻變流程

當瀏覽器從網路串流(Network Stream)接收到位元組後,會依序執行以下四個關鍵階段來建立 DOM 樹:

  1. 位元組轉字元 (Bytes to Characters):瀏覽器根據 HTTP 標頭或 <meta charset="UTF-8"> 的編碼聲明,將原始位元組轉換為人類可讀的字元文字。
  2. 詞法標記化 (Tokenization):詞法分析器(Lexer)循序解析字元,將特定語法拆解為獨立的標記(Tokens),例如 StartTag: html、StartTag: body、Character: Hello、EndTag: body。
  3. 節點化 (Node Construction):瀏覽器根據每一個 Token 的語意,在記憶體中建立對應的 C++ 節點物件(Node Instance),物件中封裝了該標籤的所有原生屬性、方法與事件監聽槽。
  4. 樹狀階層組裝 (DOM Tree Building):利用堆疊(Stack)演算法,在解析標籤開閉過程中建立節點的父子(Parent-Child)與兄弟(Sibling)指針關聯,最終形成以 document 為頂層根節點的雙向鏈結樹狀結構。
從 HTML 原始碼到 DOM 樹的 4 步蛻變流程 視覺化架構流程圖
圖:從 HTML 原始碼到 DOM 樹的 4 步蛻變流程 視覺化架構流程圖

1.2 直觀圖解:HTML 程式碼與 DOM 樹的映射關係

為了讓抽象的記憶體結構更具象化,我們來看一段簡單的網頁片段:

<!DOCTYPE html>
<html>
  <head>
    <title>技術筆記</title>
  </head>
  <body>
    <header>
      <h1>前端效能核心</h1>
    </header>
    <main>
      <p>深入理解 <strong>DOM 渲染管線</strong>。</p>
    </main>
  </body>
</html>

這段代碼經過瀏覽器解析後,在記憶體中建立的節點拓撲關係如下:

直觀圖解:HTML 程式碼與 DOM 樹的映射關係 視覺化架構流程圖
圖:直觀圖解:HTML 程式碼與 DOM 樹的映射關係 視覺化架構流程圖

💡 踩坑提醒:為什麼說操作 DOM 很昂貴?
很多人以為操作 DOM 昂貴是因為「記憶體寫入很慢」,這其實只說對了一小部分。在現代瀏覽器架構中,執行 JavaScript 的引擎(如 V8)與負責維護 DOM 樹及渲染畫面的引擎(如 Blink / WebKit)是兩個相互獨立的 C++ 模組。
當你在 JS 中呼叫 document.getElementById('my-el') 時,相當於在兩個模組之間進行了一次昂貴的跨橋接通訊(Bridge Crossing)。而比通訊更昂貴的,是這個節點變更後所引發的後續幾何佈局計算!


二、 瀏覽器關鍵渲染流水線:從 DOM + CSSOM 到螢幕像素

當 DOM 樹建構完畢後,它只具備結構資訊,並不包含顏色、字型或空間位置。瀏覽器還必須將它與樣式規則融合,這個完整的端到端處理流程,被稱為關鍵渲染路徑 (Critical Rendering Path)。

整個流水線包含五個環環相扣的核心階段:

瀏覽器關鍵渲染流水線:從 DOM + CSSOM 到螢幕像素 視覺化架構流程圖
圖:瀏覽器關鍵渲染流水線:從 DOM + CSSOM 到螢幕像素 視覺化架構流程圖

2.1 渲染樹 (Render Tree) 與 DOM 樹的關鍵區別

在第二階段,瀏覽器會將 DOM 樹與 CSSOM 樹合併為「渲染樹」。請注意:DOM 樹與渲染樹並非一一對應!

  • 不會進入渲染樹的節點:
    • 本身不具視覺表現的標籤,例如 <head>, <meta>, <script>, <link>。
    • 被樣式明確聲明為 display: none 的元素及其所有子節點(因為它們完全不佔據任何排版空間)。
  • 依然會進入渲染樹的節點:
    • 設定為 visibility: hidden 或 opacity: 0 的元素。雖然眼睛看不見,但它們依然會佔據畫面空間,因此瀏覽器仍必須為其計算幾何座標!

了解渲染樹之後,我們就能精確理解:所謂的 Reflow 與 Repaint,本質上就是渲染流水線在第 3 階段的重新運算行為。


三、 深入 Reflow (重排) 與 Repaint (重繪) 的本質差異

理解了渲染流水線的先後依賴性,我們就能透徹剖析這兩者的物理分水嶺:

3.1 什麼是 Reflow (重排 / 重新佈局)?

Reflow 是指瀏覽器必須重新計算渲染樹中節點的「幾何屬性(幾何尺寸、位置或頁面排版關係)」。

之所以叫「重排」,是因為 HTML 遵循流式佈局模型(Flow-based Layout)。在通常情況下,上層節點或前一個節點的寬高更動,會像骨牌效應一樣,導致後續所有相鄰節點、子節點乃至整張頁面的座標必須全部重新推算。

  • 例如:修改了網頁頂部 Banner 的高度,下方幾百個文章段落的垂直坐標(Top)全都要向下平移,這就是經典的全頁面連鎖重排!

3.2 什麼是 Repaint (重繪)?

Repaint 是指節點的幾何尺寸與空間位置完全沒變,僅有外觀視覺特性(如顏色、背景、陰影)發生了改變。

此時瀏覽器無需重新執行昂貴的幾何座標推算(跳過 Layout 階段),只需直接呼叫繪圖引擎重新光柵化像素(Paint 階段)即可。雖然它的開銷比 Reflow 輕,但如果在一秒鐘內頻繁重繪數百次,依然會造成 GPU/CPU 負載飆高。

3.3 鐵律:為什麼「Reflow 必定引發 Repaint,反之不然」?

從渲染流水線的依序單向流動即可看出原因:
$$\text{DOM + CSSOM} \longrightarrow \text{Render Tree} \longrightarrow \mathbf{Layout (Reflow)} \longrightarrow \mathbf{Paint (Repaint)} \longrightarrow \text{Composite}$$

當一個操作觸發了 Layout (Reflow),意味著元素的幾何邊界已發生位移或縮放;邊界一旦改變,舊位置的像素必須清除,新位置的像素必須重新著色,因此 Reflow 之後百分之百必須伴隨 Repaint。

反過來說,若僅僅改變 background-color,元素的寬、高、X、Y 坐標完全紋風不動,流水線便直接從 Paint 階段開始執行,完全不需要驚動前面的 Layout 計算。


四、 致命地雷:哪些寫法會觸發「強制同步佈局 (Layout Thrashing)」?

在日常 JavaScript 編程中,瀏覽器其實具備一套精密的優化機制:它會將樣式變更暫存於內部佇列(Queue)中,等待累積到一個微任務結束或下一次螢幕刷新(約 16.6ms)時才「批次執行」一次重排。

然而,許多工程師無意間撰寫的程式碼,會徹底打碎瀏覽器的批次防線,逼迫瀏覽器每毫秒都執行強制重排,這就是所謂的 佈局抖動 (Layout Thrashing)。

4.1 典型陷阱:在迴圈中「交替讀寫佈局屬性」

以下是生產環境中最惡名昭彰的效能殺手:

// ❌ 災難級實務:強制同步佈局 (Forced Synchronous Layout)
const listItems = document.querySelectorAll('.card-item');

for (let i = 0; i < listItems.length; i++) {
  // 1. 讀取操作 (Read):瀏覽器為了給你最即時的數值,被迫中斷批次快取,立即執行一次 Reflow!
  const currentHeight = listItems[i].offsetHeight;
  
  // 2. 寫入操作 (Write):將樣式改動標記為髒 (Dirty)
  listItems[i].style.height = (currentHeight + 10) + 'px';
  // 💥 迴圈 100 次,就逼迫瀏覽器在 1 個影格內硬生生執行了 100 次 Reflow + Repaint!
}
[執行結果與現象]
- CPU 佔用率直接飆至 100%
- DevTools 錄製結果:出現密密麻麻的紫色 Layout 與紅色三角警告標籤
- 畫面出現肉眼可見的頓挫與卡死 (Dropped Frames)

4.2 會引發強制同步清空佇列的常見屬性與 API

凡是詢問最新幾何測量結果的屬性,都會打破快取防線:

  1. 幾何邊界查詢:offsetWidth, offsetHeight, offsetTop, offsetLeft
  2. 滾動相關屬性:scrollTop, scrollLeft, scrollWidth, scrollHeight
  3. 視窗與客戶端屬性:clientTop, clientLeft, clientWidth, clientHeight
  4. 計算樣式 API:window.getComputedStyle(element)
  5. 位置幾何函式:element.getBoundingClientRect()

五、 生產環境效能優化 5 大最佳實踐

搞懂了底層機理,我們在日常寫 Code 時該如何避開地雷?以下整理五種在大型前端專案中歷經實戰驗證的高效解法:

5.1 善用 DocumentFragment 批次動態建構節點

當需要透過迴圈動態建立並插入大量節點(例如渲染長列表或留言清單)時,切勿逐筆 appendChild 進入實體 DOM 樹:

// 🟢 最佳實踐:在記憶體虛擬容器中組裝,最終一次性掛載至 DOM 樹
const container = document.getElementById('product-list');
const fragment = document.createDocumentFragment();

const productData = [ /* 100 筆商品資料 */ ];

productData.forEach(item => {
  const card = document.createElement('div');
  card.className = 'product-card';
  card.innerHTML = `<h3>${item.title}</h3><p>NT$ ${item.price}</p>`;
  // 先掛在離線的 DocumentFragment 上,不驚動實體 DOM
  fragment.appendChild(card);
});

// 關鍵動作:整批僅引發 1 次實體 DOM 重排!
container.appendChild(fragment);

5.2 徹底落實「讀寫分離 (Batch Reads & Writes)」

如果必須讀取幾何屬性並動態指派,請務必將所有讀取操作集中在前面,所有寫入操作統一排在後面,或透過 requestAnimationFrame 排入下一幀:

// 🟢 最佳實踐:讀寫徹底分離
const listItems = document.querySelectorAll('.card-item');
const heights = [];

// 第一步:統一進行所有唯讀測量 (只引發 1 次查詢讀取)
for (let i = 0; i < listItems.length; i++) {
  heights.push(listItems[i].offsetHeight);
}

// 第二步:統一透過 requestAnimationFrame 在下一幀批次寫入
requestAnimationFrame(() => {
  for (let i = 0; i < listItems.length; i++) {
    listItems[i].style.height = (heights[i] + 10) + 'px';
  }
});

5.3 活用現代 CSS:contain: layout paint 劃定重排隔離區

現代 CSS 提供了革命性的 Containment 規範。透過向獨立卡片或側邊欄宣告 contain,告訴瀏覽器:「這個容器內部的任何改動,絕對不會影響到容器外面的幾何佈局!」

/* 🟢 宣告佈局與繪製隔離:將局部改動鎖死在此容器內 */
.dashboard-widget-card {
  contain: layout paint;
  /* 瀏覽器計算此卡片內的幾何變化時,不會觸發外層整頁的連鎖重排! */
}

/* 現代神級屬性:視窗外的元件完全跳過渲染與佈局計算 */
.feed-stream-post {
  content-visibility: auto;
  contain-intrinsic-size: 0 450px; /* 提供預估佔位高度,防止捲軸跳動 */
}

5.4 涉及位移與動畫時,強制走 GPU 合成層 (Composite Layer)

在實作選單滑出、卡片放大或淡入淡出時,絕不要操作 top, left, margin, width,一律改用 CSS 變形屬性:

/* ❌ 壞實務:每一幀都觸發 Reflow + Repaint */
.modal-box {
  position: absolute;
  top: 50px;
  transition: top 0.3s ease;
}

/* 🟢 最佳實踐:完全跳過 Layout 與 Paint,直接由 GPU 合成層硬體加速處理 */
.modal-box {
  transform: translateY(50px);
  will-change: transform; /* 提前提示瀏覽器升級為獨立圖層 */
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

5.5 批次切換樣式優先使用 classList

避免透過 JavaScript 連續修改多行 .style.*:

// ❌ 壞實務:連續指派多行 inline style
element.style.width = '200px';
element.style.height = '150px';
element.style.padding = '12px';

// 🟢 最佳實踐:定義好 CSS Class,一次切換 ClassName
element.classList.add('is-expanded');

六、 Chrome DevTools 實戰排查:如何抓出隱藏的紅色警告?

要驗證自己的頁面是否擺脫了重排泥淖,最可靠的方法是利用瀏覽器內建的分析器親自驗證:

  1. 開啟 Chrome DevTools(快捷鍵 F12 或 Ctrl + Shift + I)。
  2. 切換至 Performance (效能) 面板。
  3. 勾選 Screenshots (螢幕截圖),點擊左上角的「錄製 (Record)」圓點按鈕。
  4. 在頁面上執行你懷疑有卡頓的操作(例如快速滾動列表、點擊展開卡片或拖曳浮層)。
  5. 停止錄製後,在 Main (主線條) 下方觀察火焰圖:
    • 尋找標記為紫色長條的「Layout」或橘色長條的「Recalculate Style」。
    • 若紫色區塊右上角出現紅色三角形標籤,點擊該區塊查看下方 Summary,若標註有 Forced reflow is a likely performance bottleneck,即可在 Call Stack 中精準定位到引發問題的 JS 程式碼行數!

實務總結:資深工程師的渲染心法

  1. DOM 樹是記憶體中的 C++ 物件層級映射:HTML 文字必須歷經「位元組 ➔ 字元 ➔ Token ➔ Node ➔ DOM 樹」的嚴謹構建,每一次跨 JS/DOM 存取都有基本開銷。
  2. 謹記渲染管線流水線:DOM + CSSOM ➔ Render Tree ➔ Reflow ➔ Repaint ➔ Composite。Reflow 必定引發 Repaint,是掉幀的罪魁禍首。
  3. 嚴守讀寫分離準則:絕不在迴圈中交替讀取佈局屬性與修改樣式;善用 DocumentFragment 與 requestAnimationFrame。
  4. 擁抱現代 CSS 隔離:善用 contain: layout paint 與 content-visibility: auto,將繁重的計算任務交由現代瀏覽器底層優化!

參考資料與延伸閱讀

發表迴響