📓

デバウンスについて

2024/12/08に公開

デバウンスとは

デバウンスとは、特定のイベントや関数などが短期間に連続して実行された際に、タイマーによる入力遅延を利用して一度だけ処理が実行されるようにする手法です。
この際に、実際に実行されるのは一番最後に実行されたイベントや関数だけとなります。

デバウンスが有効に使用できる場面とは?

デバウンスが有効に使用できる場面としてすぐに思いつくのは、ユーザーの入力した値に対して、何らかの制約をチェックする場面です。
郵便番号やEメールアドレスなどがそれに該当すると思います。
デバウンスによく似た手法としてレートリミットという手法が存在します。こちらは、一定時間内に一定回数まで関数を実行し、それ以降は実行しないようにするという手法です。
こちら二関してはまた別で記事を記載しようと考えています。

サンプルコード

今回のサンプルコードになります。
なるべく不要な部分は削ぎ落とすようにしました。

index.html
<!DOCTYPE html>
<html lang="js">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <input type="email" name="email" id="email">
  <input type="text" name="postCode" id="postCode">
  <button type="submit">送信</button>
  <script type="module" src="./debounce.js"></script>
</body>
</html>
debounce.js
/**
 * @file デバウンスのサンプル
 */
(() => {

  /**
   * 関数にデバウンス機能を追加する関数。
   * この関数でラッピングされた関数は、呼び出された後 delay で指定したミリ秒待機する。
   * 待機中に再度呼び出された場合は、以前の処理を破棄して最新の1件だけ待機状態にする。
   * 待機状態のまま delay で指定したミリ秒間経過すると func で指定した関数が実行される。
   *
   * @param {function} func デバウンスでラッピングしたい関数
   * @param {Number} delay 遅延させるミリ秒
   * @returns ラッピングした関数
   */
  const debounce = (func, delay) => {
    let timerId;
    return (...args) => {
      clearTimeout(timerId);
      timerId = setTimeout(func, delay, ...args);
    }
  };

  /**
   * Eメールの値が妥当か確認する関数。
   * 最後の入力から500ミリ秒後に動作する
   *
   * @param {Event} event イベント
   * @param {EventTarget} target イベントの登録対象
   */
  const emailValidator = debounce((event, target) => {
    // ★★★ Eメールに対してのチェック処理 ★★★
  }, 500);

  /**
   * デバウンスでラッピングされた関数を登録する
   */
  document.querySelector('#email').addEventListener('input', event => {
    // ※デバウンスでは setTimeout を用いて遅延起動している。そのため、規定の処理や伝播を止めるのは関数内では使用できない。
    event.preventDefault();
    event.stopPropagation();
    // ※ event.currentTarget はイベントの処理内でしか読み取る事ができない。
    // そのため、利用する場合は別引数としてこのタイミングで読み込んでおく
    emailValidator(event, event.currentTarget);
  });

  // _/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/
  // 一度 debounce 関数を定義すれば、別の関数でも利用できる
  // _/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/

  /**
   * 郵便番号の値が妥当か確認する関数。
   * 最後の入力から1000ミリ秒後に動作する
   *
   * @param {Event} event イベント
   * @param {EventTarget} target イベントの登録対象
   */
  const postCodeValidator = debounce((event, target) => {
    // ★★★ 郵便番号に対してのチェック処理 ★★★
  }, 1000);

  /**
   * デバウンスでラッピングされた関数を登録する
   */
  document.querySelector('#postCode').addEventListener('input', event => {
    event.preventDefault();
    event.stopPropagation();
    postCodeValidator(event, event.currentTarget);
  });

  // _/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/
  // 別パターン
  // _/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/

  // 無名関数で即座に実行することでデバウンス機能でラッピング済みの関数を登録している
  const telValidator = (() => {

    /**
     * telValidator 関数が実行された際の実際の処理
     *
     * @param {Event} event イベント
     * @param {EventTarget} target イベントの登録対象
     */
    const isValid = (event, target) => {
      // ★★★サブミット前の処理★★★
    }

    let timerId;

    return (event, ...args) => {
      // 単一処理のラッピングであればここで処理しても問題ない。
      event.preventDefault();
      event.stopPropagation();
      clearTimeout(timerId);
      // ココで event オブジェクトなどから必要なデータを取得して関数に引き渡せる
      timerId = setTimeout(isValid, 1000, event, event?.currentTarget);
    }
  })();

  // イベント登録
  document.querySelector('#tel').addEventListener('input', telValidator);

})();

サンプルコードの解説

debounce関数について

  /**
   * 関数にデバウンス機能を追加する関数。
   * この関数でラッピングされた関数は、呼び出された後 delay で指定したミリ秒待機する。
   * 待機中に再度呼び出された場合は、以前の処理を破棄して最新の1件だけ待機状態にする。
   * 待機状態のまま delay で指定したミリ秒間経過すると func で指定した関数が実行される。
   *
   * @param {function} func デバウンスでラッピングしたい関数
   * @param {Number} delay 遅延させるミリ秒
   * @returns ラッピングした関数
   */
  const debounce = (func, delay) => {
    let timerId;
    return (...args) => {
      clearTimeout(timerId);
      timerId = setTimeout(func, delay, ...args);
    }
  };

