Debounce と Throttle(JavaScript)

Debounce と Throttle は一定期間に関数が呼び出される回数を制限することで、パフォーマンスや UX を改善するための手法です。

以下は JavaScript の Debounce と Throttle の使い方に関する覚書です。

関連ページ:JavaScript デコレータ関数 Decorator Function

作成日:2023年1月18日

Debounce や Throttle とは

リサイズやスクロールイベントなど高い頻度で発生するイベントで、イベントが発生する度にコールバック関数を呼び出して実行するとブラウザに負担がかかったり、UX 上好ましくない場合があります。

Debounce や Throttle は、一定期間に(コールバック)関数が呼び出される回数を制限するための手法で、不必要な関数の実行を制御することができます。

例えば、ユーザーが操作を終了するのを待ってからコールバック関数を呼び出せばよい場合は、Debounce を利用してイベントが一定期間発生しない場合に関数を呼び出すことができます。

Throttle を利用すると、コールバック関数の呼び出しを一定の割合で間引く(呼び出し回数を減らす)ことができます。

以下は ResizeObserver を使って、要素の幅が変更されたらランダムな背景色を設定する例です(要素の右下をドラッグして幅を変更できます)。

Debounce や Throttle を利用しない場合(ラジオボタンの None が選択されている場合)、リサイズが発生する度に背景色が変更されます。

Debounce を利用するとリサイズ操作が停止して Timeout のスライダーで指定されたミリ秒後に背景色が変更され、Throttle を利用すると Timeout で指定されたミリ秒に一回背景色が変更されます。

初期状態では Debounce が選択され、Timeout は 200ms に設定されています。

右下をドラッグして幅を変更できます

Timeout: 200ms

JavaScript
//Debounce の関数の定義
const debounce = (func, delay) => {
  let timer = 0;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => func.apply(null, args), delay);
  }
}
//Throttle の関数の定義
const throttle = (func, timeout) => {
  let timer;
  let lastTime;
  return function (...args) {
    const context = this;
    if (!lastTime) {
      func.apply(context, args)
      lastTime = Date.now();
    } else {
      clearTimeout(timer);
      timer = setTimeout( () => {
        if (Date.now() - lastTime >= timeout) {
          func.apply(context, args);
          lastTime = Date.now();
        }
      }, timeout - (Date.now() - lastTime) );
    }
  }
}
let resizeObserver;
const timeoutValueSpan = document.getElementById('timeoutValue');
const target = document.querySelector('#target');
const funcNameRadio = document.querySelectorAll('input[name="funcName"]');
const timeoutSlider = document.querySelector('input[name="timeout"]');
let timeoutValue = timeoutValueSpan.textContent;
timeoutSlider.addEventListener('input', (e) => {
  timeoutValueSpan.textContent = e.currentTarget.value;
});
timeoutSlider.addEventListener('change', (e) => {
  const checkedValue = document.querySelector('[name="funcName"]:checked').value;
  if (checkedValue !== 'none') {
    timeout = e.currentTarget.value;
    resizeObserver.unobserve(target);
    resizeObserver = null;
    startRO(checkedValue, timeout);
  }
});
const roCallback = (entries) => {
  for (let entry of entries) {
    const color = "#" + Math.random().toString(16).slice(-6);
    entry.target.style.backgroundColor = color;
  }
}
function startRO(func, timeout) {
  if (func === 'debounce') {
    // コールバック関数に debounce() を適用
    resizeObserver = new ResizeObserver(debounce(roCallback, timeout));
  } else if (func === 'throttle') {
    // コールバック関数に throttle() を適用
    resizeObserver = new ResizeObserver(throttle(roCallback, timeout));
  } else {
    resizeObserver = new ResizeObserver(roCallback);
  }
  resizeObserver.observe(target);
}
startRO('debounce', 200);
for (let i = 0; i < funcNameRadio.length; i++) {
  funcNameRadio[i].addEventListener('change', (e) => {
    resizeObserver.unobserve(target);
    const checkedValue = e.currentTarget.value;
    const timeout = timeoutSlider.value;
    startRO(checkedValue, timeout);
  });
}
<div id="target"></div>
<div class="radio-wrapper">
  <input type="radio" name="funcName" value="none" id="none">
  <label for="none"> None </label>
  <input type="radio" name="funcName" value="debounce" id="bounce" checked>
  <label for="bounce"> Debounce </label>
  <input type="radio" name="funcName" value="throttle" id="throttle">
  <label for="throttle"> Throttle </label>
