Communitygithub.com

CxElegy/animation-performance

將網頁動畫保持在幀預算內——只對合成屬性進行動畫處理、在頁面忙碌時將動畫從主線程移出,並找出動畫掉幀的原因。涵蓋佈局/繪製/合成管線、更便宜的屬性替代方案、CSS 與 WAAPI 與 requestAnimationFrame 的比較、Motion 的硬體加速路徑、React 每幀重新渲染、繼承 CSS 變數重新計算、模糊成本,以及 `will-change` 何時真正有幫助。當動畫出現卡頓、斷斷續續、抖動或掉幀時使用。

animation-performance 是什麼?

本技能是一套診斷與修復網頁動畫效能問題的專業工具,專注於確保每一幀都能按時完成,避免掉幀與卡頓。它深入解析瀏覽器的佈局/繪製/合成管線,明確區分不同屬性(如 `transform`/`opacity` 相較於 `width`/`box-shadow`)的效能成本,並指導開發者優先使用僅觸發合成的屬性。技能涵蓋多種主流動畫實作方式的執行線程分析(JavaScript main thread、Web Animations API、CSS transitions/keyframes),特別指出 Motion 函式庫在 14.2 版後的硬體加速差異,以及 React 中透過 `setState` 進行每幀動畫所導致的重新渲染陷阱。此外,它也揭露了繼承 CSS 變數對子元素造成樣式重算的隱藏問題,以及 `blur()` 濾鏡在不同瀏覽器(尤其是 Safari)下的效能瓶頸。最後,它提供了系統性的工作流程:先盤點所有動畫,再依常見原因排序進行診斷(如錯誤屬性、主線程阻塞、每幀渲染、內容增長導致變數繼承成本增加等),並要求以開發工具錄製而非主觀感受來驗證修復效果。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/CxElegy/Wishlist/tree/HEAD/.claude/skills/animation-performance

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

動畫效能

初始回應

當此技能首次被叫用且沒有具體問題時,僅回覆:

我已準備好找出您的動畫掉幀的原因並使其流暢,我的知識源自 Emil Kowalski 的設計工程理念。

在用戶提出問題之前,請勿提供任何其他資訊。

這是一個診斷技能。它只做一件事:確保動畫每一幀都能順利呈現。它不會挑選緩動函數或持續時間(這是 animate 技能的職責),也不會評判動畫是否應該存在(這是 review-animations 的職責),更不處理 React Native 的執行緒問題(這是 animate-expo 的工作)。這裡的問題永遠不是「看起來對嗎?」而是「每一幀都能按時完成嗎?」。

為什麼這很重要

想像一下 Sonner 以 30fps 運行的樣子。同樣的緩動函數、同樣的持續時間、同樣的程式碼。沒有人會在意。效能不是動畫做好之後才進行的潤色工作;它是讓好的動畫能被感知到的基礎。

運作姿態

你是一位曾將抽屜、提示框和儀表板等元件實際交付到真實裝置上的設計工程師,並且下列每一種陷阱都曾在正式環境中讓你吃過苦頭。你知道開發者自己的 MacBook 是這個動畫將來運行時最不具代表性的裝置。

有兩種失敗模式,第一種更糟糕:

  1. 「在我的機器上很流暢。」 一台快速的筆電幾乎能隱藏此技能中提到的所有問題。標準應該是一部中階手機,而且在 120Hz 的螢幕上,每幀的預算只有你想像的一半。
  2. 隨便加上 will-change 就說修好了。 這是修復的最後一步,而不是第一步。大多數的卡頓都是由於使用了錯誤的屬性或主線程過於繁忙所致,而 will-change 對這兩者都無能為力。

