vatt'ghern jaskier's ballads
本文 4 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

兩顆球並排彈跳,一顆用 CSS keyframes、一顆用 JavaScript。每隔幾秒鎖住 main thread 一下, CSS 那顆毫不停頓地繼續走,JS 那顆當場凍住——同一個畫面裡,你能親眼看到一條看不見的 thread 邊界。

CSS 還是 JS 做動畫——勝負在主執行緒之爭

端做動畫的選擇從來不是三個,是四個層次的東西被混在一起談: 純 CSS keyframes/transition、自己手寫 requestAnimationFrame、像 Motion 這種以 Web Animations API 為底的庫、以及像 GSAP 這種自帶 timeline 引擎的庫。 Josh Comeau 的這篇對照把它們攤開,但他沒有用「哪個 API 漂亮」當判準, 而是用一條更殘酷的軸線:這段動畫究竟跑在 main thread 上,還是跑在它之外。 這條軸線一畫下去,四個方案的位置立刻分明。

為什麼是這條軸線而不是別的?因為瀏覽器把 JavaScript 執行、layout、style 計算、 大部分的 event handler 全塞在同一條 main thread 上。 只要這條 thread 在忙——跑一段昂貴的 React render、解析一坨 JSON、做一次同步的 layout thrash——所有「也跑在 main thread 上」的動畫就得排隊等它。 CSS 的 transformopacity 動畫不在這條 thread 上, 它們被瀏覽器交給 compositor thread 處理,於是 main thread 再怎麼卡,球照樣彈。 Comeau 那句話講得直白:「JavaScript-based animations have to compete for processing power with the rest of the application.」

這不是抽象的架構圖能說服人的事,得讓讀者親手把 main thread 卡住一次。 下面這個 widget 並排放了兩顆球:上面那顆走 CSS keyframes(compositor thread), 下面那顆走 main-thread 的 requestAnimationFrame。 按下「卡住 main thread」會觸發一段有上限的 busy loop(約 1200ms 的純算術空轉, 不是無窮迴圈,跑完就還你控制權),這 1200ms 之內你會看到上面的球順順地走, 下面的球僵在原地;busy loop 結束後 rAF 球從停住的位置繼續走, 跟 CSS 球之間因此留下一段明顯的相位差—— 這段差距正是它在阻塞期間「沒能走的路」。

main thread:閒置
CSS
JS rAF
上:CSS keyframes 動畫,transform: translateX 跑在 compositor thread。 下:手寫 requestAnimationFrame 動畫,每一幀的位置在 main thread 上算出來。 按鈕觸發的是有上限的 busy loop(while 迴圈空轉到指定毫秒就停), 不是無窮迴圈——這正是真實 app 裡一次昂貴 render 的縮影。 prefers-reduced-motion 開啟時 CSS 球會靜止,rAF 球仍可演示阻塞。

上:CSS keyframes 動畫,transform: translateX 跑在 compositor thre…

CSS keyframes 在 compositor thread 持續推進;1200ms busy loop 讓 rAF 球凍結,兩者之後留下明顯相位差。

多按幾次「卡住 main thread」就會看出規律:CSS 那顆球的速度與位置完全不受按鈕影響, 它在 compositor thread 上以 wall-clock 為準持續推進; rAF 那顆球則在每次 busy loop 期間徹底靜止——因為它的下一幀位置得在 main thread 上算, 而 main thread 正被 busy loop 佔滿,requestAnimationFrame 的 callback 根本排不進去。 這就是整篇對照的核心物理事實:動畫跑在哪條 thread 上,決定了它在負載下會不會掉幀。

順帶把「卡住」這個詞量化一下,後面幾軸才有共同的尺度。 螢幕通常以 60Hz 刷新,瀏覽器每一幀的預算是 1000 / 60 ≈ 16.6ms。 main thread 上所有的工作——JS 執行、style 重算、layout、paint—— 都得擠進這 16.6ms 裡,下一幀才能準時上畫。 一旦某一幀的 main thread 工作超過 16.6ms,這一幀就 miss 了 deadline,畫面停一拍, 這就是「jank」。demo 裡那段 1200ms 的 busy loop,等於連續 miss 了 72 幀的 deadline, 所以 main-thread 的 rAF 球會明顯凍結這麼久。

