📘

JavaScript / デバウンスをガチで分解する

2025/01/28に公開

デバウンスとは

概要

高頻度で発火するイベント内の処理を一定時間が経過してから1回だけ実行するテクニック。
例えば

  • ユーザーが<input>へ1文字入力するごとに処理が発火したり
  • window幅を変化させる度に処理が発火したり

等すればブラウザに非常に大きな負担がかかる。
このような事態を防ぐためのテクニックをデバウンスと呼ぶ。

こいつが案外難しい

コピペする分には良いが、デバウンスを実現するために利用されているJSのメカニズムやメソッドをすべて理解するのが難しい。つまり前提知識が非常に多くなってくる。

前提知識

  • スレッドの占有(何故ブラウザに負担がかかるのか)
  • クロージャ / レキシカルスコープ
  • setTimeout
  • clearTimeout
  • タイマーID
  • thisの参照先
  • thisの束縛

https://zenn.dev/tsumugu2024/articles/172dbcd46281b2

実際にコードを確認する

デバウンスの定義

function debounce(func, delay) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
};

デバウンスの使用

function debounce(func, delay) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
};

function saySomething(something) {
  console.log(something)
}

const debouncedLog = debounce(saySomething, 300);
// 本来ならリサイズするごとにconsoleに出力されるが、デバウンスがコントロールしてくれている。
addEventListener("resize", () => {
  debouncedLog("hello")
})

デバウンス処理の考え方

  1. イベントが頻繁に発生しても、処理をすぐに実行せず「一定時間待つ」。
  2. 待っている間に新しいイベントが発生したら、待ち時間をリセットする。
  3. 最後のイベントが発生してから一定時間経ったときだけ処理を実行する。

▼コードを一つ一つ分解していこう

デバウンス処理のフローを確認する

let timerId;

ここでtimerIdの宣言を行う。

return function (...args){
    clearTimeout(timerId);
    timerId = setTimeout( /* 処理内容 */ );
}

ここで定義された関数はクロージャによって、timerIdへの参照を保持し続ける。

そもそもtimerIdって何が代入されるの?
let timerId

で変数宣言を行い、

timerId = setTimeout( /* 処理内容 */ );

で数値(ID)を代入する。
というのもsetTimeoutメソッドはタイマーを設定した際に一意のIDを返す。このIDはそのタイマーを識別するために使用される。clearTimeoutメソッドは、このIDを使って特定のタイマーをキャンセル(= 同じ値のIDを持つsetTimeoutに設定された処理の実行をキャンセル)することができる。

そして注意深く見てみると、非常に奇妙な箇所がある。

timerId = setTimeout( /* 処理内容 */ );

のような書き方をするとtimerIDに数値を代入しただけで、setTimeoutメソッド内の遅延処理は実行されないように見える。
しかしsetTimeoutメソッドは変数にIDを代入しつつ、同時に遅延処理も行うという特別な仕様になっている。
基本を学んでいるからこそ奇妙に見える事だろうが、こういう仕様なのだと割り切ってしまおう。

*ちなみにNode.js上で実行すると、setTImeoutはタイマーを制御するための情報を持つ Timeoutオブジェクトを返す。

つまり

function debounce(func, delay) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
};

function saySomething(something) {
  console.log(something)
}

const debouncedLog = debounce(saySomething, 300);

debounceLogを実行するたびに

  1. clearTimeoutメソッドがtimerIdを初期化
  2. setTimeoutメソッドが新しいtimerIdをセット
  3. setTimeoutメソッドが新たに設定した遅延時間が経過した後、処理を実行

を繰り返す

では、頻発するイベントの中でデバウンス処理を実行すると・・・

function debounce(func, delay) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
};

function saySomething(something) {
  console.log(something)
}

const debouncedLog = debounce(saySomething, 300);
addEventListener("resize", () => {
  debouncedLog("hello")
})

本来ならブラウザのリサイズの度に処理が実行されるが、デバウンスを用いると...

  1. clearTimeoutメソッドがtimerIdを初期化。
  2. setTimeoutメソッドが新しいtimerIdをセット。
  3. 2で設定した遅延時間(今回の場合は0.3秒)を待たずにrisezeイベントが発火するとclearTimeoutメソッドが実行され、2で設定されたtimerIdにキャンセルをかけることでsetTimeoutに設定された処理の実行を無効する。その後新たにsetTimeoutメソッドが実行され、2のフローへ戻る。
  4. 遅延時間以内にresizeイベントが発火しない場合、処理を実行。

冒頭で説明したデバウンス処理の考え方をおさらいしよう

  1. イベントが頻繁に発生しても、処理をすぐに実行せず「一定時間待つ」。
  2. 待っている間に新しいイベントが発生したら、待ち時間をリセットする。
  3. 最後のイベントが発生してから一定時間経ったときだけ処理を実行する。

これでデバウンス処理は完璧!

じゃない

これも冒頭で説明したが、今まで使ったデバウンスのコードはネット上でよく見かけるものをコピペしてきたのだが、エラーを招く可能性を孕んでいる。

なぜ?

今まで使ってきたデバウンスはthisを利用する場合の対策が不十分なのだ。
一見、applyメソッドを使っているから対策がとられているように見えるが...

function debounce(func, delay) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      func.apply(this, args); // ココ
    }, delay);
  };
};

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Function/apply

thisを利用する場合を想定する

実際、想定外の動きになる

const john = {
  name: "John",
  greeting(message) {
    console.log(`${message}, I am ${this.name}`);
  },
};

function debounce(func, delay) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
};

const debouncedLog = debounce(john.greeting, 300);
addEventListener("resize", () => {
  debouncedLog("hello")
  // > hello, I am
  // "John"が出力されない!
})

john.greetingメソッドはdebounce関数に渡される。その際にthisの参照がjohnオブジェクトから切り離され、以下のような状態になるためだ

const func = function (message) {
  console.log(`${message}, I am ${this.name}`);
  // > hello, I am
  // この場合、thisはグローバルオブジェクトを指す
}
// *fnはdebounceの第一引数

解決方法

1. bindメソッドを使う

const debouncedLog = debounce(john.greeting.bind(john), 300);

2. オブジェクトのメソッドにする

const john = {
  name: "John",
  greeting(message) {
    console.log(`${message}, I am ${this.name}`);
  },
};

function debounce(func, delay) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
};

john.debouncedLog = debounce(john.greeting, 300);
addEventListener("resize", () => {
  john.debouncedLog("hello")
  // > hello, I am John
})

まとめ

かなりの量の前提知識が必要であり、初学者の方々には理解するのが難しいテクニックだと思う。僕自身も完璧に理解できているかと言えば自信がない。
しかし解体してみれば本当に奥深くて面白いテクニックだし、非常に勉強になる。
そしてthis。お前は本当に気持ち悪いな。

Discussion