動畫寫得很爽,一不小心維護火葬場。今天想換個角度:動畫寫出來之後,要怎麼維護?
曾經遇過這種情況:同一隻吉祥物可以切換多個主題與表情。每個主題各自一份元件,動畫也就各自複製一份。久了之後會發現,這些動畫其實「長得一模一樣」,只差在幾個數字。今天就來看看怎麼把這些差異抽成參數,再看看有哪些事情是 CSS 做不到、需要交給 JavaScript 的。
今天的助教是一隻簡易小熊,他的手與腳兩個部位會動,並且手上拿著各主題的專屬物品。那我們開始吧!

先來看原始程式碼,三個主題(coffee、matcha、berry)都會揮手,結構完全一樣:舉著、停一下、放下、再舉起。差別只有角度與週期:
@keyframes waveCoffee {
0%,40% { transform: rotate(10deg); }
70%,75% { transform: rotate(0deg); }
100% { transform: rotate(10deg); } }
@keyframes waveMatcha {
0%,40% { transform: rotate(40deg); }
70%,75% { transform: rotate(0deg); }
100% { transform: rotate(40deg); } }
@keyframes waveBerry {
0%,40% { transform: rotate(40deg); }
70%,75% { transform: rotate(10deg); }
100% { transform: rotate(40deg); } }
.coffee .hand { animation: waveCoffee 2.2s ease-in-out infinite; }
.berry .hand { animation: waveBerry 3s ease-in-out infinite; }
這會帶來幾個麻煩:
.is-angry 整段重寫 animation 設定。所以我們需要把這些數字變成參數。
var()CSS 變數可以直接寫在 @keyframes 裡。共用一份 keyframes,角度與週期都改成讀變數:
.hand {
animation: wave var(--wave-duration) ease-in-out infinite;
}
@keyframes wave {
0%, 40% { transform: rotate(var(--wave-up)); }
70%, 75% { transform: rotate(var(--wave-down)); }
100% { transform: rotate(var(--wave-up)); }
}
各主題只要宣告變數:
.matcha { --wave-up: 40deg; }
.berry { --wave-up: 40deg; --wave-down: 10deg; --wave-duration: 3s; }
生氣的狀態也變得很單純,只改一個變數,不用重寫 animation:
.is-angry { --wave-duration: 0.5s; }
var() 的問題想要模組化動畫設定其實單用 CSS 變數就行了。勝ったッ!Day23,完!
且慢,雖然範例寫得很簡單,但角色動畫往往可動處多,參數設定容易變得很龐雜,如果全抽變數的話,可能會遇到以下問題:
--wave-up 寫成 50(忘了單位),瀏覽器不知道它該是角度,也不會報錯。var(--wave-up, 10deg),改預設值要改很多處,也容易不一致。這時候就輪到 @property 登場。
@property 補上型別@property 可以「註冊」一個自訂屬性,告訴瀏覽器它的型別、預設值,以及要不要繼承:
@property --wave-up { syntax: '<angle>'; inherits: true; initial-value: 10deg; }
@property --wave-down { syntax: '<angle>'; inherits: true; initial-value: 0deg; }
@property --wave-duration { syntax: '<time>'; inherits: true; initial-value: 2.2s; }
三個描述子的作用:
| 描述子 | 作用 |
|---|---|
syntax |
這個變數接受什麼型別,例如 <angle>、<time>、<length>、<number> |
initial-value |
預設值。有了它,就不必再寫 var(--x, 10deg) 的 fallback |
inherits |
要不要從父層繼承 |
註冊之後,原本的 var(--wave-up, 10deg) 就可以直接寫成 var(--wave-up),預設值只在註冊的地方寫一次。
瀏覽器支援度方面,@property 從 Chrome 85、Edge 85、Safari 16.4、Firefox 128 起支援。相對普及,但還是要小心專案是否要支援較舊版本的瀏覽器,尤其是 Firefox。
變數有了型別,瀏覽器就知道它是什麼單位,以範例為例就是知道 --wave-up 是指「deg 角度」,這時候可以直接對它做 transition:
.char {
transition: --wave-up 0.8s ease;
}
以下在範例有兩隻小熊對照:一隻有註冊 --wave-up,一隻用沒註冊的一般變數。把舉手的角度從 10deg 改成 50deg:

