JavaScript | 非同期処理:実務での非同期制御 - デバウンス

JavaScript JavaScript
スポンサーリンク

デバウンスを一言でいうと

デバウンスは、
「連続して何度も呼ばれる処理を、“最後の1回だけ”にまとめるテクニック」 です。

ユーザーがキーボードを連打したり、
入力ボックスに高速で文字を打ち込んだりすると、
イベントはそのたびに発火します。

そのたびに API を叩いたり、重い処理をすると、
アプリもサーバーもヘトヘトになります。

そこで登場するのがデバウンスです。
「一定時間入力が止まるまで待ってから、1回だけ処理する」
これがデバウンスの本質です。


まずは「デバウンスが欲しくなる状況」をイメージする

インクリメンタルサーチでの地獄絵図

検索ボックスに文字を入力するたびに API を叩く「インクリメンタルサーチ」を考えます。

ユーザーが「javascript」と打つとき、
実際にはこう入力されます。

j
ja
jav
java
javas

このたびに input イベントが発火し、
そのたびに API を叩くとどうなるか。

「j の検索結果」
「ja の検索結果」
「jav の検索結果」

が全部サーバーに飛びます。

ネットワークもサーバーも無駄に忙しいし、
レスポンスの順番が前後して画面表示もぐちゃぐちゃになりがちです。

本当に欲しいのは、
「ユーザーがある程度打ち終わって、手が止まったタイミングでの検索結果」ですよね。

ここでデバウンスです。
「最後の入力から 300ms 何も入力されなかったら、その時点の文字列で検索する」
という制御を入れると、一気に世界が平和になります。


デバウンスの基本的な考え方

「タイマーを張り直し続けて、最後だけ実行する」

デバウンスの動きは、ざっくりこうです。

1回イベントが来たら、「〇〇ms 後に処理する」というタイマーをセットする
その間にもう1回イベントが来たら、前のタイマーをキャンセルして、新しいタイマーをセットし直す
さらにイベントが来たら、またキャンセルしてセットし直す

一定時間イベントが来なくなったとき、最後にセットしたタイマーが発火して、処理が1回だけ実行される

つまり、
「イベントが連続している間は、ずっと“待ち”の状態」
「落ち着いた瞬間に、1回だけ実行」

という動きです。

この「タイマーを張り直し続ける」というイメージが掴めれば、
デバウンスはもう半分理解できています。


一番シンプルなデバウンス関数を実装してみる

汎用的な debounce 関数

まずは、どんな関数にも使える汎用的な debounce を作ってみます。

