feat(debounce): Support AbortSignal to cancel debounced functions for improved cancellation (#45)

* feat: Support AbortSignal to debounce for improved cancellation

* refactor: cancel timeoutId & add strict inequality

* fix: formatting in package.json

* refactor: using optional chaining

* fix: follow the fetch API's option

* docs: modify debounce ko, en docs
This commit is contained in:
hanna 2024-06-14 08:04:05 +09:00 committed by GitHub
parent fde86f7f4b
commit a707c06f7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 139 additions and 10 deletions

View File

@ -7,13 +7,19 @@ debounce된 함수는 또한 대기 중인 실행을 취소하는 `cancel` 메
## 인터페이스
```typescript
function debounce<F extends (...args: any[]) => void>(func: F, debounceMs: number): F & { cancel: () => void };
function debounce<F extends (...args: any[]) => void>(
func: F,
debounceMs: number,
options?: DebounceOptions
): F & { cancel: () => void };
```
### 파라미터
- `func` (`F`): debounce된 함수를 만들 함수.
- `debounceMs`(`number`): debounce로 지연시킬 밀리초.
- `options` (`DebounceOptions`, optional): 옵션 객체.
- `signal` (`AbortSignal`, optional): debounce된 함수를 취소하기 위한 선택적 `AbortSignal`.
### 결괏값
@ -21,6 +27,8 @@ function debounce<F extends (...args: any[]) => void>(func: F, debounceMs: numbe
## 예시
### 기본 사용법
```typescript
const debouncedFunction = debounce(() => {
console.log('실행됨');
@ -32,3 +40,23 @@ debouncedFunction();
// 이전 호출이 취소되었으므로, 아무것도 로깅하지 않아요
debouncedFunction.cancel();
```
### AbortSignal 사용법
```typescript
const controller = new AbortController();
const signal = controller.signal;
const debouncedWithSignalFunction = debounce(
() => {
console.log('Function executed');
},
1000,
{ signal }
);
// 1초 안에 다시 호출되지 않으면, '실행됨'을 로깅해요
debouncedWithSignalFunction();
// debounce 함수 호출을 취소해요
controller.abort();
```

View File

@ -7,13 +7,19 @@ method to cancel any pending execution.
## Signature
```typescript
function debounce<F extends (...args: any[]) => void>(func: F, debounceMs: number): F & { cancel: () => void };
function debounce<F extends (...args: any[]) => void>(
func: F,
debounceMs: number,
options?: DebounceOptions
): F & { cancel: () => void };
```
### Parameters
- `func` (`F`): The function to debounce.
- `debounceMs`(`number`): The number of milliseconds to delay.
- `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.
### Returns
@ -21,6 +27,8 @@ function debounce<F extends (...args: any[]) => void>(func: F, debounceMs: numbe
## Examples
### Basic Usage
```typescript
const debouncedFunction = debounce(() => {
console.log('Function executed');
@ -32,3 +40,23 @@ debouncedFunction();
// Will not log anything as the previous call is canceled
debouncedFunction.cancel();
```
### Using with an AbortSignal
```typescript
const controller = new AbortController();
const signal = controller.signal;
const debouncedWithSignalFunction = debounce(
() => {
console.log('Function executed');
},
1000,
{ signal }
);
// Will log 'Function executed' after 1 second if not called again in that time
debouncedWithSignalFunction();
// Will cancel the debounced function call
controller.abort();
```

View File

@ -130,4 +130,4 @@
"lint": "eslint ./src --ext .ts",
"format": "prettier --write ."
}
}
}

View File

@ -94,4 +94,41 @@ describe('debounce', () => {
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith('test', 123);
});
it('should cancel the debounced function call if aborted via AbortSignal', async () => {
const func = vi.fn();
const debounceMs = 50;
const controller = new AbortController();
const signal = controller.signal;
const debouncedFunc = debounce(func, debounceMs, { signal });
debouncedFunc();
controller.abort();
await delay(debounceMs);
expect(func).not.toHaveBeenCalled();
});
it('should not add multiple abort event listeners', async () => {
const func = vi.fn();
const debounceMs = 100;
const controller = new AbortController();
const signal = controller.signal;
const addEventListenerSpy = vi.spyOn(signal, 'addEventListener');
const debouncedFunc = debounce(func, debounceMs, { signal });
debouncedFunc();
debouncedFunc();
await new Promise(resolve => setTimeout(resolve, 150));
expect(func).toHaveBeenCalledTimes(1);
const listenerCount = addEventListenerSpy.mock.calls.filter(([event]) => event === 'abort').length;
expect(listenerCount).toBe(1);
addEventListenerSpy.mockRestore();
});
});

View File

@ -1,3 +1,7 @@
interface DebounceOptions {
signal?: AbortSignal;
}
/**
* Creates a debounced function that delays invoking the provided function until after `debounceMs` milliseconds
* have elapsed since the last time the debounced function was invoked. The debounced function also has a `cancel`
@ -5,6 +9,8 @@
*
* @param {F} func - The function to debounce.
* @param {number} debounceMs - The number of milliseconds to delay.
* @param {DebounceOptions} options - The options object.
* @param {AbortSignal} options.signal - An optional AbortSignal to cancel the debounced function.
* @returns {F & { cancel: () => void }} A new debounced function with a `cancel` method.
*
* @example
@ -17,25 +23,55 @@
*
* // Will not log anything as the previous call is canceled
* debouncedFunction.cancel();
*
* // With AbortSignal
* const controller = new AbortController();
* const signal = controller.signal;
* const debouncedWithSignal = debounce(() => {
* console.log('Function executed');
* }, 1000, { signal });
*
* debouncedWithSignal();
*
* // Will cancel the debounced function call
* controller.abort();
*/
export function debounce<F extends (...args: any[]) => void>(func: F, debounceMs: number): F & { cancel: () => void } {
export function debounce<F extends (...args: any[]) => void>(
func: F,
debounceMs: number,
{ signal }: DebounceOptions = {}
): F & { cancel: () => void } {
let timeoutId: number | NodeJS.Timeout | null = null;
const debounced = function (...args: Parameters<F>) {
if (timeoutId != null) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
if (signal?.aborted) {
return;
}
timeoutId = setTimeout(() => {
func(...args);
timeoutId = null;
}, debounceMs);
} as F & { cancel: () => void };
debounced.cancel = function () {
if (timeoutId != null) {
clearTimeout(timeoutId);
}
const onAbort = function () {
debounced.cancel();
};
debounced.cancel = function () {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
signal?.removeEventListener('abort', onAbort);
};
signal?.addEventListener('abort', onAbort);
return debounced;
}