JavaScript – 效能與最佳化
主題:重繪與重排(Repaint / Reflow)
簡介
在瀏覽器中,DOM、CSSOM 與 渲染樹 之間的互動決定了畫面最終呈現的樣子。當 JavaScript 改變了元素的樣式、結構或內容時,瀏覽器必須重新計算佈局與繪製畫面,這兩個步驟分別稱為 重排(Reflow) 與 重繪(Repaint)。
即使是微小的變動,若頻繁觸發重排或重繪,都會造成 CPU 與 GPU 的額外負擔,進而導致 UI 卡頓、滾動不順或電池耗電加劇。了解它們的運作原理、辨識高成本操作,才能在開發時主動避免效能瓶頸,提升使用者體驗。
下面的內容將從概念說明、常見陷阱、最佳實踐到實務案例,完整呈現 重繪 vs. 重排 的全貌,讓你在寫 JavaScript 時能更有「效能思維」。
核心概念
1. 渲染流程概覽
- DOM 建構:瀏覽器解析 HTML,產生 DOM 樹。
- CSSOM 建構:解析 CSS,產生 CSSOM 樹。
- 渲染樹 (Render Tree) 組合:將可見的 DOM 節點與 CSSOM 結合,形成渲染樹。
- 佈局 (Layout / Reflow):計算每個可見節點的幾何資訊(位置、尺寸)。
- 繪製 (Paint / Repaint):根據佈局結果,把像素填入畫面。
- 合成 (Composite):將不同層 (layer) 合併,送交 GPU 顯示。
重排 只發生在第 4 步,重繪 只發生在第 5 步。若變更僅影響顏色、透明度、陰影等「不改變幾何」的屬性,只會觸發 Repaint;若改變了尺寸、位置、字體大小等,則會同時觸發 Reflow(進而引發 Repaint)。
2. 何時會觸發 Reflow
| 觸發情境 | 會導致 Reflow 的屬性 | 說明 |
|---|---|---|
| DOM 結構變更 | appendChild、removeChild、innerHTML、outerHTML |
增減或重新排列節點必須重新計算佈局。 |
| 樣式變更 | width、height、margin、padding、border、font-size、display、position、top/left/right/bottom 等 |
改變盒模型或定位資訊會影響其他元素的位置。 |
| 瀏覽器尺寸變動 | 視口 (viewport) 大小改變、縮放 | 需要重新計算整個頁面的佈局。 |
| 查詢佈局資訊 | offsetWidth、offsetHeight、clientWidth、clientHeight、getBoundingClientRect() |
讀取佈局資訊時,瀏覽器會先確保佈局是最新的,若前面有未完成的變更,就會強制執行一次 Reflow。 |
3. Repaint 的範圍
- 顏色相關:
color、background-color、border-color、opacity、visibility(若仍可見) - 陰影與漸層:
box-shadow、text-shadow、background-image(若不改變尺寸) - 變形 (transform) 的部分:若
transform只改變 2D/3D 位置或旋轉,會在 GPU 層面完成,通常不會觸發 Reflow,只是 Repaint。
提示:把能在 GPU 上完成的動畫(例如
transform、opacity)盡量使用,能讓瀏覽器跳過 Reflow,直接在合成層 (composite layer) 處理,效能提升明顯。
4. 程式碼範例
下面示範幾個常見的操作,說明它們分別會產生 Reflow、Repaint 或兩者皆有。
範例 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),並盡量改用 transform、opacity。 |
過度使用 innerHTML |
替換整段 HTML 會銷毀舊的 DOM、重新建立,產生巨量 Reflow。 | 只更新必要的子節點,或使用 virtual DOM / 框架的 diff 演算法。 |
| 未使用硬體加速層 | 某些 CSS 效果(如陰影、過濾)在軟體渲染下會造成大量 Repaint。 | 為需要的元素加上 will-change: transform, opacity; 或 translateZ(0),讓瀏覽器提前建立合成層。 |
| 忽略瀏覽器的快取與合成 | 重複觸發相同的動畫或樣式變更,會讓瀏覽器每次都重新繪製。 | 盡量使用 CSS 動畫,讓瀏覽器自行優化合成與快取。 |
具體的最佳實踐
一次性批次更新:利用
DocumentFragment、cloneNode或框架的批次渲染機制,減少插入/刪除的次數。使用
requestAnimationFrame:把所有視覺更新集中在瀏覽器的繪製週期內,避免在非繪製幀中觸發 Reflow。function update() { // 所有寫入操作 element.style.width = '300px'; // 讀取操作放在下一個 RAF requestAnimationFrame(() => { console.log(element.offsetHeight); // 此時已完成 Reflow }); }把可動畫的屬性限制在
transform、opacity:這兩個屬性會在 GPU 合成層直接完成,避免佈局重新計算。使用
will-change:提前告訴瀏覽器哪個屬性即將變動,讓它提前建立合成層。.animating { will-change: transform, opacity; }避免在
scroll、resize事件中直接改變 Layout:改用position: sticky、fixed或 CSS 變形。
實際應用場景
1. 無限滾動列表(Infinite Scroll)
在實作長列表時,若每次滾動都 appendChild 新項目,會導致頻繁的 Reflow。最佳做法是:
- 預先建立虛擬容器(virtual list),只渲染螢幕內可見的項目。
- 使用
transform: translateY()控制滾動位置,讓瀏覽器只做 Repaint。
2. 動態表格排序
排序時,直接改變每列的 display 或 order 會觸發大量 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 時,從一開始就把效能納入考量,打造出既美觀又順暢的使用者介面。祝開發順利!