こちらが、本題のデバウンス機能を提供する関数になります。
この関数で他の関数をラッピングすることでデバウンスを行っています。
こちらの関数ですが、呼び出される毎に timerId のスコープが異なるので、以下のように利用しないようにしましょう。
以下のNG例1の場合、emailの入力毎にデバウンスの関数を生成してしまっているため、一切制限されません

NG例1
// 共通で使用したい関数
const func = (event) = {
}

document.querySelector('#email').addEventListener('input', event => {
  // イベント毎にデバウンス用の関数を生成している。
  // debounce(func, 500) の部分で、 debounce 関数の return (...args) => {} 部分の関数を返している。
  debounce(func, 500)(event);
});

逆に、以下のようなソースコードを使用し、ユーザーが Eメールを入力 -> 500ミリ秒以内に郵便番号を入力 という操作を行った場合、Eメールの入力後などは一切動作せず、郵便番号を入力してから500ミリ秒後に1回だけ動作します

NG例2
// 共通で使用したい関数
const debounceFunc = debounce((event) = {
}, 500);

document.querySelector('#email').addEventListener('input', event => {
  debounceFunc(event);
});

document.querySelector('#postCode').addEventListener('input', event => {
  debounceFunc(event);
});

そのため、「Eメールと郵便番号で同じ処理だけど、Eメールの入力後と郵便番号の入力後でそれぞれで最低1回ずつは起動したい。」という場合は以下のようになります。

Eメールと郵便番号でそれぞれ1回ずつ動作する
// 共通で使用したい関数
const func = (event) => {
};

// この変数には、それぞれ別の return (...args) => {} 部分の関数が登録されている
const debounceEmailFunc = debounce(func, 500);
const debouncePostCodeFunc = debounce(func, 500);

document.querySelector('#email').addEventListener('input', event => {
  debounceEmailFunc(event);
});

document.querySelector('#postCode').addEventListener('input', event => {
  debouncePostCodeFunc(event);
});

イベント登録時の注意点について

  /**
   * デバウンスでラッピングされた関数を登録する
   */
  document.querySelector('#email').addEventListener('input', event => {
    // ※デバウンスでは setTimeout を用いて遅延起動している。そのため、規定の処理や伝播を止めるのは関数内では使用できない。
    event.preventDefault();
    event.stopPropagation();
    // ※ event.currentTarget はイベントの処理内でしか読み取る事ができない。
    // そのため、利用する場合は別引数としてこのタイミングで読み込んでおく
    emailValidator(event, event.currentTarget);
  });

上のコードにもコメントにさらっと記載していますが、一応解説。
event.currentTarget はイベントの処理内でしか読み取る事ができないのはどうやら仕様のようですね。

メモ: イベント処理中だけ event.currentTarget の値は利用可能です。 もし console.log() で event オブジェクトを変数に格納し、コンソールで currentTarget キーを探すと、その値は null となります console.log(event.currentTarget) を使ってコンソールで表示するか、 debugger 文を使ってコードの実行を一時停止し、 event.currentTarget の値を表示させる必要があります。

そのため、別の引数としてイベント内で読み込む事で、 event.currentTarget の対象をイベント外でも取得できるようにしています。
また、別パターンとして記載したコードや以下のコードのように、デバウンスの関数内で event.currentTarget を読み取り、別の引数として受け渡すことで各関数で取得できるようにすることもできます。
以下のコードの場合は、Event オブジェクトがある場合はその直後の引数に Event.currentTarget が来るようにしています。

  const debounce = (func, delay) => {
    let timerId;
    return (...args) => {
      let arry = [];
      for (let arg of args) {
        arry.push(arg);
        if (arg instanceof Event && arg?.currentTarget) {
          arry.push(arg.currentTarget);
        }
      }
      clearTimeout(timerId);
      timerId = setTimeout(func, delay, ...arry);
    }
  };

すべての引数の後に Event.currentTarget が来るようにしたい場合は以下のような形になります。

  const debounce = (func, delay) => {
    let timerId;
    return (...args) => {
      let arry = [];
      for (let arg of args) {
        if (arg instanceof Event && arg?.currentTarget) {
          arry.push(arg.currentTarget);
        }
      }
      clearTimeout(timerId);
      timerId = setTimeout(func, delay, ...args.concat(arry));
    }
  };

終わりに

ここまで読んでくださりありがとうございます。
半ば覚書に近い状態での初投稿でしたが、公開してすぐ「いいね」が飛んできたのに驚いて、書き直してしまいました。
これからも何かあれば記事を投稿していこうと思いますのでよろしくお願いいたします。

Discussion