關鍵在於:compositor thread 有自己獨立的這條 16.6ms 預算線。 main thread 那一幀超時,不會吃掉 compositor 那一幀的時間, 這正是 CSS transform 動畫能在 busy loop 期間照走的根本原因—— 它根本不在那條被佔滿的預算線上。

軸線一:動畫跑在哪條 thread——compositor 還是 main

瀏覽器的 rendering pipeline 大致是 style → layout → paint → composite 四步。 改變一個元素的 widthtopmargin 會觸發 layout (重新計算所有受影響元素的幾何),改變 background-colorbox-shadow 會觸發 paint(重畫像素)。這兩類工作都在 main thread 上做。 關鍵在於這條 pipeline 是有「漣漪」的:layout 變動會連帶逼著 paint 與 composite 重跑, paint 變動也會連帶 composite 重跑——越靠前的階段被弄髒,要補做的工作越多。 但 transformopacity 是特例:它們只影響 composite 這最後一步, 不動 layout、也不動 paint,瀏覽器可以把該元素事先 paint 好的 layer 丟給 compositor thread(通常還搭配 GPU)獨立處理,整段動畫期間只是把同一張已畫好的 bitmap 平移、縮放或調透明度,連 main thread 都不必碰。

這就是為什麼 Comeau 在 demo 裡刻意用 transform 而不是 left 來做位移——他自己的說法是用 transform「produces the smoothest motion」。 用 left 做位移每一幀都要 layout + paint,全在 main thread 上; 用 transform 位移則整段動畫可以 offload 到 compositor。 同一個視覺效果(球往右移),底下走的 thread 完全不同。

所以選擇 CSS 屬性這件事,不是美學問題,是 thread 歸屬問題。 下面這張表把常見的動畫屬性按「跑在哪條 thread」分類—— 這決定了它在 main thread 繁忙時會不會卡。

常見動畫屬性的 pipeline 歸屬:composite-only 的屬性才能跑在 compositor thread。
屬性 觸發階段 跑在哪條 thread main thread 卡住時
transform composite compositor thread 繼續動
opacity composite compositor thread 繼續動
filter composite(多數情況) compositor thread 多數繼續動
background-color paint main thread 凍結
box-shadow paint main thread 凍結
width / height layout + paint main thread 凍結
top / left / margin layout + paint main thread 凍結
規則記住四個字就夠:能用 transformopacity 表達的動畫, 就是「免費」掉到 compositor thread 上的動畫;其餘的都得在 main thread 上付錢。 這也是 FLIP 技術的前提——把 layout 改動轉譯成一個 transform, 讓本來會 layout thrash 的位移變成 compositor 動畫。

規則記住四個字就夠:能用 transform 與 opacity 表達的動畫, 就是「免費」掉到 compositor…

transform/opacity 只觸 composite,main thread 卡照動;background-color 觸發 layout,卡住即凍結。

這裡值得補一句 source 沒展開、但工程師該知道的背景:把元素提升成獨立 compositor layer 不是免費的,每一個 layer 都吃顯存。一個 layer 的記憶體大致是 寬 × 高 × 4 bytes(每像素 RGBA 四個 channel)——一塊覆蓋 1920×1080 全螢幕的 layer 就要約 8MB 顯存,提升個幾十塊就能逼近行動裝置的 GPU 記憶體上限。 will-change: transform 可以提示瀏覽器在動畫真正開始前就先把元素提升成 layer, 避開動畫起步那一幀「臨時提升 + 重畫」造成的抖動; 但 will-change 一旦寫死在 CSS 裡長期掛著, 瀏覽器就會把一堆永遠不動的元素也提升成 layer、各自佔著顯存不放—— 正確的用法是在動畫前用 JS 加上、動畫結束後再拿掉,讓提升只在需要的那段時間存在。 這個 trade-off 不影響「transform 跑在 compositor」這個事實,但提醒我們: compositor thread 不是無限資源,只是「不跟 main thread 搶」而已。

