デバウンスとスロットル:違いは「誰が実行時刻を決めるか」だけ
どちらも実行頻度を制限します。デバウンスは最後の呼び出しが、スロットルは固定の時間窓が時刻を決めます。選び間違えると、ドラッグが離した時だけ更新されたり、入力中に 200ms ごとに送信したりします。
デバウンスとスロットルは「似た二つの制限関数」として覚えられがちです。両者を分けるのは一つの問いです。実行時刻を誰が決めるのか。
- デバウンス:最後の呼び出しが決めます。新しい呼び出しごとにタイマーをリセットし、十分に静かになって初めて実行します。
- スロットル:固定の時間窓が決めます。一つの窓で最大一回です。
デバウンス:静かになるのを待つ
function debounce<T extends unknown[]>(fn: (...a: T) => void, wait: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
return (...args: T) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
検索候補で使います。入力中はリクエストを送らず、止まった時に送ります。
注意すべきは入力中には一度も実行されないことです。10 秒間打ち続ければ、その間の実行回数はゼロです。それが目的ですが、裏を返せば「入力しながら文字数を更新する」用途には使えません。
スロットル:決まったリズムで実行する
function throttle<T extends unknown[]>(fn: (...a: T) => void, interval: number) {
let last = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
return (...args: T) => {
const now = Date.now();
const remaining = interval - (now - last);
if (remaining <= 0) {
clearTimeout(timer);
last = now;
fn(...args);
} else if (!timer) {
timer = setTimeout(() => {
last = Date.now();
timer = undefined;
fn(...args);
}, remaining);
}
};
}
スクロール進捗やドラッグ追随で使います。継続的な反馈は必要でも、すべてのイベントを処理する必要はありません。
重要なのは追加された else if (!timer) の分岐です。if (remaining <= 0) だけだと、最後のイベントが捨てられ、ドラッグは離した後に途中で止まります。
選び方
| 用途 | 選択 | 理由 |
|---|---|---|
| 検索候補 | デバウンス | 入力中は送らない |
| リサイズ時の再配置 | デバウンス | 停止後に一度でよい |
| スクロール進捗バー | スロットル | 継続的な反馈が要る |
| ドラッグ追随 | スロットル | 離すまで毎瞬追う |
| 自動保存 | デバウンス | 編集が止まってから保存 |
判断は一言です。「途中の各呼び出しをユーザーが知覚できるか」。知覚できないなら捨ててよく、デバウンス。知覚できるなら実行が必要で、スロットルです。
よくある二つの誤解
デバウンスの方が必ず安い。 そうとも限りません。入力が続く間デバウンスは一度も走らないので安いのは事実です。しかしスロットルは安定した頻度を保証し、ジェスチャの途中で状態を更新したい場面ではむしろ適しています。
requestAnimationFrame でスロットルを置き換えられる。 rAF はページが可視の時だけ発火し、頻度はリフレッシュレートに従います(60Hz と 120Hz で違います)。純粋に見た目だけのスクロール追随には適しています。描画と同期するからです。ただし「少なくとも N ミリ秒ごとに一回」という保証はありません。
デバウンスは静けさを、スロットルはリズムを扱います。どちらが要るかを先に決めれば、関数は三行しか違いません。

コメント
…