</div>
<p class="range-wrapper"> timeout:
  <input type="range" name="timeout" min="0" max="1000" value="200" step="10">
  <span id="timeoutValue">200</span>ms
</p>

以下は resize イベントを使ってブラウザの幅が変更されたら、幅の値を出力する例です。

Throttled ではほぼ 500ms に1回の割合でコールバック関数を呼び出し、Debounced ではイベントが 500ms 経過しても発生しない場合にコールバック関数を呼び出します。

None ではイベントが発生する度にコールバック関数を呼び出します。

None px
Throttled px
Debounced px
//Debounce の関数の定義(前述の例と同じ)
const debounce = (func, delay) => {
  let timer = 0;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => func.apply(null, args), delay);
  }
}
//Throttle の関数の定義(前述の例と同じ)
const throttle = (func, timeout) => {
  let timer;
  let lastTime;
  return function (...args) {
    const context = this;
    if (!lastTime) {
      func.apply(context, args)
      lastTime = Date.now();
    } else {
      clearTimeout(timer);
      timer = setTimeout( () => {
        if (Date.now() - lastTime >= timeout) {
          func.apply(context, args);
          lastTime = Date.now();
        }
      }, timeout - (Date.now() - lastTime) );
    }
  }
}

const none = document.getElementById('none');
const debounced = document.getElementById('debounced');
const throttled = document.getElementById('throttled');
none.textContent = window.innerWidth;
debounced.textContent = window.innerWidth;
throttled.textContent = window.innerWidth;
//イベントが発生する度に幅を出力
window.addEventListener('resize', (e) => {
  none.textContent = e.target.innerWidth;
});
// コールバック関数に debounce() を適用
window.addEventListener('resize', debounce((e) => {
  debounced.textContent = e.target.innerWidth;
}, 300));
// コールバック関数に throttle() を適用
window.addEventListener('resize', throttle((e) => {
  throttled.textContent = e.target.innerWidth;
}, 300));

Debounce や Throttle は比較的簡単なコードで実装することができます。また、Lodash などのライブラリを利用すれば、自分でコードを記述する必要はありません。

Debounce

Debounce は対象の関数が呼び出されてから次に呼び出されるまでに一定の時間が経過したら実行し、指定された時間内に再度呼び出された場合は待機させます。言い換えると、連続して繰り返される処理が指定した時間内に発生した場合に、最後(または最初)の1回だけ実行するようにします。

以下は Debounce 関数とその利用例です。

関数 debounce() は呼び出しを制御したい関数(func)と待機時間(timeout)を引き数に受け取り、受け取った関数を指定された時間待機させるように拡張したラッパー関数を返すデコレータ関数です。

関数を引数に受け取って、その関数を拡張して返す関数をデコレータ関数と呼びます。

//Debounce 関数の例
const debounce = (func, timeout) => {
  let timer;
  // 引数に受け取った関数 func を拡張して返す
  return function (...args) {
    clearTimeout(timer);
    // timeout で指定された時間後に呼び出しをスケジュール
    timer = setTimeout(() => {
      func.apply(this, args);
    }, timeout);
  }
}

//呼び出しを制御したい関数
const myFunc = () => {
  console.log(window.scrollY);
}

// debounce() に対象の関数と遅延時間を指定すると拡張した関数を返す
const debouncedFunc = debounce(myFunc, 100);

// debounce() の戻り値の拡張した関数を利用
window.addEventListener('scroll', debouncedFunc);

関数 debounce() はラッパー関数を返します。戻り値のラッパー関数(上記の場合は debouncedFunc)を実行すると、まず6行目の clearTimeout() によりタイマーが解除されます。初回の場合、clearTimeout(timer)timerundefined なので何も起きません。

続いて、setTimeout() により、渡された関数 func(上記の場合は myFunc)はタイマーで指定された時間timeout(上記の場合は 100ms)後に apply() を使って呼び出されるようにスケジュールされ、タイマーID(timer)が更新されます。

