@ayonli/jsext
Version:
A JavaScript extension package for building strong and modern applications.
198 lines (183 loc) • 5.86 kB
text/typescript
/**
* Debounces function calls for frequent access.
* @module
*/
import { AsyncTask, asyncTask } from "./async.ts";
type Debounce = {
for: any;
tasks: AsyncTask<any>[];
data: any;
timer: NodeJS.Timeout | number | undefined;
};
const registry = new Map<any, Debounce>();
/**
* Options for the {@link debounce} function.
*/
export interface DebounceOptions {
/**
* The delay duration (in milliseconds) to wait before invoking the
* handler function.
*/
delay: number,
/**
* Use the debounce strategy `for` the given key, this will keep the
* debounce context in a global registry, binding new `handler` function
* for the same key will override the previous settings. This mechanism
* guarantees that both creating the debounced function in function
* scopes and overwriting the handler are possible.
*/
for?: any;
}
/**
* Creates a debounced function that delays invoking `handler` until after
* `delay` duration (in milliseconds) have elapsed since the last time the
* debounced function was invoked.
*
* If a subsequent call happens within the `delay` duration (in milliseconds),
* the previous call will be canceled and it will result in the same return
* value as the new call's.
*
* Optionally, we can provide a `reducer` function to merge data before
* processing so multiple calls can be merged into one.
*
* @example
* ```ts
* import debounce from "@ayonli/jsext/debounce";
* import { sleep } from "@ayonli/jsext/async";
*
* let count = 0;
*
* const fn = debounce((obj: { foo?: string; bar?: string }) => {
* count++;
* return obj;
* }, 1_000);
*
* const [res1, res2] = await Promise.all([
* fn({ foo: "hello", bar: "world" }),
* sleep(100).then(() => fn({ foo: "hi" })),
* ]);
*
* console.log(res1); // { foo: "hi" }
* console.log(res2); // { foo: "hi" }
* console.log(count); // 1
* ```
*
* @example
* ```ts
* // with reducer
* import debounce from "@ayonli/jsext/debounce";
*
* const fn = debounce((obj: { foo?: string; bar?: string }) => {
* return obj;
* }, 1_000, (prev, curr) => {
* return { ...prev, ...curr };
* });
*
* const [res1, res2] = await Promise.all([
* fn({ foo: "hello", bar: "world" }),
* fn({ foo: "hi" }),
* ]);
*
* console.log(res1); // { foo: "hi", bar: "world" }
* console.assert(res2 === res1);
* ```
*/
export default function debounce<I, T, R>(
handler: (this: I, data: T) => R | Promise<R>,
delay: number,
reducer?: (prev: T, current: T) => T
): (this: I, data: T) => Promise<R>;
/**
* @example
* ```ts
* import debounce from "@ayonli/jsext/debounce";
*
* const key = "unique_key"
* let count = 0;
*
* const [res1, res2] = await Promise.all([
* debounce(async (obj: { foo?: string; bar?: string }) => {
* count += 1;
* return await Promise.resolve(obj);
* }, { delay: 5, for: "foo" }, (prev, data) => {
* return { ...prev, ...data };
* })({ foo: "hello", bar: "world" }),
*
* debounce(async (obj: { foo?: string; bar?: string }) => {
* count += 2;
* return await Promise.resolve(obj);
* }, { delay: 5, for: "foo" }, (prev, data) => {
* return { ...prev, ...data };
* })({ foo: "hi" }),
* ]);
*
* console.log(res1); // { foo: "hi", bar: "world" }
* console.assert(res1 === res2);
* console.assert(count === 2);
* ```
*/
export default function debounce<I, T, R>(
handler: (this: I, data: T) => R | Promise<R>,
options: DebounceOptions,
reducer?: (prev: T, current: T) => T
): (this: I, data: T) => Promise<R>;
export default function debounce<I, T = any, R = any>(
handler: (this: I, data: T) => R | Promise<R>,
options: number | DebounceOptions,
reducer: ((prev: T, current: T) => T) | undefined = undefined,
): (this: I, data: T) => Promise<R> {
const delay = typeof options === "number" ? options : options.delay;
const key = typeof options === "number" ? null : options.for;
const hasKey = key !== null && key !== undefined && key !== "";
let _cache = hasKey ? registry.get(key) : undefined;
if (!_cache) {
_cache = {
for: key,
tasks: [],
data: undefined,
timer: undefined,
};
if (hasKey) {
registry.set(key, _cache);
}
}
const cache = _cache;
return async function (this: I, data: T) {
if (typeof reducer === "function" && cache.data !== undefined) {
cache.data = reducer(cache.data, data);
} else {
cache.data = data;
}
cache.timer && clearTimeout(cache.timer);
cache.timer = setTimeout(() => {
// Move tasks and cached data to new variables, so during the middle
// of handler running, new calls won't affect the running process.
const _tasks = cache.tasks;
const _data = cache.data as T;
cache.tasks = [];
cache.data = undefined;
if (hasKey) {
registry.delete(key);
}
const resolve = (result: R) => {
_tasks.forEach(({ resolve }) => resolve(result));
};
const reject = (err: unknown) => {
_tasks.forEach(({ reject }) => reject(err));
};
try {
const res = handler.call(this, _data) as any;
if (typeof res?.then === "function") {
Promise.resolve(res).then(resolve, reject);
} else {
resolve(res);
}
} catch (err) {
reject(err);
}
}, delay);
const task = asyncTask<R>();
cache.tasks.push(task);
return await task;
};
}