Skip to content

Commit

Permalink
feat(useDebounceFn,useThrottleFn): return result using promise (#2580)
Browse files Browse the repository at this point in the history
  • Loading branch information
vaakian committed Jan 3, 2023
1 parent 162cdd4 commit 4d3051a
Show file tree
Hide file tree
Showing 6 changed files with 199 additions and 44 deletions.
33 changes: 33 additions & 0 deletions packages/shared/useDebounceFn/index.md
Expand Up @@ -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)
8 changes: 6 additions & 2 deletions 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'

/**
Expand All @@ -11,7 +11,11 @@ import { createFilterWrapper, debounceFilter } from '../utils'
*
* @return A new, debounce, function.
*/
export function useDebounceFn<T extends FunctionArgs>(fn: T, ms: MaybeComputedRef<number> = 200, options: DebounceFilterOptions = {}): T {
export function useDebounceFn<T extends FunctionArgs>(
fn: T,
ms: MaybeComputedRef<number> = 200,
options: DebounceFilterOptions = {},
): PromisifyFn<T> {
return createFilterWrapper(
debounceFilter(ms, options),
fn,
Expand Down
14 changes: 11 additions & 3 deletions 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'

/**
Expand All @@ -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<T extends FunctionArgs>(fn: T, ms: MaybeComputedRef<number> = 200, trailing = false, leading = true): T {
export function useThrottleFn<T extends FunctionArgs>(
fn: T,
ms: MaybeComputedRef<number> = 200,
trailing = false,
leading = true,
rejectOnCancel = false,
): PromisifyFn<T> {
return createFilterWrapper(
throttleFilter(ms, trailing, leading),
throttleFilter(ms, trailing, leading, rejectOnCancel),
fn,
)
}
105 changes: 70 additions & 35 deletions 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 extends any[] = any[], Return = void> = (...args: Args) => Return

Expand All @@ -10,10 +11,10 @@ export interface FunctionWrapperOptions<Args extends any[] = any[], This = any>
thisArg: This
}

export type EventFilter<Args extends any[] = any[], This = any> = (
invoke: Fn,
export type EventFilter<Args extends any[] = any[], This = any, Invoke extends AnyFn = AnyFn> = (
invoke: Invoke,
options: FunctionWrapperOptions<Args, This>
) => void
) => ReturnType<Invoke> | Promise<ReturnType<Invoke>>

export interface ConfigurableEventFilter {
/**
Expand All @@ -30,17 +31,29 @@ export interface DebounceFilterOptions {
* In milliseconds.
*/
maxWait?: MaybeComputedRef<number>

/**
* Whether to reject the last call if it's been cancel.
*
* @default false
*/
rejectOnCancel?: boolean
}

/**
* @internal
*/
export function createFilterWrapper<T extends FunctionArgs>(filter: EventFilter, fn: T) {
function wrapper(this: any, ...args: any[]) {
filter(() => fn.apply(this, args), { fn, thisArg: this, args })
export function createFilterWrapper<T extends AnyFn>(filter: EventFilter, fn: T) {
function wrapper(this: any, ...args: ArgumentsType<T>) {
return new Promise<ReturnType<T>>((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) => {
Expand All @@ -56,39 +69,49 @@ export const bypassFilter: EventFilter = (invoke) => {
export function debounceFilter(ms: MaybeComputedRef<number>, options: DebounceFilterOptions = {}) {
let timer: ReturnType<typeof setTimeout> | undefined
let maxTimer: ReturnType<typeof setTimeout> | undefined | null
let lastRejector: AnyFn = noop

const _clearTimeout = (timer: ReturnType<typeof setTimeout>) => {
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
Expand All @@ -100,22 +123,30 @@ export function debounceFilter(ms: MaybeComputedRef<number>, options: DebounceFi
* @param ms
* @param [trailing=true]
* @param [leading=true]
* @param [rejectOnCancel=false]
*/
export function throttleFilter(ms: MaybeComputedRef<number>, trailing = true, leading = true) {
export function throttleFilter(ms: MaybeComputedRef<number>, trailing = true, leading = true, rejectOnCancel = false) {
let lastExec = 0
let timer: ReturnType<typeof setTimeout> | 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()

Expand All @@ -129,18 +160,22 @@ export function throttleFilter(ms: MaybeComputedRef<number>, 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
Expand Down
74 changes: 71 additions & 3 deletions packages/shared/utils/index.test.ts
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -117,7 +135,7 @@ describe('filters', () => {

vitest.runAllTimers()

expect(debouncedFilterSpy).toHaveBeenCalledTimes(2)
expect(throttledFilterSpy).toHaveBeenCalledTimes(2)
})

it('should throttle evenly', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down

0 comments on commit 4d3051a

Please sign in to comment.