最適化と注意点

実装を本番で動かすときに気を付けること。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 アニメーションをしたい場合の代替:

FLIP テクニック

  1. First — 移動前の getBoundingClientRect()
  2. Last — 移動後の getBoundingClientRect()
  3. Invert — 差分を transform で逆方向に当てる
  4. Play — transform を 0 に戻す(実質的に動いて見える)

DOM 操作の頻度

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 タブ

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 が重いと悪化する。

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 との比較

RTL / 国際化

翻訳すると幅が変わって、固定 px の transform が崩れることがある。 %計算ベースにするか、レイアウト後に再計測。

動かしたいけど動かさない(短くする)

モバイルではアニメを短くするのが定石。200〜300ms が体感のボーダー。 500ms を超えると「重い」と感じやすい。

テスト

SSR との両立

Next.js などで SSR するとき、windowrequestAnimationFrame はサーバにない。

よくある罠

症状原因対処
カクつくレイアウト発生 / 重い JStransform / opacity だけ
初回だけ重いレイヤ生成コストwill-change で予約
120Hz で 2 倍速dt 無しdt 計算に直す
スマホで遅いfilter / 大きな要素サイズ・shadow 削る
戻ったら飛ぶvisibility 復帰の dt 巨大化visibilitychange でリセット
メモリ増えるrAF 止め忘れcancelAnimationFrame
全部同時に止まる1 個が長時間 JS でフレームを食う処理分割 / Worker

関連リソース

本セクションの総括

最終心得

ライブラリは便利だが、仕組みを知っていれば 100 行で十分な場合が多い。 GSAP / motion を使うときも、その値を計算しているのは結局「lerp + イージング + dt」。 原理を抑えれば、選定と最適化の両方ができるようになる。