Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(useDebounceFn,useThrottleFn): return result using promise #2580

Merged
merged 4 commits into from Jan 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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