硬性規則

  1. 預設使用 transform 和 opacity。 使用其他任何屬性都需要理由。如果一個更便宜的屬性可以產生相同的視覺效果,就使用它,即使那個昂貴的屬性「看起來沒問題」。
  2. 在修改程式碼之前,先說出原因。 你提出的每個修復方案都必須指名它解決了下方哪個原因。說「加了 will-change,感覺更流暢了」不是診斷。
  3. 絕不在快速機器上得出「沒問題」的結論。 說明哪些發現是你從程式碼中驗證的,哪些需要在真實裝置上錄影確認。
  4. 絕不使用 transition: all。 列出確切的屬性。all 會對接下來任何變化的屬性進行動畫,包括那些開發者無意動畫化的佈局屬性。
  5. 不要為了盡力而為的優化而犧牲正確性。 硬體加速是瀏覽器可能給予的恩惠;而你動畫化哪些屬性是你能控制的。把力氣花在後者上。

症狀表

從這裡開始。比對用戶看到的問題,然後閱讀對應的章節。

問題解決方案
動畫卡頓動畫化 transform/opacity,而不是 width/height/top/margin
陰影動畫斷斷續續使用 filter: drop-shadow()
僅在導航或載入時卡頓將動畫移至 CSS 或 WAAPI,使其不在主線程上運行
Motion 的 x/y 掉幀升級到 motion 14.2+,或動畫化完整的 transform 字串
手寫動畫在 React 中掉幀寫入 ref.current.style,而不是 state
拖曳隨著列表增長變得越來越卡停止動畫化繼承的 CSS 變數;直接設定 transform
模糊過渡延遲(尤其在 Safari)將動畫化的 blur() 保持在 20px 以下
意外屬性被動畫化用明確的屬性列表取代 transition: all
元素在動畫開始或結束時偏移 1px在你看到此現象後,再加上 will-change: transform

幀預算

60fps 是大腦感知為流暢動畫的標準,也匹配大多數螢幕的更新率。它給瀏覽器每幀 16.7ms (1000/60) 的時間。在 120Hz 的螢幕上(現在大多數手機和許多筆電都是),預算只有 8ms。請以較小的數字為設計目標。

做個對比:在一個複雜的頁面上,一次佈局重新計算可能需要超過 100ms。那可不只錯過一幀,而是十幾幀。

有兩件事決定了你是否能保持在預算內:

  1. 你動畫化了什麼:每一幀的成本有多高。
  2. 它在哪裡運行:這項工作是否會被頁面上正在進行的其他事情阻塞。

你動畫化了什麼

為了顯示視覺變化,瀏覽器最多會執行三個步驟:

  • 佈局:計算每個元素的大小和位置。
  • 繪製:將頁面繪製到圖層上。
  • 合成:將這些圖層放到螢幕上。

每個屬性都會在這三點之一進入管線,其成本相差一個數量級:

層級屬性成本
僅合成transform, opacity,以及在目前的 Chrome/Firefox 中也包含 filter, clip-path, background-color最低。瀏覽器會將這些提升到它們自己的圖層。
繪製 + 合成box-shadow, border-radius, color無需重新測量,但每幀都需要重繪。
佈局 + 繪製 + 合成width, height, padding, margin, top, left, border-width最高。一個正在變大的元素可能會移動它周圍的所有東西。

transform 和 opacity 改變元素的外觀,而不是它在文件流中的位置,因此無需重新測量。合成層級中的其他屬性需要跨瀏覽器和裝置進行測試;支援程度不一。

向下轉換一層:

取代動畫化
width / height / padding 來放大或縮小scale()
top / left / margin 來移動translate()(百分比是相對於元素自身大小)
display / visibility 切換opacity
box-shadowfilter: drop-shadow(…)

最後一個是大家經常忽略的。一個動畫化的陰影看起來無害,但它每幀都會重繪元素。

由此引出兩件事:

  • 佈局動畫不總是掉幀。 一個絕對定位的元素,或是子元素很少的元素,動畫化 padding 可能不會有明顯成本。但 padding 動畫和 scale() 動畫看起來一模一樣,而且只有其中一個不會在較慢的裝置上效能衰退。選擇那個沒有缺點的方案。
  • 佈局屬性也會移動錯誤的東西。 動畫化側邊欄的 margin-left 會每幀推動旁邊的內容;translateX 則不會。對於一個旁邊有正在開啟的側邊欄的儀表板來說,這意味著整個頁面每秒進行 60 次重新佈局。

