Argano の小林です。本記事では Web におけるデバウンス(Debounce)とその実装について説明します。
デバウンスとは?
MDN では、デバウンスは次のように説明されています。
プログラミングの文脈におけるデバウンスとは、特定の時間間隔でリクエストされたすべての演算を単一の呼び出しに「バッチ化」するということを意味しています。
例として以下の要件をもつ検索アプリを考えてみましょう。
- ユーザーは検索窓に検索したいキーワードを入力することで検索を行う
- ユーザーが入力を行っている最中に、入力された文字列に応じた検索候補をサジェストする
- 検索候補は Web API を用いて取得される
このアプリを愚直に実装すると、argano とユーザーが素早く入力したときに
aが入力されるaに対する検索候補を取得するrが入力され、検索文字列がarとなるarに対する検索候補を取得するgが入力され、検索文字列がargとなるargに対する検索候補を取得する- (以下略)
のように動作し、1文字入力される度に検索候補の取得が行われることになります。
しかし実際には a や arg などの途中で入力された文字列は一瞬しか検索窓に存在しないので、 これらの文字列に対する検索候補を取得・表示する必要はないと考えられます。また、API サーバーに対する検索候補の取得リクエストが文字列の長さに等しい回数行われることになるため、サーバーの負荷も無駄に増えてしまいます。
ユーザーが最後に文字列を入力した時点から一定時間経過したタイミングで検索候補を取得するようにすると、不必要なリクエストが行われなくなります。このように、特定の時間間隔で連続して呼び出された処理を、単一の処理にまとめる手法をデバウンスと呼びます。
既存の実装を読む
デバウンスは様々なライブラリで実装されていますが、ここでは TypeScript による実装の unjs/perfect-debounce を見てみます。
perfect-debounce では、提供されている debounce 関数に、
- デバウンスを行う対象の関数
fn - デバウンスを行う期間である
wait - 先端、終端で
fnを即座に実行するかを表すoptions
の3 つを渡すことで、fn がラップされた関数 debounced を取得することができます。あとは fn の代わりに debounced を呼び出すことで、デバウンスを用いて fn を実行できます。
ここで「先端」とはある期間での中の最初の fn の呼び出しのこと、「終端」は最後に fn が呼び出されてから wait ms 経過したときの、まとめた fn の呼び出しのことを指します。
では内部ではどのようにデバウンスが実装されているでしょうか?ラップされた関数 debounced の実体は、index.ts の 66 行目から 122 行目の次の部分です。
// Last result for leading value
let leadingValue: PromiseLike<ReturnT> | ReturnT;
// Debounce timeout handle
let timeout: NodeJS.Timeout;
// Promises to be resolved when debounce if finished
let resolveList: Array<(val: unknown) => void> = [];
// Keep state of currently resolving promise
let currentPromise: Promise<ReturnT>;
// Trailing call info
let trailingArgs: any[];
const applyFn = (_this, args) => {
currentPromise = _applyPromised(fn, _this, args);
currentPromise.finally(() => {
currentPromise = null;
if (options.trailing && trailingArgs && !timeout) {
const promise = applyFn(_this, trailingArgs);
trailingArgs = null;
return promise;
}
});
return currentPromise;
};
const debounced = function (...args: ArgumentsT) {
if (options.trailing) {
trailingArgs = args;
}
if (currentPromise) {
return currentPromise;
}
return new Promise<ReturnT>((resolve) => {
const shouldCallNow = !timeout && options.leading;
clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
const promise = options.leading ? leadingValue : applyFn(this, args);
trailingArgs = null;
for (const _resolve of resolveList) {
_resolve(promise);
}
resolveList = [];
}, wait);
if (shouldCallNow) {
leadingValue = applyFn(this, args);
resolve(leadingValue);
} else {
resolveList.push(resolve);
}
});
} as DebouncedReturn<ArgumentsT, ReturnT>;
まず、先端とそれ以降の debounced の呼び出しでは、前回の呼び出しで設定されたタイマーが存在するならばクリアし、 新たに wait ミリ秒後に満了し、fn を実行するタイマーを作り直します。この処理によって、debounced の内部には同時に高々 1 個のタイマーが保持されることになります。また、debounced は Promise を返却しますが、この Promise の resolve をバッチ呼び出しを行う期間中 resolveList に保持しておきます。
その後終端に達する、すなわち保持しているタイマーが満了すると、最後の debounced の呼び出しで渡された引数を用いて実際に fn を実行します。それと同時に、resolveList に保持していたこれまでの resolve 関数に対して、まとめて実行された fn に対応する Promise を渡します。これにより、期間中に何回 debounced が呼び出されても、実際の fn の呼び出しはバッチ化されて1回に抑えつつ、すべての debounced の呼び出し元へ同じ結果を返却することができます。
また、終端に達し、まとめた fn の実行中に debounced を呼び出すと、実行中の fn に対応する Promise を返します。これは一見すると呼び出しを無視しているように思えますが、Promise を返却する前に trailingArgs に引数を保存することで、fn の実行後の処理(84 行目から 89 行目)で遅延して fn を実行しています。
おわりに
本記事では Web アプリケーションにおけるデバウンスの概念と実装を見ていきました。デバウンスは検索候補のサジェスト以外にもウィンドウのリサイズ時のイベントハンドラ、フォームのバリデーションなど適用できる場面は複数あります。必要なときに思い出して頂けると幸いです。
