デバウンスとスロットルでイベントの過剰発火を抑える
デバウンスとスロットルでイベントの過剰発火を抑える
検索ボックスでキー入力のたびに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(...) を毎レンダリングで作り直すと、その都度別の関数になってタイマーが共有されません。これに気づかず「なんで効かないの」となりがちです。
useMemo や useRef で 一度だけ生成 して、アンマウント時には先ほどの cancel で予約済みのタイマーを片付けます。
const search = useMemo(
() => debounce((q: string) => fetchResults(q), 300),
[], // 生成は1回だけ
)
useEffect(() => {
// アンマウント時に未実行の予約を取り消す
return () => search.cancel()
}, [search])
cancel を呼ばないと、アンマウント後にタイマーが発火して「もう無いコンポーネント」に対する更新が走り、警告やリークの原因になります。後始末まで含めて一組と考えておくと安全です。
ライブラリを使うなら lodash.debounce などが定番ですが、中身はここで書いたものと同じ発想です。自分で一度書いてみると、挙動の調整も怖くなくなりました。
まとめ
- デバウンスは「止まってから1回」、スロットルは「一定間隔で1回」
- 確定した結果が欲しいならデバウンス、途中経過も追うならスロットル
- Reactでは関数を一度だけ生成して、後始末を忘れない
イベント頻度はDevToolsのPerformanceパネルでも観察できます。実際に発火回数を見てから手法を選ぶと、過不足のない間引きができると思います。以上です。