本文 AI 產出,尚未審核

JavaScript – 效能與最佳化

主題:重繪與重排(Repaint / Reflow)


簡介

在瀏覽器中,DOMCSSOM渲染樹 之間的互動決定了畫面最終呈現的樣子。當 JavaScript 改變了元素的樣式、結構或內容時,瀏覽器必須重新計算佈局與繪製畫面,這兩個步驟分別稱為 重排(Reflow)重繪(Repaint)

即使是微小的變動,若頻繁觸發重排或重繪,都會造成 CPUGPU 的額外負擔,進而導致 UI 卡頓、滾動不順或電池耗電加劇。了解它們的運作原理、辨識高成本操作,才能在開發時主動避免效能瓶頸,提升使用者體驗。

下面的內容將從概念說明、常見陷阱、最佳實踐到實務案例,完整呈現 重繪 vs. 重排 的全貌,讓你在寫 JavaScript 時能更有「效能思維」。


核心概念

1. 渲染流程概覽

  1. DOM 建構:瀏覽器解析 HTML,產生 DOM 樹。
  2. CSSOM 建構:解析 CSS,產生 CSSOM 樹。
  3. 渲染樹 (Render Tree) 組合:將可見的 DOM 節點與 CSSOM 結合,形成渲染樹。
  4. 佈局 (Layout / Reflow):計算每個可見節點的幾何資訊(位置、尺寸)。
  5. 繪製 (Paint / Repaint):根據佈局結果,把像素填入畫面。
  6. 合成 (Composite):將不同層 (layer) 合併,送交 GPU 顯示。

重排 只發生在第 4 步,重繪 只發生在第 5 步。若變更僅影響顏色、透明度、陰影等「不改變幾何」的屬性,只會觸發 Repaint;若改變了尺寸、位置、字體大小等,則會同時觸發 Reflow(進而引發 Repaint)。

2. 何時會觸發 Reflow

觸發情境 會導致 Reflow 的屬性 說明
DOM 結構變更 appendChildremoveChildinnerHTMLouterHTML 增減或重新排列節點必須重新計算佈局。
樣式變更 widthheightmarginpaddingborderfont-sizedisplaypositiontop/left/right/bottom 改變盒模型或定位資訊會影響其他元素的位置。
瀏覽器尺寸變動 視口 (viewport) 大小改變、縮放 需要重新計算整個頁面的佈局。
查詢佈局資訊 offsetWidthoffsetHeightclientWidthclientHeightgetBoundingClientRect() 讀取佈局資訊時,瀏覽器會先確保佈局是最新的,若前面有未完成的變更,就會強制執行一次 Reflow。

3. Repaint 的範圍

  • 顏色相關colorbackground-colorborder-coloropacityvisibility(若仍可見)
  • 陰影與漸層box-shadowtext-shadowbackground-image(若不改變尺寸)
  • 變形 (transform) 的部分:若 transform 只改變 2D/3D 位置或旋轉,會在 GPU 層面完成,通常不會觸發 Reflow,只是 Repaint。

提示:把能在 GPU 上完成的動畫(例如 transformopacity)盡量使用,能讓瀏覽器跳過 Reflow,直接在合成層 (composite layer) 處理,效能提升明顯。

4. 程式碼範例

下面示範幾個常見的操作,說明它們分別會產生 ReflowRepaint 或兩者皆有。

範例 1:改變文字顏色(僅 Repaint)

// 只改變文字顏色,不會觸發 Reflow
const title = document.querySelector('h1');
title.style.color = '#ff5722';   // ✅ 只會 Repaint

說明color 屬性不影響元素的尺寸或位置,瀏覽器只需要重新繪製像素。

範例 2:調整寬度(Reflow + Repaint)

// 改變寬度會觸發 Reflow,之後再 Repaint
const box = document.querySelector('.box');
box.style.width = '400px';   // ❌ 會產生 Reflow

說明:寬度屬於盒模型的一部份,改變後所有相鄰元素的布局都可能需要重新計算。

範例 3:使用 transform 進行平移(只 Repaint)

// 透過 transform 移動元素,僅在合成層完成
const moving = document.querySelector('.moving');
moving.style.transform = 'translateX(200px)'; // ✅ 只會 Repaint

說明transform 的平移是由 GPU 處理的,不會改變布局,只需重新繪製。

範例 4:大量讀取布局資訊(強制 Reflow)

// 讀取 offsetHeight 會迫使瀏覽器同步完成所有 pending 的變更
const items = document.querySelectorAll('.item');
items.forEach(item => {
  // 假設前面有改變樣式的程式碼,這裡會觸發同步 Reflow
  console.log(item.offsetHeight);
});

說明:在同一幀中同時 寫入 佈局相關樣式、讀取 佈局資訊,會產生 layout thrashing,嚴重降低效能。

