【JavaScript】debounceとthrottleの違いと実装方法|leading/trailing・requestAnimationFrame・Lodashまで

JavaScript

スクロール・リサイズ・入力のように高頻度で発火するイベントをそのまま処理すると、パフォーマンス低下の原因になります。これを防ぐのが debounce(デバウンス)と throttle(スロットル)です。似ていますが挙動が異なり、用途で使い分けます。この記事では両者の違いと、実務で使える堅牢な実装を解説します。

この記事でわかること

  • debounce / throttle の違いと実装
  • 最初の1回をすぐ実行する immediate(leading edge)
  • throttleが末尾の呼び出しを取りこぼす問題と対策(leading + trailing)
  • 描画更新に最適な requestAnimationFrame
  • 本番で使う Lodash と、scroll の passive リスナー
結論:操作が落ち着いてから一度だけ処理したいなら debounce(入力検索・リサイズ後)、一定間隔で間引きながら追従したいなら throttle(スクロール・マウス移動)。描画の更新なら requestAnimationFrame ベースが最適です。
スポンサーリンク

debounce:止まってから一度だけ

debounce は「最後のイベントから一定時間が経つまで処理を遅らせる」仕組みです。連続発火の間は実行されず、止まったタイミングで1回だけ呼ばれます。入力が落ち着いてから検索する、といった用途に向きます。

JavaScript:debounce の基本実装
function debounce(func, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => func.apply(this, args), delay);
  };
}

// 入力が止まって500ms後に検索
const handleInput = debounce((e) => {
  console.log("検索ワード:", e.target.value);
}, 500);

document.querySelector("#search").addEventListener("input", handleInput);

debounceの応用:immediate(leading)と cancel

「最初の1回はすぐ実行し、その後の連打は無視」したいことがあります(ボタンの初回即時反応など)。immediate オプションで切り替えられるようにし、保留中の実行を取り消す cancel も付けると実務で使いやすくなります。

JavaScript:immediate と cancel 付き debounce
function debounce(func, delay, { immediate = false } = {}) {
  let timer = null;
  function debounced(...args) {
    const callNow = immediate && timer === null;
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!immediate) func.apply(this, args); // 通常は末尾で実行
    }, delay);
    if (callNow) func.apply(this, args);      // immediate は先頭で実行
  }
  debounced.cancel = () => { clearTimeout(timer); timer = null; };
  return debounced;
}
cancel が要る場面
SPAでコンポーネントが破棄されるときなどに handler.cancel() を呼べば、破棄後に遅延実行が走るのを防げます。タイマー制御の基礎はsetTimeout の使い方を参照してください。

throttle:一定間隔ごとに実行

throttle は「一定間隔に最大1回だけ実行する」仕組みです。イベントが何度発火しても頻度を制御できます。スクロール位置の監視などに向きます。まずは直近の実行時刻を記録するシンプルな実装です。

JavaScript:throttle の基本実装(先頭実行)
function throttle(func, interval) {
  let lastTime = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      func.apply(this, args);
      lastTime = now;
    }
  };
}

// スクロール時に200msごとに処理
const handleScroll = throttle(() => {
  console.log("スクロール位置:", window.scrollY);
}, 200);

// scroll は passive: true で性能を確保
window.addEventListener("scroll", handleScroll, { passive: true });

throttleの落とし穴:末尾の呼び出しを取りこぼす

上の基本実装には弱点があります。最後のイベントが間隔内に起きると、その呼び出しが捨てられます。スクロールが止まる直前の「最終位置」を取り損ねる、といった問題です。先頭(leading)と末尾(trailing)の両方で実行する版にすると確実になります。

JavaScript:leading + trailing 対応 throttle
function throttle(func, interval) {
  let lastTime = 0;
  let timer = null;
  let savedArgs = null;
  let savedThis = null;

  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - lastTime);
    savedArgs = args;
    savedThis = this;

    if (remaining <= 0) {
      // 間隔を過ぎた → すぐ実行(leading)
      if (timer) { clearTimeout(timer); timer = null; }
      lastTime = now;
      func.apply(savedThis, savedArgs);
    } else if (!timer) {
      // 末尾で最後の1回を予約(trailing)
      timer = setTimeout(() => {
        lastTime = Date.now();
        timer = null;
        func.apply(savedThis, savedArgs);
      }, remaining);
    }
  };
}