指定された時間が経過すると、apply() を使って func が呼び出されてタイマーが完了します。

但し、タイマーが完了する前にラッパー関数が再度呼び出された場合、タイマーがリセットされ、func は再度タイマーでスケジュールされ、指定された時間が経過するまで待機状態になります。

5行目の function の引数 ...argsRest パラメータ)は、9行目の apply() を使った func の呼び出しで apply() の第二引数に args(配列)として渡されます。

debounce() の構文

debounce() の引数に対象の関数と待機時間(ミリ秒)を渡して実行すると、戻り値として拡張された関数を返します。この戻り値の関数をイベントハンドラやイベントリスナとして使用します。

const debouncedFunc = debounce(func, timeout);

スクロールイベントは連続して発生しますが、上記の場合、スクロールイベントのリスナーに拡張した関数 debouncedFunc を指定しているので、スクロールが停止して 100ms 経過したら myFunc が呼び出され、コンソールにスクロール量が出力されます。

Debounce は通常、連続して発生するイベントなどで使用しますが、それ以外で使用することもできます。

// 対象の関数(受け取った値をコンソールに出力する関数)
const logger = (val) => console.log(val);

// logger を debounce() で拡張
const dLogger= debounce(logger, 300);

// logger を拡張した dLogger を実行(2, 3, 5 と出力される)
dLogger(1); //実行されない(次の呼び出しが 300ms 未満)
setTimeout(()=> dLogger(2), 200); //実行される(次の呼び出しが 300ms 以上)
setTimeout(()=> dLogger(3), 600); //実行される(次の呼び出しが 300ms 以上)
setTimeout(()=> dLogger(4), 950); //実行されない(次の呼び出しが 300ms 未満)
setTimeout(()=> dLogger(5), 1000); //実行される(次の呼び出しが 300ms 以上)

関連ページ:JavaScript デコレータ関数 Decorator Function

使用例

以下は oninput イベントハンドラを使って入力された文字をコンソールに出力する例です。

HTML
<input type="text" id="data-input" oninput="saveInput()">

イベントハンドラの関数 saveInput を定義し、input 要素の oninput 属性に定義した saveInput() を指定します。

JavaScript
// input 要素
const dataInput = document.getElementById('data-input');
// イベントハンドラ
const saveInput = () => {
  console.log('Data saved:', dataInput.value, new Date().toLocaleString());
}

onkeyuponinput イベントでは1文字入力するごとにイベントが発生するので、この場合、hello と入力すると以下のように1文字入力する度にコンソールに出力されます。

Debounce を利用すると、入力が一定時間止まったらコンソールに出力することができます。

debounce() の引数に対象の関数としてイベントハンドラの saveInputを、待機時間に 500ms を指定して、返される拡張された関数をイベントハンドラとして使用します。

// Debounce 関数
const debounce = (func, timeout = 200) => {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(this, args)
    }, timeout);
  }
}

// input 要素
const dataInput = document.getElementById('data-input');
// 元のイベントハンドラ
const saveInput = () => {
  console.log('Data saved:', dataInput.value, new Date().toLocaleString());
}
// debounce() で元のイベントハンドラを拡張
const debouncedSaveInput = debounce(saveInput, 500);

oninput に拡張した関数 debouncedSaveInput() をイベントハンドラとして指定します。

<input type="text" id="data-input" oninput="debouncedSaveInput()">

この場合、1文字入力する都度に出力されるのではなく、入力が停止して 500ms 経過した場合に出力されるので、例えば hello と続けて入力して停止すると、その時点で(停止して 500 ミリ秒後に)出力されます。

以下はイベントリスナとして使用する例です。

イベントリスナで使用する場合、input 要素の oninput 属性は不要なので削除します。

<input type="text" id="data-input">

debounce() で拡張した関数 debouncedSaveInputaddEventListener() のリスナーに指定します。

// input 要素
const dataInput = document.getElementById('data-input');
// 元のイベントハンドラ
const saveInput = () => {
  console.log('Data saved:', dataInput.value, new Date().toLocaleString());
}
// 元のイベントハンドラを Debouce で拡張した関数
const debouncedSaveInput = debounce(saveInput, 500);

