最適化と注意点
実装を本番で動かすときに気を付けること。60fps を守る、レイアウトを避ける、 アクセシビリティ、メモリリーク、計測まで。
60fps を守る原則
1 フレーム = 16.67ms(60Hz)/ 11.1ms(90Hz)/ 8.3ms(120Hz)。 この中で「JS 処理 + スタイル計算 + レイアウト + 描画 + 合成」が終わる必要。
合成専用プロパティ
変更してもレイアウトとペイントが走らないプロパティだけを動かす。 これが「ぬるぬる」の鍵。
| プロパティ | コスト |
|---|---|
| transform | 合成のみ ✅ |
| opacity | 合成のみ ✅ |
| filter | 合成のみ(GPU 必須)✅ |
| color / background | ペイント発生 |
| width / height | レイアウト発生(最重) |
| margin / padding | レイアウト発生 |
| top / left | レイアウト発生 |
位置は top: 50px ではなく transform: translateY(50px) で動かす。
will-change の使い方
ブラウザに「このプロパティが動くよ」と予告。事前に GPU レイヤを作ってくれる。
.box {
will-change: transform, opacity;
}
- 必要なときだけ付ける(常時はメモリ消費)
- アニメーション開始時に付け、終わったら外す
- 大量の要素に付けると逆効果
box.style.willChange = "transform"
await tween(box, { x: 100 }, ...)
box.style.willChange = "auto"
レイアウト発生プロパティを動かしてしまう人へ
どうしても height アニメーションをしたい場合の代替:
- transform: scaleY() でスケールにする
- FLIP テクニック — レイアウトの差分を transform に変換
- height: auto から固定 px へは不可なので、
max-heightで擬似的に - 新しい interpolate-size: allow-keywords(CSS)で
height: autoもアニメ可能に
FLIP テクニック
- First — 移動前の
getBoundingClientRect() - Last — 移動後の
getBoundingClientRect() - Invert — 差分を
transformで逆方向に当てる - Play — transform を 0 に戻す(実質的に動いて見える)
DOM 操作の頻度
- 毎フレーム DOM の getter(offsetTop / clientWidth)を呼ぶと強制レイアウト
- 変更前に必要な値を一度だけ読む
- 書き込み(set)と読み込み(get)を交互にしない
transform の string 組み立てコスト
毎フレーム文字列を生成 / 設定するコストは無視できないが、現代のエンジンでは1000 個程度なら問題ない。 本当にカツカツになったら GPU レイヤ前提で書き直すか、Canvas / WebGL に移行。
Canvas / WebGL 統合
Canvas 内で動かすときは「DOM の transform を当てる」段階が無いのでtween の値を直接ループに渡す:
const state = { x: 0, opacity: 1 }
new Tween(state, { x: 200 }, { duration: 1000 })
function render() {
ctx.clearRect(0, 0, w, h)
ctx.globalAlpha = state.opacity
ctx.fillRect(state.x, 100, 50, 50)
requestAnimationFrame(render)
}
requestAnimationFrame(render)
Three.js / R3F との統合
R3F の useFrame 内で毎フレーム値を更新するか、
オブジェクト参照を直接書き換える tween を回す:
const ref = useRef<THREE.Mesh>(null!)
useEffect(() => {
if (!ref.current) return
const t = new Tween(ref.current.position, { x: 5 }, { duration: 1000 })
}, [])
// または useFrame で damp
useFrame((_, dt) => {
ref.current.position.x = damp(ref.current.position.x, target.x, 0.1, dt)
})
アクセシビリティ: prefers-reduced-motion
OS で「視差効果を減らす」を有効にしているユーザがいる(三半規管が弱い人等)。 その場合はアニメを短く / 無効化する。
CSS
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
JS
const reduce = matchMedia("(prefers-reduced-motion: reduce)")
function tween(target, props, options = {}) {
if (reduce.matches) {
Object.assign(target, props) // 即時に最終状態へ
return Promise.resolve()
}
// 通常実装
}
reduce.addEventListener("change", () => {
// 設定変更にも対応
})
キャンセル / クリーンアップ
コンポーネントが unmount したのに rAF が回り続ける → メモリリーク + DOM が無いオブジェクトを操作。
// React
useEffect(() => {
const t = new Tween(state, ...)
return () => t.stop()
}, [])
// Vanilla
let rafId
function loop() { rafId = requestAnimationFrame(loop) }
loop()
// クリーンアップ
cancelAnimationFrame(rafId)
セレクタを使い回す
毎フレーム document.querySelector しないこと。一度取って参照を保持。
多数要素の管理
100 要素を独立に rAF で動かすと低速。1 つの rAF で全部処理するマネージャに集約。
パフォーマンスの計測
DevTools Performance タブ
- Record → アニメ中の挙動を確認
- 各フレームの色(緑=OK, 赤=Long Frame)を見る
- 「Layout」「Recalculate Style」が頻発していないか
FPS Meter
DevTools の Cmd+Shift+P → "Show frames per second"。常時 FPS 表示。
JS 内で計測
let last = performance.now()
let frames = 0
function loop(now) {
frames++
if (now - last >= 1000) {
document.title = `FPS: ${frames}`
frames = 0; last = now
}
requestAnimationFrame(loop)
}
InteractionToNextPaint (INP)
Core Web Vitals の指標。クリック / タップ後の応答までの時間。 アニメで JS が重いと悪化する。
- イベント直後に重い処理を入れない
- アニメ開始は
requestAnimationFrame越しに次フレームで
scroll 連動の最適化
scroll イベントは多発する。requestAnimationFrame でデバウンス:
let pending = false
window.addEventListener("scroll", () => {
if (pending) return
pending = true
requestAnimationFrame(() => {
handleScroll()
pending = false
})
}, { passive: true })
passive: true
scroll / touchmove リスナは passive: true 必須。preventDefault しないことを宣言してブラウザの最適化を引き出す。
IntersectionObserver で要素ベースに
単に「見えたらアニメ」なら scroll より IntersectionObserver の方が軽い。
Lottie / Rive との比較
- Lottie: After Effects から JSON。複雑なアニメに強い
- Rive: 状態遷移できる。インタラクティブ向け
- JS で書く意味があるのは「動的に値が変わる」場合(ジェスチャ追従、データ連動)
RTL / 国際化
翻訳すると幅が変わって、固定 px の transform が崩れることがある。 % や 計算ベースにするか、レイアウト後に再計測。
動かしたいけど動かさない(短くする)
モバイルではアニメを短くするのが定石。200〜300ms が体感のボーダー。 500ms を超えると「重い」と感じやすい。
テスト
- 固定 dt で
update(dt)を直接呼んで結果を検証 - Visual Regression(Chromatic / Playwright)で見た目チェック
- Reduce Motion 設定下でも動くか
- 120Hz でも 60Hz でも同じ速度か
SSR との両立
Next.js などで SSR するとき、window や requestAnimationFrame はサーバにない。
- クライアント専用コンポーネントに分離(
"use client") - useEffect 内でアニメ初期化
- SSR 時は最終状態で書く(FOUC 回避)
よくある罠
| 症状 | 原因 | 対処 |
|---|---|---|
| カクつく | レイアウト発生 / 重い JS | transform / opacity だけ |
| 初回だけ重い | レイヤ生成コスト | will-change で予約 |
| 120Hz で 2 倍速 | dt 無し | dt 計算に直す |
| スマホで遅い | filter / 大きな要素 | サイズ・shadow 削る |
| 戻ったら飛ぶ | visibility 復帰の dt 巨大化 | visibilitychange でリセット |
| メモリ増える | rAF 止め忘れ | cancelAnimationFrame |
| 全部同時に止まる | 1 個が長時間 JS でフレームを食う | 処理分割 / Worker |
関連リソース
本セクションの総括
- rAF + dt でフレームレート独立
- lerp + easing で値を歪める
- Tween クラス で再利用可能に
- キーフレーム で複雑な値変化
- タイムライン で時間制御
- スプリング で物理的な追従
- 本ページの最適化を守る
ライブラリは便利だが、仕組みを知っていれば 100 行で十分な場合が多い。 GSAP / motion を使うときも、その値を計算しているのは結局「lerp + イージング + dt」。 原理を抑えれば、選定と最適化の両方ができるようになる。