有 @property |
沒有 @property |
|
|---|---|---|
改成 50deg |
約 0.8 秒內平滑變化 | 立刻變成 50deg,沒有過渡 |
寫錯單位 50 |
值被丟棄,退回預設的 10deg,手繼續揮 | rotate(50) 無效,整個 transform 失效,手完全不動,而且沒有任何錯誤提示 |
沒有註冊的一般變數,對瀏覽器來說只是一串文字,它不知道該怎麼在兩個值之間補間,所以只能瞬間切換。而寫錯單位兩者的差別更明顯。有型別時,錯誤的值會被擋下來、退回預設,畫面不會壞;沒有型別時,整個動畫就默默失效了。
💡
@property補的是「自訂變數」的型別。transform: rotate()這類內建屬性,瀏覽器本來就知道型別,能自己補出中間值,所以不需要@property。只有想讓「變數本身」被過渡、或想要型別檢查時,才需要註冊它。
變數解決了不同情況下該如何動,但有些事情純 CSS 還是很難做到,像是:
以上這些是在動畫播放時做出的控制行為,這時可以交給 JavaScript 的 Web Animations API(WAAPI)。
el.animate()?el.animate(keyframes, options) 的設定跟 CSS 動畫幾乎能一一對應:
| CSS | WAAPI |
|---|---|
@keyframes |
keyframes:物件陣列,用 offset(0~1)代替百分比 |
animation-duration: 2.2s |
duration: 2200(單位是毫秒) |
animation-timing-function |
easing |
animation-iteration-count: infinite |
iterations: Infinity |
animation-delay |
delay |
它會回傳一個 Animation 物件,之後就靠這個物件來控制動畫。
💡 el 是用
document.querySelector()之類取得的 DOM 元素。animate()則是 WAAPI 的入口,呼叫之後會建立一個動畫,並回傳一個 Animation 物件。
將前面的 CSS 設定改用 JS,CSS 變數就要轉換成函式的參數。把以揮手為例,將動畫設定包成一個函式:
export function waveHold(el, { up = 10, down = 0, duration = 2200, iterations = Infinity } = {}) {
return el.animate([
{ transform: `rotate(${up}deg)`, offset: 0 },
{ transform: `rotate(${up}deg)`, offset: 0.4 },
{ transform: `rotate(${down}deg)`, offset: 0.7 },
{ transform: `rotate(${down}deg)`, offset: 0.75 },
{ transform: `rotate(${up}deg)`, offset: 1 },
], { duration, easing: 'ease-in-out', iterations })
}
各主題只要傳參數:
waveHold(coffeeHand) // 全部預設
waveHold(berryHand, { up: 40, down: 10, duration: 3000 })
拿到 Animation 物件之後,就能進一步操作動畫:
const anim = waveHold(hand)
anim.pause() // 暫停
anim.play() // 繼續
anim.reverse() // 倒轉
anim.updatePlaybackRate(2) // 變成兩倍速
另外,還有 finished,它是一個 Promise,動畫播完才會 resolve,可以拿來串接動作。例如「答對之後揮手一次,再回到待機動畫」:
async function celebrate(hand, idle) {
idle.pause() // 待機動畫先暫停
const once = waveHold(hand, { up: 30, duration: 1200, iterations: 1 })
await once.finished // 等揮完
once.cancel() // 清掉這次的動畫
idle.play() // 回到待機
}

想用純 CSS 做這件事,得靠 animationend 事件再手動切換 class,程式會比較分散,除非是要在動畫執行期間控制,不然一般循環播放的動畫交由 CSS 製作即可
💡 JS 建立的動畫不會被 CSS 的
prefers-reduced-motion關閉,需要自己判斷:const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches if (!reduceMotion) startAnimations()
import { onMounted, onBeforeUnmount } from 'vue'
import { waveHold } from './characterMotion'
export function useCharacterMotion(handRef, options) {
let anim
onMounted(() => {
anim = waveHold(handRef.value, options)
})
onBeforeUnmount(() => {
anim?.cancel() // 不清理的話,元件消失後動畫物件還在
})
return {
setSpeed: (rate) => anim?.updatePlaybackRate(rate),
}
}
getAnimations() 拿到 CSS 動畫:WAAPI 的控制能力,也可以用在「已經存在的 CSS 動畫」上。el.getAnimations() 會回傳這個元素上所有正在跑的動畫,包含 CSS 寫的:const cssAnim = hand.getAnimations().find(a => a.animationName === 'wave')
// 暫停對應的 CSS 動畫後,log 出他已經跑了幾毫秒
cssAnim.pause()
console.log(cssAnim.currentTime)
今天利用 CSS 變數、@property 來管理動畫的參數,想依情況微調動畫細節,只需要調整對應變數即可。若想進一步操作播放中的動畫,則能透過 Web Animations API 從 DOM 取得 Animation 物件,用內部方法影響執行中的動畫。
之前看專案的動畫效果有很多重複卻又分散設定,剛好藉這次參加鐵人賽的機會,研究看看有什麼比較好的處理方式。現在又學到一招了,蒸蚌!