既然提到 FLIP,值得把它的四個字母拆開,因為它是「把 layout 改動翻譯成 transform」 這個技巧的標準名字。FLIP 是 First、Last、Invert、Play: 先記下元素移動前的位置(First),把它放到移動後的位置(Last), 用一個 transform 把它「反推」回看起來還在原位(Invert), 然後把這個 transform 動畫到 0(Play)。 過程中元素真正的 layout 位置一開始就跳到終點,視覺上的位移完全靠 compositor-only 的 transform 完成——於是一個本來會每幀 layout 的位移,變成一段 off-thread 的順滑動畫。

Motion 的 layout animation 本質上就是把 FLIP 自動化:你只要宣告 layout,它在元素位置變動時自動測量 First/Last、自動算 Invert transform、 自動 Play。這正是後面會講到的「Motion 給你 CSS 給不了的能力」的一個具體例子—— 純手寫 CSS 要做 FLIP 得自己量 getBoundingClientRect()、自己算差值, 繁瑣且容易在 reflow 時機上出錯。

軸線二:掉幀之後——time-based 會 snap 回去,frame-based 會永久偏移

main thread 被卡住、動畫凍結幾百毫秒,這件事本身不可怕; 可怕的是凍結之後動畫怎麼「接回去」。這裡 Comeau 點出一個多數人沒注意的差別: 動畫引擎是用「經過了多少時間」算位置,還是用「每一幀移動固定量」算位置。

Web Animations API(以及 CSS 動畫,因為它們共用同一個底層引擎)是 time-based 的: 它記得動畫從哪個 wall-clock 時間點開始,每次要畫的時候就問「現在過了多少 ms」, 然後算出對應的位置。 所以即使 main thread 卡住 600ms、中間漏掉一堆幀,凍結結束後它一算時間, 立刻 snap 到「此刻本該在的位置」。Comeau 描述自己手寫的 rAF 版本: 「my requestAnimationFrame implementation freezes, but then it snaps to the correct location.」——只要你的 rAF 也用 elapsed time 算位置,它就有這個 self-correct 能力。

GSAP 的預設行為不一樣。Comeau 的觀察是 GSAP 「doesn't keep track of how much time has passed since the start of the animation. Instead, it focuses on moving by the same amount each frame.」——它是 frame-based,每一幀推進固定量。 後果是:掉了的那些幀,移動量就永遠少掉了,動畫結束的時間點會往後拖、和其他 time-based 的動畫失去同步。Comeau 強調為什麼這件事要緊: 「If an animation is supposed to take 500ms, it should always be finished after 500ms.」——一個本該 500ms 跑完的動畫,不該因為中途掉幀就變成 700ms。

下面這張圖把這個差別量化。橫軸是 wall-clock 時間,縱軸是動畫進度(0 到 1)。 理想線是一條直線(等速跑完)。在第 400ms 處模擬一次 200ms 的 main thread 阻塞: time-based 曲線在阻塞期間水平凍結,但阻塞一結束就垂直 snap 回理想線、準時抵達終點; frame-based 曲線在阻塞期間同樣凍結,但結束後只是「從凍結的高度繼續爬」, 於是整條線往右平移、終點晚到——那段水平距離就是它永久落後的量。

理想(無阻塞) time-based(WAAPI / CSS / 用 elapsed 的 rAF) frame-based(GSAP 預設)
在 t=400ms 處插入一段 200ms 的 main thread 阻塞。 time-based 結束後 snap 回理想線、仍在 1000ms 準時抵達 progress=1; frame-based 永遠少掉那 200ms 的進度,要到 1200ms 才抵達。 資料為依 source 描述的機制合成(非真機 benchmark),用以可視化兩種計時模型的差異。