// addEventListener のコールバックに上記 debouncedSaveInput を指定
dataInput.addEventListener('input', debouncedSaveInput);

以下のように引数にイベント(e)を受け取るコールバック関数を定義して debounce() で拡張してリスナーに指定することもできます。

// input 要素
const dataInput = document.getElementById('data-input');

// debounce() で拡張したコールバック関数(リスナー)
const callback = debounce( (e) => {
  console.log('Data saved:', e.target.value, new Date().toLocaleString());
} ,500)

dataInput.addEventListener('input',callback);

または、以下のように直接 addEventListener() に記述することもできます。

const dataInput = document.getElementById('data-input');

dataInput.addEventListener('input', debounce((e) => {
  console.log('Data saved:', e.target.value, new Date().toLocaleString());
} ,500));

resize イベントでの使用例

以下はウィンドウの幅が変化すると背景色を変更し、現在のウィンドウ幅を出力する例です。

ウィンドウ幅の変化は windowresize イベントで検出できますが、連続して頻繁に発生するため、変化を検出する度に背景色を変更するとチラチラしてしまうので、Debounce を使って変化が止まって 500ms 経過したら背景色を変更するようにしています。

// Debounce の定義
const debounce = (func, timeout = 200) => {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(this, args)
    }, timeout);
  }
}
// 対象の要素を取得
const foo = document.querySelector('.foo');
// 対象の要素のスタイル
const fooStyle = foo.style;
// 対象の要素の中の span 要素
const fooSpan = foo.querySelector('span');
// 上記 span 要素にウィンドウ幅を出力
fooSpan.textContent = window.innerWidth;

// debounce() で拡張した resize イベントのリスナー
const callback = debounce( (e) => {
  // 乱数から16進数の色を生成
  const color = "#" + Math.random().toString(16).slice(-6);
  // 上記で生成した色を背景色に設定
  fooStyle.backgroundColor = color;
  // ウィンドウ幅を更新
  fooSpan.textContent = e.target.innerWidth;
}, 500)

window.addEventListener('resize',callback);
HTML
<div class="foo">
  <p>window width: <span></span> px</p>
</div>

ブラウザのサイズ(幅や高さ)を変更する操作が完了すると以下のボックスの背景色が変更され、ウィンドウ幅が現在の値で更新されます(変更中は変わりません)。

window width: px

待機前に処理を実行する Debounce

これまでの例とは反対に、待機前に処理を実行する(後続の関数の呼び出しを無視する)Debounce もあります。

この場合、呼び出された関数を即座に実行し、指定された時間その後の関数の呼び出しを無視します。以下がコードです。

初回の呼び出しでは timerundefined なので引数に受け取った関数が実行され、timeout 後に timerundefined にするようにスケジュールします。その際、timer が更新され、timerは正の整数なので true になります。

timertrue の場合は関数を呼び出さないので、timeout 後に timerundefined になるまで、後続の関数は無視されます(呼び出されません)。

const debounce_leading = (func, timeout = 300) =>{
  let timer;  //タイマーID
  return function(...args) {
     // timer が true の場合は関数を呼び出さない
    if (!timer) {
      // timer が undefined(false)であれば関数を呼び出す
      func.apply(this, args);
    }
    // タイマーをクリア
    clearTimeout(timer);
    // timer を更新(timer は正の整数なので true になる)
    timer = setTimeout(() => {
      // timeout 後に timer を undefined にするようにスケジュール
      timer = undefined;
    }, timeout);
  };
}

この Debounce を利用すると、例えば一定時間の間に何回クリックしても、最初のクリックのみを検出するようにすることができます。

以下は、指定した時間(この例の場合は 500ms)の間にボタンを何回クリックしても、1回とカウントされる例です。

<button id="btn">Single Click</button>
let count =0;
//クリックイベントのリスナーを上記の Debounce で拡張
document.getElementById('btn').addEventListener('click', debounce_leading(()=> {
  count ++;
  console.log(count);
},500))

以下の左側のボタン(Regular Button)は通常のボタンでクリックしただけカウントアップしますが、右側のボタン(Single Button)は上記の Debounce で拡張してあるので、500 ms の間に何回クリックしても最初のクリックのみがカウントされます。