function debounce(fn, delay) {
  let timerId = null;

  return function (...args) {
    if (timerId !== null) {
      clearTimeout(timerId);
    }

    timerId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}
JavaScript

この関数は、

fn: 実際に実行したい処理
delay: 何ミリ秒入力が止まったら実行するか

を受け取り、
「デバウンスされた新しい関数」を返します。

中でやっていることを分解すると、

timerId という変数で「今セットされているタイマー」を覚えておく
返された関数が呼ばれるたびに、前のタイマーを clearTimeout でキャンセルする
新しく setTimeout でタイマーをセットし直す
delay ミリ秒間、追加の呼び出しがなければ、fn が実行される

という流れです。

ここが重要です。
デバウンスは「最後に呼ばれたものだけを有効にする」ために、
前の予約(タイマー)を毎回キャンセルしている、という構造を理解してください。


実例:入力ボックスのインクリメンタルサーチにデバウンスを使う

デバウンスなしの危険な実装

まず、デバウンスなしの実装を見てみます。

<input id="search" type="text" placeholder="検索ワードを入力" />
<div id="result"></div>
const input = document.getElementById("search");
const result = document.getElementById("result");

async function searchApi(query) {
  const res = await fetch("/api/search?q=" + encodeURIComponent(query));
  const data = await res.json();
  result.textContent = JSON.stringify(data);
}

input.addEventListener("input", (event) => {
  const query = event.target.value;
  searchApi(query);
});
JavaScript

これだと、
1文字入力するたびに API が飛びます。

「j」「ja」「jav」…
全部サーバーに行きます。
しかもレスポンスの順番が前後すると、
画面には「古い検索結果」が上書きされる可能性もあります。

デバウンスありの実装

ここにさっきの debounce を組み合わせます。

function debounce(fn, delay) {
  let timerId = null;

  return function (...args) {
    if (timerId !== null) {
      clearTimeout(timerId);
    }

    timerId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

const input = document.getElementById("search");
const result = document.getElementById("result");

async function searchApi(query) {
  const res = await fetch("/api/search?q=" + encodeURIComponent(query));
  const data = await res.json();
  result.textContent = JSON.stringify(data);
}

// 300ms 入力が止まったら検索する
const debouncedSearch = debounce((query) => {
  searchApi(query);
}, 300);

input.addEventListener("input", (event) => {
  const query = event.target.value;
  debouncedSearch(query);
});
JavaScript

これで、

ユーザーが高速で「javascript」と打っても、
「最後に入力が止まってから 300ms 経ったタイミング」で、
その時点の文字列(”javascript”)だけが検索されます。

サーバーへのリクエストは 1 回だけ。
画面に表示されるのも「最新の検索結果」だけ。

ここが重要です。
デバウンスは「イベントの数を減らす」のではなく、
“実際に処理を走らせる回数”を減らすテクニックです。
イベントは全部受け取るけれど、処理は最後の1回だけにする。


非同期処理とデバウンスの関係で気をつけること

デバウンスは「呼び出しタイミング」を制御するだけ

よくある誤解として、
「デバウンスを使えば古いリクエストはキャンセルされる」と思ってしまうことがあります。

実際には、
デバウンスは「そもそも古いリクエストを投げない」ようにする仕組みです。

つまり、

デバウンス前:
j → リクエスト
ja → リクエスト
jav → リクエスト

デバウンス後:
j → タイマーセット
ja → タイマー張り直し
jav → タイマー張り直し

入力が止まる → その時点の文字列で 1 回だけリクエスト

という違いです。

すでに投げてしまったリクエストを途中で止めたい場合は、
前に話した AbortController の出番です。

実務では、

「リクエストを減らす」 → デバウンス
「投げたリクエストを途中で止める」 → AbortController

というふうに、役割を分けて考えると整理しやすいです。

デバウンスされた関数の中で async を使う

デバウンスの中で非同期処理をするのもよくあります。

const debouncedSearch = debounce(async (query) => {
  try {
    const res = await fetch("/api/search?q=" + encodeURIComponent(query));
    const data = await res.json();
    renderSearchResult(data);
  } catch (err) {
    console.error(err);
  }
}, 300);
JavaScript

この場合でも、
「最後の入力から 300ms 後に、この async 関数が 1 回だけ実行される」
という動きは変わりません。

ただし、
「古い結果があとから返ってきて上書きされる」問題は残るので、
本当に厳密にやるなら、
AbortController と組み合わせるのがベストです。


デバウンスとスロットリングの違い(軽く触れておく)

デバウンス:最後の1回だけ

スロットリング:一定間隔ごとに実行

よく一緒に語られるのが「スロットリング(throttle)」です。

ざっくり言うと、

デバウンス:
「最後の1回だけ実行したい」
(入力が落ち着いたタイミングで処理したい)

スロットリング:
「どれだけ連打されても、〇〇ms に1回だけ実行したい」
(スクロールイベントなどで使う)

例えば、
検索ボックスにはデバウンスが向いていて、
スクロール位置に応じて何かする処理にはスロットリングが向いています。

ここが重要です。
「ユーザーの操作が“落ち着いたタイミング”で処理したいならデバウンス、
“連打されても一定間隔でだけ処理したい”ならスロットリング」
と覚えておくと、使い分けがスッと入ります。


実務でのデバウンスの使いどころ

検索ボックスのインクリメンタルサーチ

これは王道中の王道です。

ユーザーが文字を打つたびに API を叩くのではなく、
「入力が止まってから 300〜500ms 後」に 1 回だけ検索する。

これだけで、

サーバー負荷が激減する
レスポンスの順番問題が減る
ユーザー体験も「打ち終わったら結果が出る」という自然なものになる

というメリットがあります。

自動保存(オートセーブ)

エディタやフォームで、
「入力内容を自動保存する」機能にもデバウンスはよく使われます。

ユーザーが文字を打つたびに保存すると、
サーバーが大変なことになります。

「最後の入力から 2 秒間何もなかったら保存する」
というデバウンスを入れると、
自然なタイミングでのオートセーブが実現できます。

ウィンドウリサイズ、スクロールに応じた処理

ウィンドウサイズ変更やスクロールイベントは、
ものすごい頻度で発火します。

そのたびに重い処理をすると、
ブラウザがカクカクします。

ここでも、
「リサイズが終わってから 200ms 後に 1 回だけ処理する」
というデバウンスがよく使われます。


初心者として「デバウンス」で本当に押さえてほしいこと

デバウンスは、
「連続するイベントを、“最後の1回だけ”にまとめるテクニック」

中身はシンプルで、
「タイマーを毎回張り直して、最後のタイマーだけ実行する」
という仕組み。

よくある使いどころは、
検索ボックスのインクリメンタルサーチ
自動保存(オートセーブ)
リサイズ・スクロール後の処理

非同期処理と組み合わせるときは、
「デバウンスは“そもそもリクエストを減らす”もの」
「投げたリクエストを途中で止めたいなら AbortController」
という役割分担を意識する。

そして何より、
「この処理、本当に“全部のイベント”で走らせる必要ある?」
「ユーザーが落ち着いたタイミングで 1 回だけでよくない?」

と自分に問いかけてみてください。

そう感じたら、そこはデバウンスの出番です。
一つ debounce を挟むだけで、
コードもサーバーも、そしてユーザー体験も、
一段しなやかになります。

タイトルとURLをコピーしました