高效能網頁動畫
消除導致網頁動畫卡頓的首要原因:動畫那些會強制瀏覽器在每一影格重新計算佈局(回流)或重新繪製的屬性。瀏覽器的渲染階段依序為 佈局 → 繪製 → 合成。動畫 width、height、top、left、margin、padding、box-shadow 或 filter 會使每個影格重新執行佈局和/或繪製,從而阻塞主線程。而動畫 transform 和 opacity 則完全在合成器(通常是 GPU)上執行,跳過佈局和繪製,這正是讓動畫在 60/120fps 下保持流暢的原因。
使用時機
當動畫出現卡頓或掉幀(丟失影格)時,當懸停/滾動效果感覺沉重時,當動畫元素的大小/位置/陰影時,當佈局變化需要平滑動畫(如卡片重新排序、元素在容器間移動)時,當動畫 height: auto 時,或在審查動畫程式碼的效能時使用。
核心規則:僅動畫 transform 和 opacity
將每個「昂貴」的動畫映射為一個等效的低成本動畫。
| 正在動畫(昂貴) | 觸發的階段 | 替換為(低成本) |
|---|---|---|
width / height | 佈局 + 繪製 | transform: scaleX()/scaleY() (若需真實大小則搭配 FLIP) |
top / left / margin | 佈局 + 繪製 | transform: translate() |
box-shadow | 繪製 | 動畫一個持有陰影的偽元素的 opacity |
filter: blur() | 繪製(負擔重) | 透過 opacity 交叉淡入淡出兩個圖層,或謹慎接受 |
background-position | 繪製 | 動畫子圖層的 transform: translate() |
color / background | 繪製 | 通常可接受;或交叉淡入淡出圖層 |
透過 transform 實現位置和大小
/* 不好:每影格都動畫佈局 */
.box { transition: left 300ms, width 300ms; left: 0; width: 100px; }
.box:hover { left: 200px; width: 200px; }
/* 好:僅在合成器上執行 */
.box {
transition: transform 300ms ease;
transform: translateX(0) scaleX(1);
transform-origin: left center;
}
.box:hover { transform: translateX(200px) scaleX(2); }
scaleX 會扭曲內部內容(文字被拉伸)。如需真正的縮放而不失真,請使用 FLIP。
透過偽元素透明度實現低成本 box-shadow
動畫 box-shadow 會每影格重新繪製一個大面積的模糊區域。相反地,應將陰影預先繪製在 ::after 上,然後僅動畫其 opacity。
.card { position: relative; }
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 28px rgba(0,0,0,0.35);
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
}
.card:hover::after { opacity: 1; }
模糊陰影被光柵化一次;懸停僅改變合成器的透明度——在任何影格率下都流暢。
FLIP:低成本地動畫佈局變化
FLIP(First, Last, Invert, Play)僅使用 transform 來動畫佈局變化(重新排序、調整大小、在容器間移動)。測量元素最初的位置(First)和最終將達到的最後位置(Last),應用一個反向的變形,使其在視覺上看似未移動,然後將變形動畫回原始狀態(Play)。DOM 最終處於其真實的最終佈局;而運動過程則完全是合成器的工作。
function flip(el, mutate) {
const first = el.getBoundingClientRect(); // First
mutate(); // 改變 DOM/佈局
const last = el.getBoundingClientRect(); // Last
const dx = first.left - last.left;
const dy = first.top - last.top;
const sx = first.width / last.width;
const sy = first.height / last.height;
el.animate(
[
{ transformOrigin: 'top left',
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, // Invert
{ transformOrigin: 'top left', transform: 'none' }, // Play
],
{ duration: 300, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
);
}
// 用法:將一個元素動畫到新的網格位置
flip(card, () => targetContainer.appendChild(card));
Web Animations API animate() 在合成器上執行變形。對於多個元素,請在任何變異之前批量執行所有 getBoundingClientRect() 讀取(參見下方的佈局抖動)。
動畫 height: auto
height: auto 傳統上無法進行插值。以下為現代和備用方法:
/* 現代方法(Chrome 129+/支援此功能的瀏覽器):允許尺寸關鍵字參與動畫 */
.panel {
interpolate-size: allow-keywords; /* 允許動畫到/從 auto */
height: 0;
overflow: clip;
transition: height 300ms ease;
}
.panel.open { height: auto; }
/* calc-size() 也能運作:height: calc-size(auto, size); */
/* 當今穩固的備用方案:CSS grid 1fr -> 0fr */
.wrapper {
display: grid;
grid-template-rows: 0fr; /* 折疊 */
transition: grid-template-rows 300ms ease;
}
.wrapper.open { grid-template-rows: 1fr; }
.wrapper > .content { overflow: hidden; min-height: 0; }
這個網格技巧可以動畫軌道大小(對合成器友好且廣泛支援),且無需 JavaScript 測量高度。在目標瀏覽器支援的地方使用 interpolate-size: allow-keywords / calc-size(auto, size);同時將網格技術作為跨瀏覽器的預設方案。
避免佈局抖動(批量讀取,然後寫入)
在寫入佈局屬性之後,讀取一個佈局屬性(offsetWidth、getBoundingClientRect、scrollTop、getComputedStyle)會強制進行同步回流。在迴圈中交錯進行讀取和寫入(「佈局抖動」)可能導致每個影格內執行數十次強制回流。
// 不好:讀取、寫入、讀取、寫入... 每次迭代都強制回流
items.forEach((el) => {
const w = el.offsetWidth; // 讀取(強制佈局)
el.style.width = w * 1.5 + 'px'; // 寫入(使佈局失效)
});
// 好:先批量讀取所有,再批量寫入所有
const widths = items.map((el) => el.offsetWidth); // 所有讀取
items.forEach((el, i) => { // 所有寫入
el.style.width = widths[i] * 1.5 + 'px';
});
對於需要與影格同步的工作,請在 requestAnimationFrame 回調中進行讀取,並應用寫入;像 fastdom 這樣的庫會將這種讀取/寫入排程正規化。
will-change:謹慎使用
will-change: transform 會提前將一個元素提升到自己的合成器圖層,從而避免動畫開始時的卡頓。但每個被提升的圖層都會消耗 GPU 記憶體,過度使用會降低效能。
.menu { will-change: transform; } /* 僅用於即將開始動畫的元素 */
規則:僅在動畫開始前應用(例如在懸停/父元素狀態上),在空閒時移除(設為 will-change: auto),切勿對大量元素無差別應用,也切勿永久性地將其保留在大型/眾多節點上。單獨一個 transform: translateZ(0) 駭客技巧也能達到同樣的提升效果,但更難以撤銷——建議優先使用 will-change。
交付與驗證(獨立 HTML)
打包好的輔助腳本(位於
scripts/目錄):scripts/seek-shot.sh anim.html 0 1.5 3會凍結?t=N測試框架並截取每個時刻的螢幕截圖;scripts/contact-sheet.sh sheet.png frame-*.png會將它們拼接成一張總覽圖,以便快速審查。詳情請見scripts/README.md。
交付物是一個自包含的 .html 文件,可直接在瀏覽器中打開——標記、CSS/JS 動畫以及一個凍結測試框架都整合在一個檔案中。對於此技能,驗證是雙重的:影格必須看起來正確並且生成成本低廉(僅在合成器上執行)。
輸出約定:
- 一個
.html文件,如有依賴項則透過 CDN 引入,動畫由 CSS transitions/@keyframes或 Web Animations API 驅動。 - 一個凍結機制,以便螢幕截圖能落在一個確定的影格上:
- CSS
@keyframes→?t=N設置el.style.animationDelay = (-N)+'s'; el.style.animationPlayState = 'paused'。 - WAAPI / JS → 保留動畫物件並呼叫
anim.pause(); anim.currentTime = N*1000。
- CSS
驗證循環 — 凍結 → 截圖,然後分析卡頓:
- 在無頭模式下打開開始/中間/結束時刻(
?t=0、中段、結尾),對每個時刻進行螢幕截圖;確認運動在視覺上是正確的(沒有因scaleX導致的裁剪或拉伸文字,FLIP 準確定位到真實佈局,陰影/高度過渡看起來正常)。 - 確認它僅在合成器上執行 — 這是此技能的核心要點。可以透過以下任一方式驗證:
- DevTools → Performance:錄製動畫,檢查每個影格**沒有紫色的「Layout (佈局)」或綠色的「Paint (繪製)」**頻段(僅有「Composite Layers (合成圖層)」)。
- 或者無頭追蹤:使用
npx playwright screenshot進行視覺驗證,再加上 CDP/tracing捕獲,並斷言在動畫窗口期間沒有觸發任何Layout/Paint事件。
- 觀察 FPS/「Frame Rendering Stats (影格渲染統計)」覆蓋層保持在 60 — 掉幀表示某個昂貴的屬性又被引入。
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=0.3" frame.png
在你完成之前:
- 獨立打開 — 無控制台錯誤。
- 每個影格僅
transform/opacity在動畫;DevTools 顯示沒有逐影格的佈局/繪製。 - 在開始/中間/結束時刻進行了螢幕截圖 — 正確,沒有
scaleX文字失真,FLIP 準確定位。 - 保持在 60fps;
will-change即時應用並在空閒時移除(無殘留圖層)。 - 遵循
prefers-reduced-motion。
快速參考
| 目標 | 這樣做 |
|---|---|
| 移動元素 | transform: translate() |
| 不扭曲地調整大小 | 使用 transform 的 FLIP |
| 懸停時顯示陰影 | 動畫陰影偽元素的 opacity |
| 展開到內容高度 | grid 0fr→1fr,或 interpolate-size: allow-keywords |
| 大量元素移動 | 批量 getBoundingClientRect 讀取,然後寫入 |
| 平滑的動畫開始 | 即時 will-change,空閒時移除 |
| 驗證是否僅合成器處理 | DevTools Performance:每影格無紫色「Layout (佈局)」/綠色「Paint (繪製)」 |
注意事項
scaleX/scaleY會拉伸文字和子元素;當內容必須清晰時,請使用 FLIP。transform的百分比是相對於元素自身的盒子,而不是父元素——這與left: %不同。- 過度使用
will-change或translateZ(0)會建立過多圖層,反而損害效能;僅提升需要動畫的元素。 filter和backdrop-filter與合成器相關,但仍然昂貴;應透過不透明度交叉淡入淡出來動畫它們的存在,而不是動畫模糊半徑。- 網格
1fr→0fr的內容必須設定overflow: hidden和min-height: 0,否則它不會折疊。 - 始終將非必要的動畫限制在
@media (prefers-reduced-motion: reduce)內部。
參考文件
references/patterns-and-profiling.md— 完整的可執行範例(摺疊式選單、重新排序列表、視差滾動)、用於確認合成器僅處理影格的 DevTools 分析演練、減少動畫模式以及屬性成本速查表。