5.4 動畫與渲染效能#
瀏覽器的渲染管線:動畫為何會卡#
一切效能問題都要回到管線:
flowchart LR
A[JS 執行] --> B[Style 樣式計算]
B --> C[Layout 版面<br/>幾何重算 ← 最貴!]
C --> D[Paint 繪製<br/>產生圖層位圖]
D --> E[Composite 合成<br/>GPU 貼圖 ← 最便宜!]| 步驟 | 觸發條件 | 成本 |
|---|---|---|
| Layout | 改 width/height/top/font-size | 重排整棵樹,最貴 |
| Paint | 改 background/color/box-shadow | 重繪區域 |
| Composite | 改 transform/opacity | 只動 GPU,幾乎免費 |
\[ \text{60 FPS 的預算} = 16.67 \text{ ms/幀} \quad(1000 \div 60) \]
動畫卡頓的定義:某幀的計算超過 16.67ms。最佳化 = 把工作挪到管線的便宜段。
黃金法則:只動 transform 與 opacity#
/* ❌ 觸發 Layout:每幀重算整頁版面 */
.box { transition: width 0.3s; }
.box:hover { width: 300px; }
/* ✅ 只觸發 Composite */
.box { transition: transform 0.3s; }
.box:hover { transform: scaleX(1.5); } /* 視覺上變寬,版面不變 */
/* ✅ 進出場用 opacity + transform 取代 display */
.modal {
opacity: 0;
transform: translateY(8px) scale(0.98);
transition: opacity 0.2s, transform 0.2s;
}
.modal.open { opacity: 1; transform: none; }
位移用 transform: translate() 而非 left/top —— 因為 transform 發生在合成階段(且可預先提升圖層)。
強制同步版面(layout thrashing):JS 的經典陷阱#
// ❌ 讀寫交錯 → 每圈都強制立刻重排
items.forEach((el) => {
const h = el.offsetHeight; // 讀 → 強制 layout
el.style.height = h + 10 + 'px'; // 寫 → 標記 dirty
// 下一圈讀取時被迫立刻重排 → N 次重排!
});
// ✅ 先全部讀,再全部寫(批次化)
const heights = items.map((el) => el.offsetHeight); // 讀 N 次(1 次重排)
items.forEach((el, i) => (el.style.height = heights[i] + 10 + 'px')); // 寫 N 次(1 次重排)
\[ T_{\text{交錯}} = N \times T_{\text{重排}} \quad\quad T_{\text{批次}} = 1 \times T_{\text{重排}} \]
讀寫分離是 DOM 效能的第一實務紀律。
CSS 動畫 vs JS 動畫#
/* CSS:合成器執行,JS 阻塞時依然順暢 */
@keyframes slide {
from { transform: translateX(0); }
to { transform: translateX(100px); }
}
.banner { animation: slide 0.5s ease-out; }
| CSS animation/transition | JS requestAnimationFrame | |
|---|---|---|
| 執行者 | 合成器執行緒(主執行緒忙也不卡) | 主執行緒 |
| 控制 | 簡單(播放/暫停/事件) | 完全控制每幀 |
| 適用 | 進出場、懸浮、載入 | 遊戲、物理、資料驅動 |
// rAF:與螢幕同步的 JS 動畫(取代 setTimeout)
function frame(t) {
box.style.transform = `translateX(${Math.min(t / 5, 100)}px)`;
if (t < 500) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
平滑度之外的細節#
will-change:圖層提示(勿濫用)#
.slider { will-change: transform; } /* 提前建立圖層,避免動畫首幀頓挫 */
/* 過度使用 = 記憶體爆炸:每個圖層都是一張位圖 */
動效的功能性:過場即回饋#
動畫的價值不是炫技,而是解釋因果:元素從哪來、到哪去、什麼狀態變了。材料設計的「變形移動」與 macOS 的縮放最小化都在做同一件事 —— 用運動維持物件恆常性,降低認知負荷。
時長的經驗值:微互動 100–200ms、進出場 200–300ms、大型轉場 400ms 內。超過 500ms 的 UI 動畫 = 使用者開始不耐煩。
效能量測#
- DevTools → Performance 面板:錄製滾動/動畫,看紫色(recalculate style)、綠色(layout)條
- Rendering 面板開啟 Paint flashing(重繪區域高亮)與 FPS meter
- 目標:動畫期間綠色 layout 條應為零
習題#
- 把「手風琴展開(用 height)」改寫為只觸發 Composite 的版本(提示:max-height 的問題?grid-template-rows 0fr→1fr?)。
- 示範 layout thrashing:寫 20 個元素的交錯讀寫,用 Performance 面板量測,再批次化對比。
- 解釋為什麼
left動畫在手機上比transform卡很多,從管線與圖層兩個角度。