描画の更新は requestAnimationFrame で間引く

スクロールに合わせて要素を動かす・スタイルを変えるといった描画の更新では、ミリ秒指定の throttle より requestAnimationFrame(rAF)が最適です。ブラウザの描画タイミングに合わせて1フレームに1回だけ実行され、無駄がありません。

JavaScript:requestAnimationFrame ベースの throttle
function rafThrottle(func) {
  let scheduled = false;
  return function (...args) {
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      func.apply(this, args);
      scheduled = false;
    });
  };
}

// スクロール量に応じて描画を更新(1フレーム1回)
window.addEventListener("scroll", rafThrottle(() => {
  document.documentElement.style.setProperty("--scroll-y", window.scrollY + "px");
}), { passive: true });

スクロールイベントの扱い全般はスクロールイベントの使い方(throttle・IntersectionObserver)も参考になります。

debounce と throttle の比較

特徴 debounce throttle
実行タイミング イベントが止まった後に一度だけ 一定間隔ごとに最大1回
処理回数 連続中は0、最後に1回 間隔内に1回ずつ継続
代表的な用途 入力検索・リサイズ後・オートセーブ スクロール監視・マウス移動・無限スクロール
体感 「落ち着いたら反応」 「定期的に追従」

使い分けと本番での選択肢

ユーザー操作が完了したタイミングで1回処理したいなら debounce、継続的に監視しつつ負荷を抑えたいなら throttle、描画更新なら rAF を選びます。ボタンの連打防止のように「処理が終わるまで無効化」したい場合は、debounce/throttleとは別アプローチが向きます(ボタンの連打を防止する方法)。また一定時間の無操作を検知したいなら一定時間操作がなかった場合に処理を実行する方法が参考になります。

本番は Lodash も選択肢
自前実装の代わりに、lodash.debounce / lodash.throttle を使う方法もあります。leading / trailing / maxWait などのオプションや cancel() / flush() が揃っており、エッジケースまで考慮されています。必要な関数だけ個別インポート(import debounce from "lodash/debounce")すれば軽量です。

よくある質問(FAQ)

Qdebounceとthrottleはどちらを使えばいいですか?
A「操作が終わってから1回」なら debounce(入力検索・リサイズ後)、「一定間隔で継続的に間引く」なら throttle(スクロール・マウス移動)です。要素を動かす描画更新は requestAnimationFrame ベースが最適です。
Qthrottleで最後の処理が実行されないことがあります。
Aタイムスタンプだけの基本実装は、最後のイベントが間隔内だと取りこぼします。先頭と末尾の両方で実行する「leading + trailing」版を使うか、Lodashの throttle を使ってください。
Q最初の1回だけすぐ実行したいです。
Adebounceに immediate(leading edge)オプションを付けます。timer === null のとき先頭で実行し、その後の連打は遅延させる実装にします。
Q保留中の処理をキャンセルしたいです。
A返す関数に cancel メソッドを持たせ、内部の clearTimeout を呼びます。SPAでコンポーネント破棄時に呼べば、破棄後の遅延実行を防げます。Lodashなら fn.cancel() が使えます。
Qスクロール処理が重いです。
Aリスナーを { passive: true } で登録し、描画更新は requestAnimationFrame ベースで間引きます。スクロール量の判定だけなら IntersectionObserver も検討してください。

まとめ

  • debounce:止まってから一度だけ。入力検索・リサイズ後に
  • throttle:一定間隔ごとに。スクロール・マウス移動に
  • throttleは末尾を取りこぼす→ leading + trailing 版で確実に
  • 描画更新は rAF:1フレーム1回で滑らか
  • scrollは passive、本番は Lodash も選択肢

イベントの性質に合わせて debounce / throttle / rAF を選べば、パフォーマンスと操作感を両立できます。