count: 0


count: 0

※前述の待機後に処理を実行する Debounce を使用すると、最後のクリックから一定時間経過してカウントされます。

オプション付きの Debounce

以下は前述の2つの Debounce を組み合わせたものです。

第三引数(immediate)を省略するか false を指定した場合、関数はシーケンスの最後に実行し、第三引数に true を指定した場合、関数はシーケンスの最初に実行されます。

const debounce = (func, timeout, immediate = false) => {
  let timer;
  return function(...args) {
    const context = this;
    const callNow = immediate && !timer;
    clearTimeout(timer);
    timer = setTimeout(() => {
      // timeout後に timer を undefined にスケジュール
      timer = undefined;
      if (!immediate) {
        // immediate が false であれば関数を呼び出し
        func.apply(context, args);
      }
    }, timeout);
    if (callNow) {
      // immediate が true で且つ timer が undefined であれば即座に関数を呼び出す
      func.apply(context, args);
    }
  };
};

第三引数を省略するか false を指定した場合、callNow は常に false になります。setTimeout() により timeout 後に関数の呼び出しと timerundefined にするようにスケジュールされ、timer は更新され true(正の整数)になります。

timeout で指定された時間内に再度ラッパーが呼び出されると、clearTimeout(timer) でタイマーがリセットされますが、timeout で指定された時間が経過すると、関数が呼び出されます。

第三引数に true を指定した場合は、初回は timer が undefined なので callNowtrue になり、関数は即座に呼び出されますが、その後は setTimeout() により timertrue になっているので、タイマーが完了するまでは関数は呼び出されません。

Lodash の debounce を利用

LodashUnderscore などのライブラリには Debounce が用意されているので、それらを利用すれば自分でコードを記述する必要はありません。また、色々なオプションを利用することができます。

以下が Lodash の Debounce の構文です。引数の wait は待機時間で、今までの例の timeout に該当し、デフォルトは 0ms です。第三引数にはオプションオブジェクトを指定することができます。

_.debounce(func, [ wait = 0 ], [ options = {} ])

以下は Lodash をCDN 経由で読み込んで、Lodash の debounce メソッド _.debounce() を利用する例です。内容は先のサンプルと同じものです。

<div class="foo">
  <p>window width: <span></span> px</p>
</div>
<!-- Lodash を CDN 経由で読み込む  -->
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
<script>
const foo = document.querySelector('.foo');
const fooStyle = foo.style;
const fooSpan = foo.querySelector('span');
fooSpan.textContent = window.innerWidth;

// debounce() の代わりに  _.debounce() を使用
const callback = _.debounce( (e) => {
  const color = "#" + Math.random().toString(16).slice(-6);
  fooStyle.backgroundColor = color;
  fooSpan.textContent = e.target.innerWidth;
}, 500)

window.addEventListener('resize',callback);
</script>

以下は、第三引数にオプションのオブジェクトを指定して、後続の関数の呼び出しを無視する例です。

<button id="btn">Single Click</button>
<!-- Lodash を CDN 経由で読み込む  -->
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
<script>
let count =0;
// debounce() の代わりに  _.debounce() を使用し、第三引数にオプションを指定
document.getElementById('btn').addEventListener('click', _.debounce(()=> {
  count ++;
  console.log(count);
}, 500, {
  'leading': true,  //タイムアウト前での呼び出しを指定
  'trailing': false  //後続の関数(タイムアウト後)の呼び出しを無視
}))
</script>

lodash/debounce.js

Throttle

Throttle は Debounce とは異なり、ほぼ一定の間隔で処理を実行する場合に利用することができます。

Throttle は呼び出された関数の実行を一定時間あたり一回に間引くテクニックで、関数が一定時間ごとに複数回呼び出されるのを防ぎ、関数が(ほぼ)固定レートで定期的に実行されるようにします。

以下は Throttle 関数のコードの例です。

この関数は引数に対象の関数と待機時間(func, timeout)を受け取り、受け取った関数を一定間隔で実行する機能を追加したラッパー関数を返すデコレータ関数です。