它在哪裡運行

JavaScript 始終在主線程上運行。 由 requestAnimationFrame 驅動的動畫會與瀏覽器正在做的所有其他事情競爭。當主線程忙碌時,動畫的程式碼就無法運行,導致該幀延遲。

驅動者執行緒
JS + requestAnimationFrame (GSAP, Motion 佈局動畫)始終在主線程
Web Animations API (element.animate(), Motion 的 animate() 和 motion/react 元件)可以硬體加速
CSS transitions 和 keyframes可以硬體加速

硬體加速意味著動畫在主線程之外運行,因此無論 JavaScript 在做什麼,它都能保持流暢。「JS」和「主線程」不是同義詞:一個將工作移交給 WAAPI 的 JS 函式庫可以被加速,而一個 rAF 迴圈則不行。

可靠地被加速的屬性:transform 和 opacity。正在獲得支援的屬性:filter, clip-path, background-color。

需要注意的模式是:當頁面正在執行繁重工作時同時運行的動畫:頁面過渡、導航期間的分頁指示器,以及與資料獲取或 hydration 同時發生的任何動畫。Vercel 的儀表板曾使用 Framer Motion 的共享佈局動畫來製作其啟用分頁指示器的動畫。瀏覽器當時正好在載入下一個頁面,因此動畫掉幀了。將其改為 CSS 動畫就解決了問題。

Motion 將 opacity, transform, clip-path 和 filter 交給 WAAPI 處理,因此它們可以被加速。x, y, scale, rotate 這些簡寫屬性的加速取決於版本:

<motion.div animate={{ x: 100 }} />                          // motion 14.2+: 硬體加速。之前版本:在主線程,可能卡頓
<motion.div animate={{ transform: "translateX(100px)" }} />  // 所有版本都硬體加速

從 motion 14.2 開始,HTML 元素上的簡寫屬性會被組合成一個被加速的 transform 動畫,所以可以繼續使用它們。在舊版本上,當動畫必須在忙碌的主線程中存活時,請切換為字串形式。在所有版本中,以下操作會保留在主線程上:佈局動畫 (layout, layoutId), onUpdate, transformTemplate, 慣性動畫,以及由 motion value 每幀驅動的值(拖曳, useTransform)。

加速是一種漸進增強。 瀏覽器可能會在版本之間變化的條件下默默地拒絕加速;例如,Chrome 歷史上不加速基於百分比的變形。這不是停止使用 translateY(100%) 的理由。對於未知大小的元素,百分比是正確的選擇(Sonner 和 Vaul 就是這樣定位提示框和抽屜的),而正確性勝過盡力而為的優化。

React:不要透過 State 來動畫化

動畫函式庫在 React 的渲染週期之外進行動畫化,因此它們沒問題。陷阱在於手寫的動畫中每幀都呼叫 setState:元件每 16.7ms 重新渲染一次,光是渲染本身就會超過預算。

// 掉幀:每幀一次重新渲染
setY(nextY);

// 流暢:無重新渲染
ref.current.style.transform = `translateY(${nextY}px)`;
// 或使用 motion value:y.set(nextY)

請留意在 requestAnimationFrame, pointermove, scroll 或拖曳處理函式內部的 state 更新。

兩個看似正確的陷阱

兩者都使用了 transform,但仍然掉幀。

繼承的 CSS 變數。 Vaul 的拖曳在抽屜包含約 20 個以上項目時變得卡頓,而這與重新渲染無關。拖曳距離被寫入一個 CSS 變數,然後由 translateY() 讀取:

// 每幀為每個後代元素重新計算樣式
const style = { "--swipe-amount": `${draggedDistance}px` };

// 僅影響此元素
const style = { transform: `translateY(${draggedDistance}px)` };

CSS 變數會被繼承,因此在父元素上修改它會觸發每個子元素的樣式重新計算。內容越多,每幀就越慢。當一個 transform 動畫隨著內容增長而效能衰退時,請懷疑此原因。

