[JavaScript] スクロールイベントを最適化してパフォーマンスを向上させる方法

5 min read

ページのスクロールの滑らかさが損なわれると UX の低下に繋がる。

スクロールイベントの最適化

スクロールイベントは発生頻度が多いため、実装内容によっては Scroll Jank を引き起こす可能性がある。

  • イベント内でpreventDefault()が呼ばれている可能性がある({passive: false}
  • サイズや位置を取得する処理がある(対象 DOM の位置を取得するため Forced Synchronous Layout が発生する可能性がある)
    参考 - What forces layout / reflow

これらの対策には、大量の処理イベントを間引く throttle が一般的である。
また、処理内でpreventDefault() を呼ばないのであれば、Passive Event Listenerを利用することも可能である。

60fps と端末のリフレッシュ レート

今日の端末は、画面を 1 秒に 60 回リフレッシュする。そのため、アニメーションなどの実行中もしくはページのスクロール中は、端末のリフレッシュ レートに合わせて画面がリフレッシュ毎に 1 つの新しい画像またはフレームを表示する必要がある。

これらの間隔を数値にすると約 16 ミリ秒(1000 ミリ秒 / 60 = 約 16.66 ミリ秒)になる。この間隔に合致しない場合、フレームレートが低下し、ジャンクが発生する(画面上で描画が震えて見える)。

setTimeout()

これまで一般的に広くsetTimeoutが利用されてきた。次回の処理をスケジューリングし処理を頻繁に実行させないようにする事ができる。

  • ブラウザ側の準備に関わらず必ず実行される
  • タブが非アクティブ時でも実行される

関数が 16ms ごとに呼び出されないようにするには、以下のようにする。

var timer = null;

function func() {
  clearTimeout(timer);
  timer = setTimeout(function () {
    // 処理
    // do something
  }, 16);
}

document.addEventListener("scroll", func, { passive: true });

lodash.throttle

lodash のthrottleを使う手法もある。lodash.throttle - npm

import { throttle } from "lodash";

document.addEventListener("scroll", throttle(func, 16), { passive: true });

requestAnimationFrame()

requestAnimationFrameは、処理を待つように時間指定するのではなく、次のフレームのレンダリングが準備が整った時に呼び出されるため、ほかの処理に割り込まれてフレームのレンダリングが遅延することなく適切なタイミングで呼び出される。
window.requestAnimationFrame - Web API インターフェイス | MDN

もちろん負荷の高い処理が重なると fps は落ちてしまう。あくまで次のフレームのレンダリング準備が整ったときに呼び出してくれるだけであり、どんな場合でも 60fps を保証する銀の弾丸ではない。

  • ブラウザの画面リフレッシュと同じタイミングで呼び出される
  • 画面が非アクティブ時には実行されない

requestAnimationFrameを利用した throttle は、以下のようにする。

var ticking = false;

function func() {
  if (!ticking) {
    requestAnimationFrame(function () {
      ticking = false;
      // 処理
      // do something
    });
    ticking = true;
  }
}

document.addEventListener("scroll", func, { passive: true });

デモ

凝った実装ではないが、それぞれの処理を比較できるデモを用意した。

scroll 量に応じて指定要素のwidthが変わる処理が走る。width変更関数内にforで負荷をかけている。

function updateWidth(element) {
  // 負荷をかけるループ
  for (var i = 0; i < 100; i++) {
    console.log("waiting...");
  }
  element.style.width = window.scrollY + 1 + "px";
}

setTimeoutは明らかに動作にもたつきが見られる。requestAnimationFrame_.throttle(間隔 16.66 ミリ秒指定)には大きな差は見られないが、若干_.throttleにチラつきが見れるケースもあった。

おわり

setTimeoutが安定しない場合はrequestAnimationFrameを使うとほか処理の割り込みによる遅延を低減できる可能性が高まる。