跳至主要內容
Skip to content

瀏覽器渲染管線與 DOM 操作成本

我們常聽到一句話:「DOM 操作很貴。」這句話容易被誤解成「所有 DOM API 都很慢」。更精準的說法是:DOM 操作可能觸發瀏覽器重新計算樣式、佈局、繪製與合成;當操作很多、範圍很大,或讀寫交錯時,成本會明顯變高。

這個知識對理解 Vue Diff 很重要。Vue 不是因為 JavaScript 比 DOM 高貴,而是因為先在 JS 層比較 VNode,通常能減少真正昂貴的 DOM 建立、刪除、移動與 layout 成本。


一、 必考觀念

1. DOM 操作到底貴在哪?

JavaScript 改一個普通變數很便宜:

javascript
count = count + 1

這只是記憶體裡的值變了。

但改 DOM 可能會影響畫面:

javascript
element.textContent = 'Hello'
element.className = 'active'
list.appendChild(item)

瀏覽器可能因此需要重新計算:

  • 這個元素套用了哪些 CSS?
  • 元素尺寸是否改變?
  • 其他元素位置是否受影響?
  • 哪些像素需要重畫?
  • 哪些圖層需要重新合成?

所以 DOM 操作的成本,常常不只在 API 呼叫本身,而在它牽動的瀏覽器渲染管線。

2. 瀏覽器渲染管線

一個簡化版的瀏覽器渲染流程如下:

階段做什麼成本感受
JavaScript執行程式、修改 DOM 或樣式視程式量而定
Style計算元素套用哪些 CSS 規則DOM 大、選擇器複雜時變貴
Layout / Reflow計算元素大小與位置通常很貴,可能影響整個頁面
Paint把文字、顏色、陰影、邊框畫成像素視重畫區域與效果複雜度而定
Composite合成不同圖層到螢幕通常比 layout/paint 便宜,但也不是免費

面試時可以這樣說:

DOM 操作真正貴的地方,是它可能讓瀏覽器重新跑 Style、Layout、Paint、Composite。尤其 Layout 需要計算元素幾何位置,影響範圍可能會往父層或兄弟節點擴散。

3. Layout、Paint、Composite 差在哪?

不同 CSS 屬性造成的成本不一樣。

會觸發 Layout 的變更

這類變更會影響元素尺寸或位置:

css
width
height
padding
margin
border
display
position
top
left
font-size

例如:

javascript
box.style.width = '300px'

元素寬度改變,其他元素可能也要重新排版。

可能只觸發 Paint 的變更

這類變更不一定影響位置,但會改變像素外觀:

css
color
background
box-shadow
border-radius

例如:

javascript
box.style.backgroundColor = 'red'

通常不需要重新計算 layout,但需要重新繪製。

常見只走 Composite 的變更

這類變更常被用在動畫優化:

css
transform
opacity

例如:

javascript
box.style.transform = 'translateX(100px)'

transform 通常不會讓其他元素重新排版,所以比改 left 更適合動畫。

IMPORTANT

「只觸發 composite」不是絕對保證,仍會受到瀏覽器、圖層、元素樣式與硬體條件影響。但作為實務原則,動畫優先使用 transformopacity 是合理的。

4. 強制同步佈局是什麼?

瀏覽器通常會把多次 DOM 寫入延後批次處理,避免每改一次就立刻 layout。

但如果你在寫入後立刻讀取 layout 資訊,瀏覽器為了給你正確結果,可能被迫馬上刷新 layout。這叫 forced synchronous layout,也常被稱為 layout thrashing。

壞例子:

javascript
for (const item of items) {
  item.style.width = `${box.offsetWidth}px`
}

每一輪都讀 offsetWidth,又寫 style.width。如果前一次寫入讓 layout 失效,下一次讀取就可能強迫瀏覽器同步計算。

比較好的做法是先讀後寫:

javascript
const width = box.offsetWidth

for (const item of items) {
  item.style.width = `${width}px`
}

或把讀取與寫入拆成不同階段:

javascript
const widths = items.map(item => item.offsetWidth)

items.forEach((item, index) => {
  item.style.width = `${widths[index]}px`
})

核心原則是:避免在大量迴圈裡交錯讀 layout 與寫 DOM。


二、 追問題庫

Q1:所有 DOM 操作都很貴嗎?

不是。

單次小範圍 DOM 操作通常沒有問題。真正容易出事的是:

  • 在大量節點上頻繁操作 DOM。
  • 每次操作都影響 layout。
  • 在迴圈中讀寫交錯,造成強制同步佈局。
  • 一次更新牽動很大的 DOM 子樹。
  • 動畫每一幀都改會觸發 layout 的屬性。

所以更精準的回答是:DOM 操作不是不能做,而是要注意操作量、影響範圍與是否觸發渲染管線中的昂貴階段。

Q2:為什麼 Vue 要先做 VNode Diff?

因為 JS 物件比較通常比大量真實 DOM 操作便宜。

VNode Diff 讓 Vue 可以先算出:

  • 哪些節點可以復用。
  • 哪些文字或屬性真的變了。
  • 哪些節點需要新增或刪除。
  • 哪些列表節點需要移動。

最後只把必要變更 patch 到真實 DOM。

text
資料變化
-> 產生新 VNode
-> JS 層比較新舊 VNode
-> 最小化真實 DOM 操作

這就是為什麼理解 DOM 成本,能幫你理解 Vue Diff 的價值。

