feat(async/unstable): add throttle() function (#6110)

This commit is contained in:
Simon Lecoq 2024-10-21 02:19:54 -04:00 committed by GitHub
parent d6b5612c9f
commit 0f4649d4b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 197 additions and 1 deletions

View File

@ -11,6 +11,7 @@
"./unstable-mux-async-iterator": "./unstable_mux_async_iterator.ts", "./unstable-mux-async-iterator": "./unstable_mux_async_iterator.ts",
"./pool": "./pool.ts", "./pool": "./pool.ts",
"./retry": "./retry.ts", "./retry": "./retry.ts",
"./tee": "./tee.ts" "./tee": "./tee.ts",
"./unstable-throttle": "./unstable_throttle.ts"
} }
} }

102
async/unstable_throttle.ts Normal file
View File

@ -0,0 +1,102 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.
/**
* A throttled function that will be executed at most once during the
* specified `timeframe` in milliseconds.
*/
export interface ThrottledFunction<T extends Array<unknown>> {
(...args: T): void;
/**
* Clears the throttling state.
* {@linkcode ThrottledFunction.lastExecution} will be reset to `NaN` and
* {@linkcode ThrottledFunction.throttling} will be reset to `false`.
*/
clear(): void;
/**
* Execute the last throttled call (if any) and clears the throttling state.
*/
flush(): void;
/**
* Returns a boolean indicating whether the function is currently being throttled.
*/
readonly throttling: boolean;
/**
* Returns the timestamp of the last execution of the throttled function.
* It is set to `NaN` if it has not been called yet.
*/
readonly lastExecution: number;
}
/**
* Creates a throttled function that prevents the given `func`
* from being called more than once within a given `timeframe` in milliseconds.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @example Usage
* ```ts
* import { throttle } from "./unstable_throttle.ts"
* import { retry } from "@std/async/retry"
* import { assert } from "@std/assert"
*
* let called = 0;
* await using server = Deno.serve({ port: 0, onListen:() => null }, () => new Response(`${called++}`));
*
* // A throttled function will be executed at most once during a specified ms timeframe
* const timeframe = 100
* const func = throttle<[string]>((url) => fetch(url).then(r => r.body?.cancel()), timeframe);
* for (let i = 0; i < 10; i++) {
* func(`http://localhost:${server.addr.port}/api`);
* }
*
* await retry(() => assert(!func.throttling))
* assert(called === 1)
* assert(!Number.isNaN(func.lastExecution))
* ```
*
* @typeParam T The arguments of the provided function.
* @param fn The function to throttle.
* @param timeframe The timeframe in milliseconds in which the function should be called at most once.
* @returns The throttled function.
*/
// deno-lint-ignore no-explicit-any
export function throttle<T extends Array<any>>(
fn: (this: ThrottledFunction<T>, ...args: T) => void,
timeframe: number,
): ThrottledFunction<T> {
let lastExecution = NaN;
let flush: (() => void) | null = null;
const throttled = ((...args: T) => {
flush = () => {
try {
fn.call(throttled, ...args);
} finally {
lastExecution = Date.now();
flush = null;
}
};
if (throttled.throttling) {
return;
}
flush?.();
}) as ThrottledFunction<T>;
throttled.clear = () => {
lastExecution = NaN;
};
throttled.flush = () => {
lastExecution = NaN;
flush?.();
throttled.clear();
};
Object.defineProperties(throttled, {
throttling: { get: () => Date.now() - lastExecution <= timeframe },
lastExecution: { get: () => lastExecution },
});
return throttled;
}

View File

@ -0,0 +1,93 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import {
assertEquals,
assertGreater,
assertLess,
assertNotEquals,
assertStrictEquals,
} from "@std/assert";
import { throttle, type ThrottledFunction } from "./unstable_throttle.ts";
import { delay } from "./delay.ts";
Deno.test("throttle() handles called", async () => {
let called = 0;
const t = throttle(() => called++, 100);
assertEquals(t.throttling, false);
assertEquals(t.lastExecution, NaN);
t();
const { lastExecution } = t;
t();
t();
assertLess(Math.abs(t.lastExecution - Date.now()), 100);
assertEquals(called, 1);
assertEquals(t.throttling, true);
assertEquals(t.lastExecution, lastExecution);
await delay(200);
assertEquals(called, 1);
assertEquals(t.throttling, false);
assertEquals(t.lastExecution, lastExecution);
t();
assertEquals(called, 2);
assertEquals(t.throttling, true);
assertGreater(t.lastExecution, lastExecution);
});
Deno.test("throttle() handles cancelled", () => {
let called = 0;
const t = throttle(() => called++, 100);
t();
t();
t();
assertEquals(called, 1);
assertEquals(t.throttling, true);
assertNotEquals(t.lastExecution, NaN);
t.clear();
assertEquals(called, 1);
assertEquals(t.throttling, false);
assertEquals(t.lastExecution, NaN);
});
Deno.test("debounce() handles flush", () => {
let called = 0;
let arg = "";
const t = throttle((_arg) => {
arg = _arg;
called++;
}, 100);
t("foo");
t("bar");
t("baz");
assertEquals(called, 1);
assertEquals(arg, "foo");
assertEquals(t.throttling, true);
assertNotEquals(t.lastExecution, NaN);
for (const _ of [1, 2]) {
t.flush();
assertEquals(called, 2);
assertEquals(arg, "baz");
assertEquals(t.throttling, false);
assertEquals(t.lastExecution, NaN);
}
});
Deno.test("throttle() handles params and context", async () => {
const params: Array<string | number> = [];
const t: ThrottledFunction<[string, number]> = throttle(
function (param1: string, param2: number) {
params.push(param1);
params.push(param2);
assertStrictEquals(t, this);
},
100,
);
t("foo", 1);
t("bar", 1);
t("baz", 1);
// @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'string'.
t(1, 1);
assertEquals(params, ["foo", 1]);
assertEquals(t.throttling, true);
await delay(200);
assertEquals(params, ["foo", 1]);
assertEquals(t.throttling, false);
});