パフォーマンス

デバウンスとスロットルでイベントの過剰発火を抑える

読了 7分
パフォーマンス

デバウンスとスロットルでイベントの過剰発火を抑える

検索ボックスでキー入力のたびにAPIを叩いていたら、リクエストが大量に飛んでUIがもたついて困ったので、そのときのメモです。

入力やスクロールイベントは、ほんの数秒で何十回も発火します。そのたびに重い処理を走らせると、当然もたつきます。これを抑える代表的な手法が デバウンススロットル です。違いを押さえて、自分で実装できるようにしておくと安心です。

まず2つの違いを押さえる

どちらも「呼び出しを間引く」点は同じですが、間引き方の発想が違います。

デバウンススロットル
考え方連続が止まってから実行一定間隔ごとに最大1回実行
向く場面入力確定後の検索・保存スクロール・リサイズ・マウス追従
実行回数最後の1回間隔ごとに1回

ざっくり言うと、デバウンスは「落ち着くまで待つ」、スロットルは「一定ペースで間引く」です。

デバウンスを実装する

デバウンスは「呼ばれるたびにタイマーをリセットして、一定時間呼ばれなかったら実行する」ものです。タイマーIDをクロージャに閉じ込めるのがポイントです。以下のようになります。

function debounce<A extends unknown[]>(fn: (...args: A) => void, wait: number) {
  let timer: ReturnType<typeof setTimeout> | null = null

  function debounced(...args: A) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      fn(...args)
      timer = null
    }, wait)
  }

  // 予約済みのタイマーを外から取り消せるようにしておく
  debounced.cancel = () => {
    if (timer) clearTimeout(timer)
    timer = null
  }

  return debounced
}

呼び出しが続く間は clearTimeout で前回の予約を取り消し続けるので、入力が止まってから wait ミリ秒後に一度だけ 実行されます。検索ボックスで「打ち終わってから検索する」のにぴったりです。

あわせて cancel を生やしておくと、コンポーネントのアンマウント時など「予約を破棄したい」場面で後始末できます(次のReactの節で使います)。

const search = debounce((q: string) => {
  fetch(`/api/search?q=${encodeURIComponent(q)}`)
}, 300)

input.addEventListener('input', (e) => {
  search((e.target as HTMLInputElement).value)
})

これでキー入力が落ち着いてからリクエストが飛ぶようになります。

スロットルを実装する

スロットルは「最後に実行してから一定時間が経つまで、次の実行を許さない」ものです。前回実行した時刻を覚えておきます。

function throttle<A extends unknown[]>(fn: (...args: A) => void, interval: number) {
  let last = 0

  return (...args: A) => {
    const now = Date.now()
    if (now - last >= interval) {
      last = now
      fn(...args)
    }
  }
}

スクロール位置に応じてヘッダーの見た目を変える、といった「途中経過も反映したいけど、頻度は抑えたい」処理に向いています。

const onScroll = throttle(() => {
  header.classList.toggle('shrink', window.scrollY > 100)
}, 100)

window.addEventListener('scroll', onScroll)

どちらを選ぶか

判断の軸はシンプルです。「最後の状態だけ欲しい」ならデバウンス、「途中も一定ペースで反映したい」ならスロットル です。

  • 検索・オートコンプリート・自動保存 → デバウンス(確定した入力で十分)
  • スクロール連動・無限スクロールの判定・マウス追従 → スロットル(途中も追いたい)

ここで注意です。入力欄のように「止まったら最後の1回」が欲しいのにスロットルを使うと、最後の入力が間引かれて取りこぼすことがあります。目的に合った方を選ぶのが大切です。

Reactで使うときの注意

Reactでこれらを使う場合、debounce(...) を毎レンダリングで作り直すと、その都度別の関数になってタイマーが共有されません。これに気づかず「なんで効かないの」となりがちです。

useMemouseRef一度だけ生成 して、アンマウント時には先ほどの cancel で予約済みのタイマーを片付けます。

const search = useMemo(
  () => debounce((q: string) => fetchResults(q), 300),
  [], // 生成は1回だけ
)

useEffect(() => {
  // アンマウント時に未実行の予約を取り消す
  return () => search.cancel()
}, [search])

cancel を呼ばないと、アンマウント後にタイマーが発火して「もう無いコンポーネント」に対する更新が走り、警告やリークの原因になります。後始末まで含めて一組と考えておくと安全です。

ライブラリを使うなら lodash.debounce などが定番ですが、中身はここで書いたものと同じ発想です。自分で一度書いてみると、挙動の調整も怖くなくなりました。

まとめ

  • デバウンスは「止まってから1回」、スロットルは「一定間隔で1回」
  • 確定した結果が欲しいならデバウンス、途中経過も追うならスロットル
  • Reactでは関数を一度だけ生成して、後始末を忘れない

イベント頻度はDevToolsのPerformanceパネルでも観察できます。実際に発火回数を見てから手法を選ぶと、過不足のない間引きができると思います。以上です。