この Throttle 関数により返されたラッパー関数(Throttle 関数の戻り値)を実行すると、待機状態かどうかのフラグ(isWaiting)が false であれば引数で受け取った関数を呼び出します。

その際、isWaitingtrue にして、setTimeout() で指定された時間(timeout)後に false にするようにスケジュールします。これにより指定された時間(timeout)の間は、func は呼び出されず、指定された時間(timeout)後に呼び出されるようになります。

isWaitingtrue の間は関数は呼び出されません。

//Throttle 関数の定義
const throttle = (func, timeout) => {
  // 待機状態かどうかのフラグ(初期状態は undefined なので false)
  let isWaiting;
  // 指定した間隔に関数を一回呼び出す機能を追加したラッパー関数として返す
  return function(...args) {
    // 待機状態でなければ
    if (!isWaiting) {
      // 引数で受け取った関数を呼び出し
      func.apply(this, args);
      // 待機状態かどうかのフラグを true に変更
      isWaiting = true;
      setTimeout(() => {
        // 指定された時間後に待機状態かどうかのフラグを false に変更
        isWaiting = false;
      }, timeout);
    }
  }
}

但し、上記の場合、最後のラッパー関数の呼び出しが isWaiting = true になっているタイミングでは関数は実行されません。

最後の呼び出しを実行

以下は最後のラッパー関数の呼び出しが実行されるように変更した Throttle 関数の例です。

setTimeout() に指定する遅延時間(スケジュール)を timeout - (Date.now() - lastTime) とすることで、待機時間内に1回関数が実行されます。

const throttle = (func, timeout) => {
  let timer;
  let lastTime;  //前回実行された時のタイムスタンプ(最初は undefined)
  return function (...args) {
    const context = this;
    if (!lastTime) {
      // 初回のみ
      func.apply(context, args);
      lastTime = Date.now();
    } else {
      // 初回以外
      clearTimeout(timer);
      timer = setTimeout( () => {
        func.apply(context, args);
        lastTime = Date.now();
      }, timeout - (Date.now() - lastTime) );
    }
  }
}

timeout で指定された時間内に ラッパー関数が続けて呼び出されると、毎回タイマーはリセットされ、Date.now() - lastTime の値は大きくなり、timeout の値に近くなると、関数が呼び出されます。

以下は上記の関数 throttle()Date.now() - lastTime などの値をコンソールに出力する記述を追加して、スクロールイベントのリスナーに throttle() を使用する例です。

//動作確認用のコンソールへの出力を追加
const throttle = (func, timeout) => {
  let timer;
  let lastTime;
  return function (...args) {
    const context = this;
    if (!lastTime) {
      func.apply(context, args);
      lastTime = Date.now();
    } else {
      clearTimeout(timer);
      console.log('Date.now()-lastTime=', Date.now()-lastTime); // 追加
      timer = setTimeout( () => {
        console.log('timeout-(Date.now()-lastTime)=', timeout-(Date.now()-lastTime)); // 追加
        func.apply(context, args);
        console.log('func called!'); // 追加
        lastTime = Date.now();
      }, timeout - (Date.now() - lastTime) );
    }
  }
}

// スクロールイベントのリスナーに throttle() を使用
window.addEventListener('scroll', throttle ((e) => {
  console.log('window.scrollY: ', window.scrollY)
}, 100));  // 待機時間 100ms

以下は出力例です。待機時間を 100ms としているので、Date.now() - lastTime の値が 100 に近くなった後に関数が呼び出されているのが確認できます。

初回の場合は即座に呼び出され、最後(スクロール停止時)は Date.now() - lastTime の値は小さいですが、その後の呼び出しがないので関数が実行されているのが確認できます。

また、setTimeout() に指定する遅延時間 timeout-(Date.now()-lastTime) は場合によっては負の値になりますが、特に問題はありません(0 を指定したのと同様、即座に実行されます)。

throttle() の構文

throttle() の引数に対象の関数と待機時間(ミリ秒)を渡して実行すると、戻り値として拡張された関数を返します。この戻り値の関数をイベントハンドラやイベントリスナとして使用します。

const throttledFunc = throttle(func, timeout);