範例 5:批次更新(使用 DocumentFragment)

// 使用 DocumentFragment 一次性插入多筆節點,減少 Reflow 次數
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
  const li = document.createElement('li');
  li.textContent = `項目 ${i + 1}`;
  fragment.appendChild(li);   // 只在 fragment 中暫存,不會觸發 Reflow
}
document.querySelector('ul').appendChild(fragment); // 只觸發一次 Reflow

說明:把多筆 DOM 操作集中在同一個插入點,可將多次 Reflow 合併為一次,大幅提升效能。


常見陷阱與最佳實踐

陷阱 為什麼會成效能問題 解決方案
頻繁讀寫佈局屬性(layout thrashing) 每次讀取 offset*getBoundingClientRect() 前若有未完成的樣式寫入,瀏覽器會立即執行 Reflow。 批次化(batch)寫入與讀取,或使用 requestAnimationFrame 將讀寫分離到不同的幀。
在滾動事件中直接操作 DOM 滾動觸發頻率極高(60fps),若每次都改變尺寸或插入元素,會導致大量 Reflow。 使用 節流 (throttle)防抖 (debounce),並盡量改用 transformopacity
過度使用 innerHTML 替換整段 HTML 會銷毀舊的 DOM、重新建立,產生巨量 Reflow。 只更新必要的子節點,或使用 virtual DOM / 框架的 diff 演算法
未使用硬體加速層 某些 CSS 效果(如陰影、過濾)在軟體渲染下會造成大量 Repaint。 為需要的元素加上 will-change: transform, opacity;translateZ(0),讓瀏覽器提前建立合成層。
忽略瀏覽器的快取與合成 重複觸發相同的動畫或樣式變更,會讓瀏覽器每次都重新繪製。 盡量使用 CSS 動畫,讓瀏覽器自行優化合成與快取。

具體的最佳實踐

  1. 一次性批次更新:利用 DocumentFragmentcloneNode 或框架的批次渲染機制,減少插入/刪除的次數。

  2. 使用 requestAnimationFrame:把所有視覺更新集中在瀏覽器的繪製週期內,避免在非繪製幀中觸發 Reflow。

    function update() {
      // 所有寫入操作
      element.style.width = '300px';
      // 讀取操作放在下一個 RAF
      requestAnimationFrame(() => {
        console.log(element.offsetHeight); // 此時已完成 Reflow
      });
    }
    
  3. 把可動畫的屬性限制在 transformopacity:這兩個屬性會在 GPU 合成層直接完成,避免佈局重新計算。

  4. 使用 will-change:提前告訴瀏覽器哪個屬性即將變動,讓它提前建立合成層。

    .animating {
      will-change: transform, opacity;
    }
    
  5. 避免在 scrollresize 事件中直接改變 Layout:改用 position: stickyfixed 或 CSS 變形。


實際應用場景

1. 無限滾動列表(Infinite Scroll)

在實作長列表時,若每次滾動都 appendChild 新項目,會導致頻繁的 Reflow。最佳做法是:

  • 預先建立虛擬容器(virtual list),只渲染螢幕內可見的項目。
  • 使用 transform: translateY() 控制滾動位置,讓瀏覽器只做 Repaint。

2. 動態表格排序

排序時,直接改變每列的 displayorder 會觸發大量 Reflow。改為:

  • 把資料搬到 JavaScript 陣列中排序,一次性 渲染排序後的結果(使用 innerHTML 只一次)。
  • 若需要動畫效果,使用 transform 讓每列平滑移動,而非改變 top/left

3. 圖片懶載入(Lazy Load)

圖片載入完成後,改變 src 會觸發 Reflow,尤其在網格布局(grid / flex)中。解決方案:

  • 先設定固定的寬高或使用 占位元素(placeholder),確保布局在圖片載入前已確定。
  • 圖片載入後只改變 opacity,配合 transition 完成淡入效果,避免重新計算布局。

總結

  • Reflow(重排)是最昂貴的渲染步驟,會重新計算所有元素的尺寸與位置;Repaint(重繪)則僅重新繪製像素,成本較低。
  • 任何會改變 盒模型、定位或 DOM 結構 的操作,都會觸發 Reflow;而僅改變 顏色、透明度、陰影、transform 的則只會 Repaint。
  • 避免頻繁的讀寫佈局屬性、使用批次更新、把動畫限制在 GPU 可加速的屬性,是降低 Reflow 次數與提升效能的關鍵。
  • 在實務開發中,透過 DocumentFragment、requestAnimationFrame、will-change 等技巧,可讓大型 UI(如無限滾動、表格排序、懶載入)保持流暢。

掌握了 重繪與重排 的本質與最佳化策略,你就能在寫 JavaScript 時,從一開始就把效能納入考量,打造出既美觀又順暢的使用者介面。祝開發順利!