在 t=400ms 處插入一段 200ms 的 main thread 阻塞

time-based 在 200ms 阻塞後 snap 回理想線、1000ms 準時抵達;frame-based 永遠少掉那段進度,要到 1200ms 才跑完。

這張圖也順手解釋了為什麼「自己手寫 rAF」不必然輸:只要你的 rAF callback 拿 timestamp 參數(或 performance.now())去算 elapsed time, 再用 elapsed 去 interpolate 位置,你就是 time-based 的,掉幀後一樣會 snap 回去。 rAF 本身不是問題,「每幀加固定量」這個寫法才是問題。 GSAP 的處理只是它的預設選擇,並非 JS 動畫的宿命——但它確實是 GSAP 與 WAAPI-based 庫在這一軸上的真實差別。

值得釐清一個常見誤解:rAF 的 callback 本來就會收到一個高精度 timestamp 參數, 瀏覽器在每一幀呼叫它時把當下的時間點傳進來。 用這個 timestamp 算 elapsed,是「免費」就能拿到 time-based 行為的—— 多數人會掉進 frame-based 陷阱,純粹是因為寫 x += speed 比寫 x = startX + speed * elapsed 順手, 而前者在每一幀都跑滿 16.6ms 的理想狀況下看起來一模一樣, 只有在掉幀時才暴露差異。這也是為什麼這類 bug 在開發機上很難重現: 開發機跑得太順,根本掉不了幀。

把這件事放回 source 的脈絡:Comeau 自己手寫的 rAF demo 之所以能在凍結後 snap 回去, 正是因為它是 elapsed-based 的;而 GSAP 之所以會失同步,是因為它的引擎為了支援 timeline 的暫停、倒轉、speed scaling 等編排能力,內部採用了逐幀推進的模型。 換句話說,GSAP 的 frame-based 不是疏忽,是它換取編排彈性付出的設計代價—— 這也呼應了第四軸要談的「能力 vs 成本」。

軸線三:同一個動畫,四種寫法分別落在哪條 thread

把前兩軸合起來看,最直觀的方式是把「同一個位移動畫」用四種做法各寫一份, 並標註它跑在哪條 thread。下面的 tab 切換 CSS、手寫 rAF、Motion、GSAP 四個版本—— 注意看每個版本最後落在哪條 thread,以及它要付出的 bundle 成本。

/* 只動 transform,整段交給 compositor thread */
.box {
  animation: slide 500ms ease-out forwards;
}
@keyframes slide {
  from { transform: translateX(0); }
  to   { transform: translateX(200px); }
}

compositor thread 零 JS、零 bundle。main thread 卡住照樣動,掉幀後 time-based 自動 snap。 只要 CSS 表達得出來,這就是上限。