以下は、 throttle() を利用してリサイズイベントで 300ms に一回ウィンドウ幅をコンソールに出力する例です。

//ウィンドウ幅をコンソールに出力する対象の関数
const callback = (e) => {
  console.log(e.target.innerWidth);
}

window.addEventListener('resize', throttle(callback, 300));

上記は以下でも同じです。

window.addEventListener('resize', throttle( (e) => {
  console.log(e.target.innerWidth);
}, 300));

または、以下のように記述しても同じです。

const callback = throttle( (e) => {
  console.log(e.target.innerWidth);
}, 300);

window.addEventListener('resize', callback);

最後の呼び出しを実行する場合としない場合

以下はリサイズイベントで、最後の呼び出しを実行する Throttle と最後の呼び出しを無視する Throttle の違いを確認する例です。

最後の呼び出しを実行する Throttle ではその時の実際のウィンドウ幅を最後に取得できますが、最後の呼び出しを無視する Throttle では取得できる時とできない時があります。

この例では、以下のような表にウィンドウ幅を出力して確認します。「window widt」の値には throttle() を使用しない値を出力します。

<table>
  <tr>
    <th>throttle1(最後の呼び出しを無視)</th>
    <td id="t1"></td>
  </tr>
  <tr>
    <th>throttle2(最後の呼び出しを実行)</th>
    <td id="t2"></td>
  </tr>
  <tr>
    <th>window width</th>
    <td id="wwv"></td>
  </tr>
</table>
// 最後の呼び出しを無視する Throttle 関数
const throttle1 = (func, timeout) => {
  let isWaiting;
  return function(...args) {
    if (!isWaiting) {
      func.apply(this, args);
      isWaiting = true;
      setTimeout(() => {
        isWaiting = false;
      }, timeout);
    }
  }
}
// 最後の呼び出しを実行する Throttle 関数
const throttle2 = (func, timeout) => {
  let timer;
  let lastTime;
  return function (...args) {
    const context = this;
    if (!lastTime) {
      func.apply(context, args);
      lastTime = Date.now();
    } else {
      clearTimeout(timer);
      timer = setTimeout( () => {
        func.apply(context, args);
        lastTime = Date.now();
      }, timeout - (Date.now() - lastTime) );
    }
  }
}

const wwv = document.getElementById('wwv');
const t1 = document.getElementById('t1');
const t2 = document.getElementById('t2');
const ww = window.innerWidth;
wwv.textContent = ww;
t1.textContent = ww;
t2.textContent = ww;

// 最後の呼び出しを無視する Throttle 関数(throttle1)で拡張
const callback1 = throttle1( (e) => {
  t1.textContent = e.target.innerWidth;
}, 200);
window.addEventListener('resize', callback1);

// 最後の呼び出しを実行する Throttle 関数(throttle2)で拡張
const callback2 = throttle2( (e) => {
  t2.textContent = e.target.innerWidth;
}, 200);
window.addEventListener('resize', callback2);

// Throttle 関数を使わない
window.addEventListener('resize', (e)=> {
  wwv.textContent = e.target.innerWidth;
});

ブラウザの幅を変更すると、それぞれの値が更新されます。

throttle1(最後の呼び出しを無視)
throttle2(最後の呼び出しを実行)
window width

throttle2(最後の呼び出しを実行)と Throttle を使用しない場合(window width)では同じ値になりますが、throttle1(最後の呼び出しを無視)とは値が異なる場合があるのが確認できます。

このため、最後の呼び出しで取得する値が影響する処理では注意が必要です。

Debounce と Throttle を1つの関数で

以下は Debounce と Throttle を1つの関数にしたコードの例です。

この関数は、第三引数を省略するか false を指定した場合は Throttle の関数になり、第三引数に true を指定すると Debounce 関数になります。

最後の呼び出しを実行する Throttle の関数の else 節の部分がほぼ Debounce の関数と同じなので1つにまとめてみましたが、1つにまとめることで特にメリットはありません。

const throttle_debounce = (func, timeout, debounce = false) => {
  let timer;
  let lastTime;
  return function (...args) {
    const context = this;
    if (!debounce && !lastTime) {
      func.apply(context, args);
      lastTime = Date.now();
    } else {
      clearTimeout(timer);
      timer = setTimeout( () => {
        func.apply(context, args);
        lastTime = Date.now();
      },debounce ? timeout : timeout - (Date.now() - lastTime) );
    }
  }
}

