requestAnimationFrameを用いたスクロールイベントの描画最適化

JavaScript Performance Frontend DOM
結論

ticking フラグを使い、DOM描画を requestAnimationFrame に委譲してレイアウトの再計算を1フレーム1回に抑制します。

// MDN公式仕様:requestAnimationFrame を用いた最適化パターン
let lastScrollY = 0;
let ticking = false;

function updateHeader(scrollY) {
  const header = document.querySelector('header');
  if (scrollY > 100) {
    header.classList.add('is-scrolled');
  } else {
    header.classList.remove('is-scrolled');
  }
}

window.addEventListener('scroll', () => {
  lastScrollY = window.scrollY; // Read Phase (イベント内)

  if (!ticking) {
    window.requestAnimationFrame(() => {
      updateHeader(lastScrollY); // Write Phase (rAF内)
      ticking = false;
    });

    ticking = true;
  }
});

パフォーマンス比較

手法リフレッシュレート同期描画実行頻度CPU負荷 / 画面カクツキ
スクロール直呼び出し❌ 非同期1秒間に 100回以上高頻度実行❌ 高負荷・重度のJank発生
setTimeout スロットル❌ ディスプレイ描画とズレる固定ミリ秒指定△ コマ落ちの可能性あり
requestAnimationFrame◯ ディスプレイ(60Hz/120Hz)と完全同期1フレーム(16.6ms)に最高1回◯ 滑らかで超低負荷

実際に起こる障害:レイアウトスラッシング (Layout Thrashing)

MDN公式ドキュメント(Rendering Performance)で警告されている通り、スクロールイベントハンドラ内で DOMの読み取り(Read)とDOMの書き込み(Write)を交互に呼び出すコード を書くと、ブラウザは1フレーム中に何度も同期再計算(Reflow)を強制実行させられます。

// ❌ 重度のレイアウトスラッシングを起こすダメな例
window.addEventListener('scroll', () => {
  // Read
  const height = element.offsetHeight;
  // Write (ここでブラウザのレイアウトキャッシュが破壊される)
  element.style.height = height + 1 + 'px';
  // 再度 Read (強制再レイアウトが発生!)
  const nextTop = element.getBoundingClientRect().top;
});

実装手順

  1. ループ状態を保持する ticking = false フラグ変数を用意する
  2. イベント発火時に window.scrollY などの位置情報(Read)を変数にのみ保持する
  3. tickingfalse の時だけ requestAnimationFrame を予約し、そのコールバック内でクラス付与やスタイル変更(Write)を行う
  4. 処理完了時に ticking = false に戻す