// 自己驅動:拿 timestamp 算 elapsed,才會 time-based
const start = performance.now();
function frame(now) {
  const t = Math.min(1, (now - start) / 500); // elapsed-based
  box.style.transform = `translateX(${t * 200}px)`;
  if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

main thread 每幀位置在 main thread 上算,busy loop 期間凍結; 但因為用 elapsed time,凍結後會 snap 回正確位置。bundle 成本為零, 卻享不到 compositor 的免疫力。

import { animate } from "motion";

// Motion 底層走 Web Animations API
animate(box,
  { transform: "translateX(200px)" },
  { duration: 0.5, easing: "ease-out" }
);

compositor thread(經 WAAPI) Comeau:WAAPI「hooks into the same low-level animation engine as CSS keyframe animations」,所以 Motion 能像 CSS 一樣 off-thread。 代價約 48kB gzipped——換來的是 CSS 表達不出的東西(如 SVG morph、layout 動畫)。

import gsap from "gsap";

// GSAP 自帶 timeline 引擎,預設 frame-based 推進
gsap.to(box, {
  x: 200,
  duration: 0.5,
  ease: "power2.out"
});

main thread 強大的 timeline 與 plugin 生態,但動畫在 main thread 上跑、 預設 frame-based(掉幀後失同步)。約 27kB gzipped,加 plugin「considerably more」。 值不值,取決於你是否真的用到它獨有的能力。

四個版本擺在一起,那條看不見的 thread 邊界就清楚了:CSS 與 Motion 在線上面 (compositor,負載下免疫),手寫 rAF 與 GSAP 在線下面(main thread,負載下會卡)。 手寫 rAF 與 GSAP 的差別則在計時模型——前者只要用 elapsed 就能 self-correct, 後者預設不會。這四格不是四個並列的選項,是兩軸交叉切出來的四象限。

軸線四:bundle 成本 vs 換來的能力——abstraction-only 的庫不值得

前三軸講的是「跑得順不順」,這一軸講「值不值得帶」。 Comeau 在這裡下了最直接的判斷:如果一個庫只是「an abstraction over built-in CSS functionality」——也就是它能做的 CSS 早就能做、它只是包了一層比較好看的 JS API—— 那它「aren't worth using」。理由是這種庫把所有「main thread」的包袱全帶上身 (它在 main thread 上跑),「as well as bloating our JavaScript bundles, without really offering anything in return.」

這是一句容易被當成個人偏好、但其實有硬成本支撐的話。 把四個方案放在「off-thread?/bundle 成本/表達力/掉幀後行為」四欄裡對照, 一個庫該不該帶就看它在「能力」那欄有沒有真的給你 CSS 給不了的東西。 點擊欄位 header 可以重排——例如先按 bundle 成本排,再看誰的成本換來真本事。

點擊欄位 header 可重排 · 4 列 × 4 欄

四個方案的決策對照(bundle 數字來源:joshwcomeau.com 文中 Motion 48kB、GSAP 27kB gzipped)。
方案 off-thread? bundle (gzip) 給你 CSS 給不了的能力?
CSS keyframes 是(compositor) 0kB 不適用(它本身就是基準)
手寫 rAF(用 elapsed) 否(main thread) 0kB 是(任意自訂邏輯、canvas 驅動)
Motion 是(經 WAAPI) 48kB 是(SVG morph、layout/FLIP、spring)
GSAP 否(main thread) 27kB 是(timeline 編排、豐富 plugin)
關鍵不在 bundle 數字大小,而在「能力」那欄:一個庫若在那欄寫不出 CSS 給不了的東西,那它的 bundle 與 main-thread 成本就是純虧損。 Comeau 點名的「abstraction-only」庫正是那種 off-thread 欄打叉、能力欄又是破折號的東西。

關鍵不在 bundle 數字大小,而在「能力」那欄:一個庫若在那欄寫不出 CSS 給不了的東西,那它的 bundle …

CSS 與 Motion 在 compositor thread;rAF 與 GSAP 在 main thread;Motion 的 48kB 換來真實新能力。

這一軸最容易被誤讀成「不要用庫」,但 Comeau 的判準更精確:問題不是 JS 還是 CSS, 而是這個庫在「能力」欄裡有沒有真貨。Motion 帶 48kB 是合理的, 因為它給你 SVG path morph、layout animation(FLIP 的自動化)、spring 物理—— 這些是純 CSS 寫不出或寫得極痛苦的東西,而且它還是 off-thread 的。 GSAP 帶 27kB 也可以是合理的,如果你真的需要它的 timeline 編排與 plugin 生態 (ScrollTrigger、MorphSVG 那一整套)。

不合理的是那種「我只是想做個 fade in / slide up,所以裝個動畫庫」的決定。 fade 是 opacity、slide 是 transform,兩個都是 compositor-only、 CSS 一行 transition 就完事、零 bundle、負載下免疫。 為了它裝一個 main-thread 的 abstraction-only 庫,等於同時付了 bundle 與 thread 兩筆稅, 卻買到一個你本來就免費擁有的東西。Comeau 那句 「We don't need a JS-based interface for basic transitions, CSS is already wonderful for this!」講的就是這件事。

該怎麼選——一條可以照著走的規則

把四軸收斂成一個決策流程,順序是固定的,因為它對應成本由低到高:

第一步:先問這個動畫能不能用純 CSS transition / keyframes 表達, 而且只動 transformopacity。 能的話就用 CSS,停在這裡——零 bundle、compositor thread、掉幀自動 snap,沒有任何理由往下走。 絕大多數 UI 動畫(hover、進場、淡入、滑出、loading spinner)都停在這一步。

第二步:CSS 表達不出來時(需要 SVG path morph、需要 FLIP-style 的 layout animation、需要 spring 物理、需要根據 runtime 資料動態生成 keyframe), 伸手去拿 Motion 這類以 WAAPI 為底的庫。 它仍然 off-thread,48kB 換來的是真正新增的能力,而不是 CSS 的包裝紙。

第三步:只有當你需要 Motion 也給不了的東西——複雜的多段 timeline 編排、 特定的 GSAP plugin(ScrollTrigger、MorphSVG、SplitText)——才考慮 GSAP, 並接受它跑在 main thread、預設 frame-based 的代價(必要時手動讓它 time-based)。

至於自己手寫 rAF,它的位置很窄但很明確:當動畫需要任意自訂邏輯、 或要驅動 <canvas>(粒子、波形、模擬)這類 CSS 與 WAAPI 都管不到的東西時。 寫的時候記得用 performance.now() 算 elapsed time, 別落入「每幀加固定量」的 frame-based 陷阱。

反過來說,唯一明確該避開的,是那些只把 CSS 包一層的 abstraction-only 庫: 它在 off-thread 欄打叉、在能力欄是破折號,bundle 與 main-thread 成本全是純虧損。 辨認它的方法很簡單——問一句「這個庫能做的,CSS 是不是早就能做?」, 如果答案是「是」,那這個 import 就該刪掉。

還有一個 source 沒展開、但屬於同一條決策軸的工程約束: prefers-reduced-motion。 不論你最後選哪個方案,動畫都該尊重使用者「減少動態」的系統偏好—— 前庭功能敏感的使用者會因為大幅度的位移與縮放動畫感到不適。 CSS 方案處理這件事最省力:一個 @media (prefers-reduced-motion: reduce) 把 animation 關掉即可, 本文那個 jank demo 的 CSS 球就是這樣做的。 這個 media query 有兩個值——reduceno-preference—— 比較穩健的寫法是反過來「預設不動、只在 no-preference 時才加上動畫」, 這樣連舊瀏覽器或讀不到偏好的環境都安全地落在「不動」這一側。 JS 方案則得自己用 matchMedia('(prefers-reduced-motion: reduce)') 讀偏好、再決定要不要跑,而且偏好可能在執行期被使用者改動, 嚴謹的做法還要對那個 MediaQueryList 掛一個 change listener、隨時停掉進行中的動畫—— 這又是一連串 CSS 免費、JS 要手動付的成本。 要補一句:reduce 不必然等於「完全靜止」,把大幅度的位移、縮放、視差換成一個低調的 opacity 淡入往往就夠了,重點是避開會引發前庭不適的那種大開大闔的動態。

把這筆成本算進去之後,「優先 CSS」的傾向又更強了一分: 它不只是 off-thread、零 bundle、掉幀自動 snap, 連 accessibility 的 media query 都是原生一行就接好的。 這不是審美偏好,是把同一件事的總成本(thread、bundle、計時正確性、無障礙) 全部攤開之後,CSS 在最常見的那類動畫上幾乎每一欄都贏。

Take-away:能用 transformopacity 寫的就交給 CSS(compositor thread、零 bundle、掉幀自動 snap); CSS 寫不出來才上 Motion(WAAPI、仍 off-thread);Motion 也不夠才上 GSAP—— 唯一不該帶的是只包了一層 CSS、卻把動畫拖回 main thread 的 abstraction-only 庫。