diff --git a/packages/shared/useDebounceFn/index.md b/packages/shared/useDebounceFn/index.md index b78e6ef97bc..65d12fc4ba6 100644 --- a/packages/shared/useDebounceFn/index.md +++ b/packages/shared/useDebounceFn/index.md @@ -35,7 +35,40 @@ const debouncedFn = useDebounceFn(() => { window.addEventListener('resize', debouncedFn) ``` +Optionally, you can get the return value of the function using promise operations. +```js +import { useDebounceFn } from '@vueuse/core' +const debouncedRequest = useDebounceFn(() => 'response', 1000) + +debouncedRequest().then((value) => { + console.log(value) // 'response' +}) + +// or use async/await +async function doRequest() { + const value = await debouncedRequest() + console.log(value) // 'response' +} +``` + +Since unhandled rejection error is quite annoying when developer doesn't need the return value, the promise will **NOT** be rejected if the function is canceled **by default**. You need to specify the option `rejectOnCancel: true` to capture the rejection. + +```js +import { useDebounceFn } from '@vueuse/core' +const debouncedRequest = useDebounceFn(() => 'response', 1000, { rejectOnCancel: true }) + +debouncedRequest() + .then((value) => { + // do something + }) + .catch(() => { + // do something when canceled + }) + +// calling it again will cancel the previous request and gets rejected +setTimeout(debouncedRequest, 500) +``` ## Recommended Reading - [**Debounce vs Throttle**: Definitive Visual Guide](https://redd.one/blog/debounce-vs-throttle) diff --git a/packages/shared/useDebounceFn/index.ts b/packages/shared/useDebounceFn/index.ts index 7048c0e5dd9..b8e333cc383 100644 --- a/packages/shared/useDebounceFn/index.ts +++ b/packages/shared/useDebounceFn/index.ts @@ -1,4 +1,4 @@ -import type { DebounceFilterOptions, FunctionArgs, MaybeComputedRef } from '../utils' +import type { DebounceFilterOptions, FunctionArgs, MaybeComputedRef, PromisifyFn } from '../utils' import { createFilterWrapper, debounceFilter } from '../utils' /** @@ -11,7 +11,11 @@ import { createFilterWrapper, debounceFilter } from '../utils' * * @return A new, debounce, function. */ -export function useDebounceFn(fn: T, ms: MaybeComputedRef = 200, options: DebounceFilterOptions = {}): T { +export function useDebounceFn( + fn: T, + ms: MaybeComputedRef = 200, + options: DebounceFilterOptions = {}, +): PromisifyFn { return createFilterWrapper( debounceFilter(ms, options), fn, diff --git a/packages/shared/useThrottleFn/index.ts b/packages/shared/useThrottleFn/index.ts index 56103d92afb..2b997a066bd 100644 --- a/packages/shared/useThrottleFn/index.ts +++ b/packages/shared/useThrottleFn/index.ts @@ -1,4 +1,4 @@ -import type { FunctionArgs, MaybeComputedRef } from '../utils' +import type { FunctionArgs, MaybeComputedRef, PromisifyFn } from '../utils' import { createFilterWrapper, throttleFilter } from '../utils' /** @@ -13,11 +13,19 @@ import { createFilterWrapper, throttleFilter } from '../utils' * * @param [leading=true] if true, call fn on the leading edge of the ms timeout * + * @param [rejectOnCancel=false] if true, reject the last call if it's been cancel + * * @return A new, throttled, function. */ -export function useThrottleFn(fn: T, ms: MaybeComputedRef = 200, trailing = false, leading = true): T { +export function useThrottleFn( + fn: T, + ms: MaybeComputedRef = 200, + trailing = false, + leading = true, + rejectOnCancel = false, +): PromisifyFn { return createFilterWrapper( - throttleFilter(ms, trailing, leading), + throttleFilter(ms, trailing, leading, rejectOnCancel), fn, ) } diff --git a/packages/shared/utils/filters.ts b/packages/shared/utils/filters.ts index 4b065f47205..1ec0fd08156 100644 --- a/packages/shared/utils/filters.ts +++ b/packages/shared/utils/filters.ts @@ -1,6 +1,7 @@ import { ref } from 'vue-demi' import { resolveUnref } from '../resolveUnref' -import type { Fn, MaybeComputedRef, Pausable } from './types' +import { noop } from './is' +import type { AnyFn, ArgumentsType, MaybeComputedRef, Pausable } from './types' export type FunctionArgs = (...args: Args) => Return @@ -10,10 +11,10 @@ export interface FunctionWrapperOptions thisArg: This } -export type EventFilter = ( - invoke: Fn, +export type EventFilter = ( + invoke: Invoke, options: FunctionWrapperOptions -) => void +) => ReturnType | Promise> export interface ConfigurableEventFilter { /** @@ -30,17 +31,29 @@ export interface DebounceFilterOptions { * In milliseconds. */ maxWait?: MaybeComputedRef + + /** + * Whether to reject the last call if it's been cancel. + * + * @default false + */ + rejectOnCancel?: boolean } /** * @internal */ -export function createFilterWrapper(filter: EventFilter, fn: T) { - function wrapper(this: any, ...args: any[]) { - filter(() => fn.apply(this, args), { fn, thisArg: this, args }) +export function createFilterWrapper(filter: EventFilter, fn: T) { + function wrapper(this: any, ...args: ArgumentsType) { + return new Promise>((resolve, reject) => { + // make sure it's a promise + Promise.resolve(filter(() => fn.apply(this, args), { fn, thisArg: this, args })) + .then(resolve) + .catch(reject) + }) } - return wrapper as any as T + return wrapper } export const bypassFilter: EventFilter = (invoke) => { @@ -56,39 +69,49 @@ export const bypassFilter: EventFilter = (invoke) => { export function debounceFilter(ms: MaybeComputedRef, options: DebounceFilterOptions = {}) { let timer: ReturnType | undefined let maxTimer: ReturnType | undefined | null + let lastRejector: AnyFn = noop + + const _clearTimeout = (timer: ReturnType) => { + clearTimeout(timer) + lastRejector() + lastRejector = noop + } const filter: EventFilter = (invoke) => { const duration = resolveUnref(ms) const maxDuration = resolveUnref(options.maxWait) if (timer) - clearTimeout(timer) + _clearTimeout(timer) if (duration <= 0 || (maxDuration !== undefined && maxDuration <= 0)) { if (maxTimer) { - clearTimeout(maxTimer) + _clearTimeout(maxTimer) maxTimer = null } - return invoke() + return Promise.resolve(invoke()) } - // Create the maxTimer. Clears the regular timer on invoke - if (maxDuration && !maxTimer) { - maxTimer = setTimeout(() => { - if (timer) - clearTimeout(timer) - maxTimer = null - invoke() - }, maxDuration) - } + return new Promise((resolve, reject) => { + lastRejector = options.rejectOnCancel ? reject : resolve + // Create the maxTimer. Clears the regular timer on invoke + if (maxDuration && !maxTimer) { + maxTimer = setTimeout(() => { + if (timer) + _clearTimeout(timer) + maxTimer = null + resolve(invoke()) + }, maxDuration) + } - // Create the regular timer. Clears the max timer on invoke - timer = setTimeout(() => { - if (maxTimer) - clearTimeout(maxTimer) - maxTimer = null - invoke() - }, duration) + // Create the regular timer. Clears the max timer on invoke + timer = setTimeout(() => { + if (maxTimer) + _clearTimeout(maxTimer) + maxTimer = null + resolve(invoke()) + }, duration) + }) } return filter @@ -100,22 +123,30 @@ export function debounceFilter(ms: MaybeComputedRef, options: DebounceFi * @param ms * @param [trailing=true] * @param [leading=true] + * @param [rejectOnCancel=false] */ -export function throttleFilter(ms: MaybeComputedRef, trailing = true, leading = true) { +export function throttleFilter(ms: MaybeComputedRef, trailing = true, leading = true, rejectOnCancel = false) { let lastExec = 0 let timer: ReturnType | undefined let isLeading = true + let lastRejector: AnyFn = noop + let lastValue: any const clear = () => { if (timer) { clearTimeout(timer) timer = undefined + lastRejector() + lastRejector = noop } } - const filter: EventFilter = (invoke) => { + const filter: EventFilter = (_invoke) => { const duration = resolveUnref(ms) const elapsed = Date.now() - lastExec + const invoke = () => { + return lastValue = _invoke() + } clear() @@ -129,18 +160,22 @@ export function throttleFilter(ms: MaybeComputedRef, trailing = true, le invoke() } else if (trailing) { - timer = setTimeout(() => { - lastExec = Date.now() - isLeading = true - clear() - invoke() - }, duration - elapsed) + return new Promise((resolve, reject) => { + lastRejector = rejectOnCancel ? reject : resolve + timer = setTimeout(() => { + lastExec = Date.now() + isLeading = true + resolve(invoke()) + clear() + }, duration - elapsed) + }) } if (!leading && !timer) timer = setTimeout(() => isLeading = true, duration) isLeading = false + return lastValue } return filter diff --git a/packages/shared/utils/index.test.ts b/packages/shared/utils/index.test.ts index 452d742ff99..70bc34a22c2 100644 --- a/packages/shared/utils/index.test.ts +++ b/packages/shared/utils/index.test.ts @@ -91,6 +91,24 @@ describe('filters', () => { expect(debouncedFilterSpy).toHaveBeenCalledTimes(2) }) + it('should resolve & reject debounced fn', async () => { + const debouncedSum = createFilterWrapper( + debounceFilter(500, { rejectOnCancel: true }), + (a: number, b: number) => a + b, + ) + + const five = debouncedSum(2, 3) + let nine + setTimeout(() => { + nine = debouncedSum(4, 5) + }, 200) + + vitest.runAllTimers() + + await expect(five).rejects.toBeUndefined() + await expect(nine).resolves.toBe(9) + }) + it('should debounce with ref', () => { const debouncedFilterSpy = vitest.fn() const debounceTime = ref(0) @@ -107,8 +125,8 @@ describe('filters', () => { }) it('should throttle', () => { - const debouncedFilterSpy = vitest.fn() - const filter = createFilterWrapper(throttleFilter(1000), debouncedFilterSpy) + const throttledFilterSpy = vitest.fn() + const filter = createFilterWrapper(throttleFilter(1000), throttledFilterSpy) setTimeout(filter, 500) setTimeout(filter, 500) @@ -117,7 +135,7 @@ describe('filters', () => { vitest.runAllTimers() - expect(debouncedFilterSpy).toHaveBeenCalledTimes(2) + expect(throttledFilterSpy).toHaveBeenCalledTimes(2) }) it('should throttle evenly', () => { @@ -164,6 +182,56 @@ describe('filters', () => { expect(debouncedFilterSpy).toHaveBeenCalledTimes(1) }) + + it('should get trailing value', () => { + const sumSpy = vitest.fn((a: number, b: number) => a + b) + const throttledSum = createFilterWrapper( + throttleFilter(1000, true), + sumSpy, + ) + + let result = throttledSum(2, 3) + setTimeout(() => { result = throttledSum(4, 5) }, 600) + setTimeout(() => { result = throttledSum(6, 7) }, 900) + + vitest.runAllTimers() + + expect(sumSpy).toHaveBeenCalledTimes(2) + expect(result).resolves.toBe(6 + 7) + + setTimeout(() => { result = throttledSum(8, 9) }, 1200) + setTimeout(() => { result = throttledSum(10, 11) }, 1800) + + vitest.runAllTimers() + + expect(sumSpy).toHaveBeenCalledTimes(4) + expect(result).resolves.toBe(10 + 11) + }) + + it('should get leading value', () => { + const sumSpy = vitest.fn((a: number, b: number) => a + b) + const throttledSum = createFilterWrapper( + throttleFilter(1000, false), + sumSpy, + ) + + let result = throttledSum(2, 3) + setTimeout(() => { result = throttledSum(4, 5) }, 600) + setTimeout(() => { result = throttledSum(6, 7) }, 900) + + vitest.runAllTimers() + + expect(sumSpy).toHaveBeenCalledTimes(1) + expect(result).resolves.toBe(2 + 3) + + setTimeout(() => { result = throttledSum(8, 9) }, 1200) + setTimeout(() => { result = throttledSum(10, 11) }, 1800) + + vitest.runAllTimers() + + expect(sumSpy).toHaveBeenCalledTimes(2) + expect(result).resolves.toBe(8 + 9) + }) }) describe('is', () => { diff --git a/packages/shared/utils/types.ts b/packages/shared/utils/types.ts index db60e09ea34..462c0ce1482 100644 --- a/packages/shared/utils/types.ts +++ b/packages/shared/utils/types.ts @@ -1,10 +1,15 @@ import type { ComputedRef, Ref, WatchOptions, WatchSource } from 'vue-demi' /** - * Any function + * Void function */ export type Fn = () => void +/** + * Any function + */ +export type AnyFn = (...args: any[]) => any + /** * A ref that allow to set null or undefined */ @@ -73,6 +78,8 @@ export type Awaitable = Promise | T export type ArgumentsType = T extends (...args: infer U) => any ? U : never +export type PromisifyFn = (...args: ArgumentsType) => Promise> + export interface Pausable { /** * A ref indicate whether a pausable instance is active