feat(debounce): Add leading and trailing edge support in debounce

This commit is contained in:
Sojin Park 2024-09-16 21:23:14 +09:00
parent 09eb88676e
commit 7239073eb0
6 changed files with 97 additions and 12 deletions

View File

@ -20,6 +20,10 @@ function debounce<F extends (...args: any[]) => void>(
- `debounceMs`(`number`): デバウンスで遅延させるミリ秒。
- `options` (`DebounceOptions`, オプション): オプションオブジェクト。
- `signal` (`AbortSignal`, オプション): デバウンスされた関数をキャンセルするためのオプションの `AbortSignal`
- `edges` (`Array<'leading' | 'trailing'>`, オプション): 元の関数をいつ実行するかを示す配列。デフォルトは `['trailing']` です。
- `'leading'` が含まれている場合、デバウンスされた関数が最初に呼び出されたときに即座に元の関数を実行します。
- `'trailing'` が含まれている場合、最後のデバウンスされた関数の呼び出しから `debounceMs` ミリ秒が経過した後に元の関数を実行します。
- `'leading'``'trailing'` の両方が含まれている場合、元の関数は遅延の開始時と終了時の両方で呼び出されます。ただし、両方の時点で呼び出されるためには、デバウンスされた関数が `debounceMs` ミリ秒の間に少なくとも2回呼び出される必要があります。デバウンスされた関数を1回呼び出して元の関数を2回呼び出すことはできません。
### 戻り値
@ -69,7 +73,6 @@ controller.abort();
- `leading`: デバウンスされた関数を最初に呼び出したときに即座に元の関数を実行するかどうかです。デフォルトは`false`です。
- `trailing`: 最後のデバウンスされた関数の呼び出しから`debounceMs`ミリ秒が経過した後に元の関数を実行するかどうかです。デフォルトは`true`です。
- `leading`と`trailing`の両方が`true`の場合、元の関数は遅延の開始時と終了時の両方で呼び出されます。ただし、両方の時点で呼び出されるためには、デバウンスされた関数が`debounceMs`ミリ秒の間に少なくとも2回呼び出される必要があります。デバウンスされた関数の1回の呼び出しで元の関数が2回呼び出されることはありません。
- `debounce`関数は`maxWait`オプションも受け取ります。

View File

@ -20,6 +20,10 @@ function debounce<F extends (...args: any[]) => void>(
- `debounceMs`(`number`): debounce로 지연시킬 밀리초.
- `options` (`DebounceOptions`, optional): 옵션 객체.
- `signal` (`AbortSignal`, optional): debounce된 함수를 취소하기 위한 선택적 `AbortSignal`.
- `edges` (`Array<'leading' | 'trailing'>`, optional): 원래 함수를 언제 실행할지 나타내는 배열. 기본값은 `['trailing']`이에요.
- `'leading'`이 포함되면, 디바운스된 함수를 처음으로 호출했을 때 즉시 원래 함수를 실행해요.
- `'trailing'`이 포함되면, 마지막 디바운스된 함수 호출로부터 `debounceMs` 밀리세컨드가 지나면 원래 함수를 실행해요.
- `'leading'``'trailing'`이 모두 포함된다면, 원래 함수는 실행을 지연하기 시작할 때와 끝날 때 모두 호출돼요. 그렇지만 양쪽 시점 모두에 호출되기 위해서는, 디바운스된 함수가 `debounceMs` 밀리세컨드 사이에 최소 2번은 호출되어야 해요. 디바운스된 함수를 한 번 호출해서 원래 함수를 두 번 호출할 수는 없기 때문이에요.
### 결괏값
@ -69,7 +73,6 @@ controller.abort();
- `leading`: 디바운스된 함수를 처음으로 호출했을 때 즉시 원래 함수를 실행할지 여부예요. 기본값은 `false`예요.
- `trailing`: 마지막 디바운스된 함수 호출로부터 `debounceMs` 밀리세컨드가 지나면 원래 함수를 실행할지 여부예요. 기본값은 `true`예요.
- `leading``trailing`가 모두 `true`라면, 원래 함수는 실행을 지연하기 시작할 때와 끝날 때 모두 호출돼요. 그렇지만 양쪽 시점 모두에 호출되기 위해서는, 디바운스된 함수가 `debounceMs` 밀리세컨드 사이에 최소 2번은 호출되어야 해요. 디바운스된 함수 호출 한 번이 원래 함수를 두 번 호출할 수는 없기 때문이에요.
- `debounce` 함수는 `maxWait` 옵션도 받아요.

View File

@ -20,6 +20,10 @@ function debounce<F extends (...args: any[]) => void>(
- `debounceMs` (`number`): The number of milliseconds to delay.
- `options` (`DebounceOptions`, optional): An options object.
- `signal` (`AbortSignal`, optional): An optional `AbortSignal` to cancel the debounced function.
- `edges` (`Array<'leading' | 'trailing'>`, optional): An array specifying when the function should be called. Defaults to `['trailing']`.
- `'leading'`: If included, the function will be called immediately on the first call.
- `'trailing'`: If included, the function will be called after `debounceMs` milliseconds have passed since the last call.
- If both `'leading'` and `'trailing'` are included, the function will be called at both the start and end of the delay period. However, it must be called at least twice within `debounceMs` milliseconds for this to happen, as one debounced function call cannot trigger the function twice.
### Returns
@ -69,8 +73,6 @@ Import `debounce` from `es-toolkit/compat` for full compatibility with lodash.
- `leading`: If true, the function runs immediately on the first call. (defaults to `false`)
- `trailing`: If true, the function runs after `debounceMs` milliseconds have passed since the last call. (defaults to `true`)
- If both `leading` and `trailing` are true, the function runs at both the start and end of the delay period. However, it must be called at least twice within `debounceMs` milliseconds for this to happen, as one debounced function call cannot trigger the function twice.
- Note that since `trailing` is true by default, setting `{ leading: true }` means that both leading and trailing are true.
- The `debounce` function also accepts a `maxWait` option:

View File

@ -112,7 +112,7 @@ export function debounce<F extends (...args: any[]) => any>(
return;
}
if (leading && timeoutId == null) {
if (leading && timeoutId === null) {
result = func.apply(this, args);
} else if (maxWait != null && pendingAt != null && Date.now() - pendingAt >= maxWait) {
result = func.apply(this, args);

View File

@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { debounce } from './debounce'; // adjust the import path as necessary
import { delay } from '../promise';
@ -149,4 +149,58 @@ describe('debounce', () => {
addEventListenerSpy.mockRestore();
});
it('should call the function immediately and only once if leading is true', async () => {
const func = vi.fn();
const debounceMs = 50;
const debouncedFunc = debounce(func, debounceMs, {
edges: ['leading', 'trailing'],
});
debouncedFunc();
expect(func).toHaveBeenCalledTimes(1);
await delay(debounceMs * 2);
expect(func).toHaveBeenCalledTimes(1);
});
it('should call the function immediately and after the wait time if leading and trailing are true', async () => {
const func = vi.fn();
const debounceMs = 50;
const debouncedFunc = debounce(func, debounceMs, {
edges: ['leading', 'trailing'],
});
debouncedFunc();
debouncedFunc();
expect(func).toHaveBeenCalledTimes(1);
await delay(debounceMs * 2);
expect(func).toHaveBeenCalledTimes(2);
});
it('should not call the function immediately if leading is false', async () => {
const func = vi.fn();
const debounceMs = 50;
const debouncedFunc = debounce(func, debounceMs, { edges: ['trailing'] });
debouncedFunc();
expect(func).not.toHaveBeenCalled();
await delay(debounceMs * 2);
expect(func).toHaveBeenCalledTimes(1);
});
it('should not call the function after the wait time if no edges are given', async () => {
const func = vi.fn();
const debounceMs = 50;
const debouncedFunc = debounce(func, debounceMs, { edges: [] });
debouncedFunc();
expect(func).not.toHaveBeenCalled();
await delay(debounceMs * 2);
expect(func).not.toHaveBeenCalled();
});
});

View File

@ -3,6 +3,15 @@ interface DebounceOptions {
* An optional AbortSignal to cancel the debounced function.
*/
signal?: AbortSignal;
/**
* An array specifying when the function should be called.
* - `'leading'`: If included, the function will be called immediately on the first call.
* - `'trailing'`: If included, the function will be called after `debounceMs` milliseconds have passed since the last call.
* - If both `'leading'` and `'trailing'` are included, the function will be called at both the start and end of the delay period. However, it must be called at least twice within `debounceMs` milliseconds for this to happen, as one debounced function call cannot trigger the function twice.
* @default ['trailing']
*/
edges?: Array<'leading' | 'trailing'>;
}
/**
@ -43,22 +52,36 @@ interface DebounceOptions {
export function debounce<F extends (...args: any[]) => void>(
func: F,
debounceMs: number,
{ signal }: DebounceOptions = {}
{ signal, edges = ['trailing'] }: DebounceOptions = {}
): ((...args: Parameters<F>) => void) & { cancel: () => void } {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let pendingArgs: Parameters<F> | null = null;
const debounced = function (...args: Parameters<F>) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
const leading = edges.includes('leading');
const trailing = edges.includes('trailing');
const debounced = function (this: any, ...args: Parameters<F>) {
if (signal?.aborted) {
return;
}
if (leading && timeoutId === null) {
func.apply(this, args);
} else {
pendingArgs = args;
}
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func(...args);
timeoutId = null;
if (trailing && pendingArgs != null) {
func.apply(this, pendingArgs);
pendingArgs = null;
}
}, debounceMs);
};