以下は使用例です。

const throttleValue = document.getElementById('throttle-value');
const debounceValue = document.getElementById('debounce-value');
const ww = window.innerWidth;
throttleValue.textContent = ww;
debounceValue.textContent = ww;

window.addEventListener('resize', throttle_debounce( (e)=> {
  throttleValue.textContent = e.target.innerWidth;
  //console.log('throttle : ' , e.target.innerWidth)
}, 300));

window.addEventListener('resize', throttle_debounce( (e)=> {
  debounceValue.textContent = e.target.innerWidth;
  //console.log('debounce: ' , e.target.innerWidth)
}, 500, true));
<table>
  <tr>
    <th>throttle </th>
    <td id="throttle-value"></td>
  </tr>
  <tr>
    <th>debounce</th>
    <td id="debounce-value"></td>
  </tr>
</table>

ブラウザの幅を変更すると、それぞれの値が更新されます。

throttle
debounce

待機前に処理を実行する Debounce のオプションも追加

以下は待機前に処理を実行する Debounce のオプションも追加した例です。第四引数に true を指定すると待機前に処理を実行する Debounce 関数になります。

但し、第四引数に true を指定する場合は第三引数も true にする必要があります。このため使い勝手は良くありません。素直に Debounce と Throttle を別々の関数に定義した方が使いやすいと思います。

const throttle_debounce = (func, timeout, debounce = false, immediate = false) => {
  let timer;
  let lastTime;
  if(!debounce && immediate) {
    //第三引数が false で第四引数が true の場合は警告をコンソールに出力
    console.warn('you need to set debounce true to set immediate true')
  }
  return function (...args) {
    const context = this;
    const callNow = immediate && debounce && !timer;
    if (!debounce && !lastTime) {
      func.apply(context, args);
      lastTime = Date.now();
    } else {
      clearTimeout(timer);
      timer = setTimeout( () => {
        if (!immediate) {
          func.apply(context, args);
          lastTime = Date.now();
        }
        timer = undefined;
      },debounce || immediate ? timeout : timeout - (Date.now() - lastTime) );
      if (callNow) {
        func.apply(context, args);
      }
    }
  }
}

Lodash の throttle を利用

以下が Lodash の throttle の構文です。引数の wait は待機時間で、デフォルトは 0ms です。第三引数にはオプションオブジェクトを指定することができます。

_.throttle(func, [ wait = 0 ], [ options = {} ])

Lodash の _.throttle() は内部的には _.debounce() を呼び出しています。

Lodash の throttle() の src から引用
// Lodash の throttle()
function throttle(func, wait, options) {
  var leading = true,
      trailing = true;

  if (typeof func != 'function') {
    throw new TypeError(FUNC_ERROR_TEXT);
  }
  if (isObject(options)) {
    leading = 'leading' in options ? !!options.leading : leading;
    trailing = 'trailing' in options ? !!options.trailing : trailing;
  }
  // debounce() にパラメータを指定して返している
  return debounce(func, wait, {
    'leading': leading,
    'maxWait': wait,
    'trailing': trailing
  });
}

以下は Lodash をCDN 経由で読み込んで、throttle メソッド _.throttle() を利用する例です。

<div class="bar">
  <p>window width: <span></span> px</p>
</div>
<!-- Lodash を CDN 経由で読み込む  -->
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
<script>
// 背景色の候補
const colors = ['yellow', 'blue', 'green', 'red', 'pink', 'orange', 'purple', 'gray','teal']
const bar = document.querySelector('.bar');
const barStyle = bar.style;
const barSpan = bar.querySelector('span');
barSpan.textContent = window.innerWidth;

// _.throttle() で拡張した resize イベントのリスナー
const callback = _.throttle( (e) => {
  // その時の幅の値から背景色を決定
  const color = colors[Math.floor(e.target.innerWidth) % 9]
  barStyle.backgroundColor = color;
  barSpan.textContent = e.target.innerWidth;
}, 500)

window.addEventListener('resize',callback);
</script>