Q3:display: nonevisibility: hidden 成本一樣嗎?

不一樣。

寫法行為
display: none元素不佔據 layout 空間,切換時通常會觸發 layout
visibility: hidden元素仍佔據空間,只是不可見
opacity: 0元素仍佔據空間,也仍可能接收事件,通常較適合做淡入淡出

所以 Vue 裡 v-ifv-show 也有類似取捨:

  • v-if 是建立與銷毀 DOM。
  • v-show 是切換 display

如果元素頻繁切換,v-show 通常更適合;如果元素很少出現,v-if 可以避免一開始就建立它。

Q4:動畫為什麼推薦用 transform,不推薦用 top/left

因為 top/left 會改變元素幾何位置,通常會觸發 layout。transform 比較常在 compositor 層處理,不需要重新排版其他元素。

不推薦:

css
.box {
  left: 100px;
}

推薦:

css
.box {
  transform: translateX(100px);
}

這也是為什麼很多前端效能建議會說:動畫優先使用 transformopacity

Q5:DocumentFragment 為什麼可以改善大量插入?

DocumentFragment 是一個離線容器。你可以先把很多節點加到 fragment 裡,再一次插入 DOM。

javascript
const fragment = document.createDocumentFragment()

for (const item of list) {
  const li = document.createElement('li')
  li.textContent = item.name
  fragment.appendChild(li)
}

container.appendChild(fragment)

這樣可以減少多次插入真實 DOM 造成的中間狀態與潛在渲染成本。

Q6:怎麼判斷頁面卡頓是不是 DOM 造成的?

可以用瀏覽器 DevTools 的 Performance 面板觀察:

  • Long Task 是否很多。
  • Recalculate Style 是否很重。
  • Layout 是否頻繁或耗時。
  • Paint 區域是否很大。
  • FPS 是否掉幀。

如果卡頓發生在大量列表更新、拖拉、動畫、視窗 resize、滾動同步這些場景,就很值得往 DOM 與渲染管線查。


三、 實作題

題目:優化讀寫交錯造成的 layout thrashing

假設有一段程式碼:

javascript
function syncWidths(source, targets) {
  for (const target of targets) {
    target.style.width = `${source.offsetWidth}px`
  }
}

問題是:每一輪迴圈都讀 source.offsetWidth,又寫 target 的 style。請改寫它。

比較好的寫法:

javascript
function syncWidths(source, targets) {
  const width = source.offsetWidth

  for (const target of targets) {
    target.style.width = `${width}px`
  }
}

如果每個 target 都要依照自己的寬度計算,也可以先讀完再寫:

javascript
function doubleWidths(targets) {
  const widths = targets.map(target => target.offsetWidth)

  targets.forEach((target, index) => {
    target.style.width = `${widths[index] * 2}px`
  })
}

面試回答重點:

  • 先讀 layout 資訊。
  • 再集中寫 DOM。
  • 避免讀寫交錯讓瀏覽器反覆同步 layout。

四、 資深視角

1. 不要把「避免 DOM 操作」理解成「永遠不碰 DOM」

前端工程師一定會碰 DOM。像是:

  • focus 控制。
  • 滾動位置管理。
  • 元素尺寸測量。
  • 第三方圖表或地圖套件整合。
  • 動畫與拖拉互動。

重點不是禁止 DOM,而是要知道哪些操作會讓瀏覽器付出什麼成本。

2. Vue 效能優化要先看資料更新範圍

如果 Vue 頁面卡頓,不要第一時間就怪 Diff。

比較合理的排查順序:

  1. 是不是一次渲染太多節點?
  2. key 是否穩定?
  3. 是否有大物件深層響應式造成過度追蹤?
  4. 是否有 computed/watch 做了重計算?
  5. 是否有元件更新範圍太大?
  6. 是否有 DOM 讀寫交錯或第三方套件造成 layout thrashing?

3. 超大列表靠 Diff 不夠,要改渲染策略

如果一次渲染一萬筆資料,再好的 Diff 也只是減少更新成本,無法改變「DOM 節點太多」這件事。

這時應該考慮:

  • 虛擬列表。
  • 分頁。
  • 懶載入。
  • 降低每列組件複雜度。
  • 避免每列綁定過多響應式依賴。

資深回答應該能從「優化某個 API」上升到「改變渲染策略」。


總結

問題面試回答重點
DOM 操作為什麼貴可能觸發 Style、Layout、Paint、Composite
最貴的是什麼Layout 通常最敏感,因為會計算尺寸與位置
什麼是強制同步佈局寫 DOM 後立刻讀 layout,迫使瀏覽器馬上計算
怎麼避免批次更新、讀寫分離、減少 layout 屬性動畫
和 Vue Diff 的關係Vue 先比較 VNode,減少真正需要 patch 的 DOM 操作

一句完整的面試回答可以是:

DOM 操作很貴不是指每個 DOM API 都慢,而是 DOM 變更可能讓瀏覽器重新跑樣式計算、佈局、繪製與合成。尤其 layout 會重新計算元素大小與位置,影響範圍可能很大。如果程式碼在迴圈中交錯讀取 offsetWidth 這類 layout 資訊,又立刻寫入 style,就可能造成強制同步佈局。Vue 的 VNode Diff 就是在 JS 層先算出必要變更,盡量減少真實 DOM 操作與瀏覽器渲染成本。

延伸閱讀