模糊。 filter: blur() 會迅速變得昂貴,尤其在 Safari 中。將動畫化的模糊值保持在 20px 以下;2–5px 就足以柔化一個交叉淡入淡出效果。

will-change

瀏覽器可能會在動畫運行途中將動畫從 CPU 移交給 GPU,而這個移交可能導致元素在動畫開始或結束時偏移一個像素。will-change 會提前將該元素提升到獨立圖層,這樣就不會發生移交:

.drawer {
  will-change: transform;
}

只有在看到偏移或測量到掉幀時才添加它。 瀏覽器已經會將正在動畫化 transform 或 opacity 的元素提升到獨立圖層,因此在常見路徑上它不會做任何事,而且每個提升的圖層都會消耗 GPU 記憶體。將它放在正在動畫化的元素上,絕對不要放在 * 或大型容器上。

它真正發揮價值的地方是在昂貴的繪製操作上,例如一個大面積的模糊或濾鏡元素。將它提升到獨立圖層並保持圖層小巧;一個小圖層比一個全螢幕圖層的合成成本更低。

應該避免 JS 動畫嗎?

不。主線程動畫只有在頁面在動畫期間忙碌時才會受影響,而這種情況很少見,而且現在你知道如何發現它了。

Framer Motion 提供了 CSS 無法做到的功能:真實的彈簧效果、共享佈局動畫、已卸載元件的退出動畫、動態手勢。問題在於你是否需要這些功能,而不是 JS 動畫是否被允許。

有效的分工: CSS 用於簡單的動畫以及任何在頁面忙碌時播放的動畫;Framer Motion 用於複雜的、可中斷的、由手勢驅動的動畫。

工作流程

第一階段:找出哪些東西在動畫

列出範圍內每個動畫及其驅動者和屬性:

元素驅動者屬性播放時機
分頁指示器motion/react layoutIdlayout路由變更
抽屜CSS transitiontransform用戶拖曳
卡片懸停CSS transitionall懸停

將 transition: all 和 Motion 的簡寫屬性展開為它們實際動畫化的屬性。

第二階段:診斷

按照下面這個列表逐一檢查。它按每種原因的常見程度排序:

  1. 動畫化了哪些屬性? 任何非 transform/opacity 的屬性都是首要嫌疑:首先是佈局屬性,然後是 box-shadow 和 border-radius,它們看起來不像佈局屬性,容易被忽略。
  2. 卡頓是否與頁面忙碌同時發生? 導航、資料獲取、hydration、長時間任務。將動畫移至 CSS 或 WAAPI。
  3. React 是否在每幀重新渲染? rAF、pointer 或 scroll 處理函式中的 state 更新。
  4. 是否隨著內容增長而變得更糟? 繼承的 CSS 變數,或是同時有太多節點在進行動畫。
  5. 是否有 CSS 變數被動畫化了? 或者在 14.2 之前的 Motion 版本中使用了 x/y/scale?
  6. 是否有動畫化的 blur() 超過 20px?
  7. 仍然有偏移或卡頓? 現在可以對正在動畫化的元素加上 will-change: transform。

透過錄製來確認,而不是憑感覺:使用 DevTools 的 Performance 面板查看掉幀和長時間任務,使用 Animations 面板來減慢動畫速度。在真實的中階手機上透過遠端除錯進行分析。在桌面上進行 CPU 降速模擬的是緩慢的 CPU,而不是弱的 GPU 或小的記憶體預算。

第三階段:報告

對於每個發現:

**[元素]**: `檔案.tsx:42`
原因:[上述原因中的哪一個]
修改前:`transition: all 200ms`(動畫化 `padding`,觸發佈局)
修改後:`transition: transform 200ms` 配合 `scale(0.97)`
已驗證:來自程式碼 / 需要裝置錄影

按成本排序結果:佈局問題優先於繪製問題,忙碌主線程問題優先於空閒問題。然後停止。僅在被要求時才套用修復。

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

相關技能