Debouncer.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. /**
  2. * @module lib/Debouncer
  3. * This module contains the Debouncer class and debounce function that allow you to reduce the amount of calls in rapidly firing event listeners and such - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer)
  4. */
  5. import { NanoEmitter } from "./NanoEmitter.js";
  6. //#region types
  7. /**
  8. * The type of edge to use for the debouncer - [see the docs for a diagram and explanation.](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer)
  9. * - `immediate` - (default & recommended) - calls the listeners at the very first call ("rising" edge) and queues the latest call until the timeout expires
  10. * - Pros:
  11. * - First call is let through immediately
  12. * - Cons:
  13. * - After all calls stop, the JS engine's event loop will continue to run until the last timeout expires (doesn't really matter on the web, but could cause a process exit delay in Node.js)
  14. * - `idle` - queues all calls until there are no more calls in the given timeout duration ("falling" edge), and only then executes the very last call
  15. * - Pros:
  16. * - Makes sure there are zero calls in the given `timeoutDuration` before executing the last call
  17. * - Cons:
  18. * - Calls are always delayed by at least `1 * timeoutDuration`
  19. * - Calls could get stuck in the queue indefinitely if there is no downtime between calls that is greater than the `timeoutDuration`
  20. */
  21. export type DebouncerType = "immediate" | "idle";
  22. export type DebouncerFunc<TArgs> = (...args: TArgs[]) => void | unknown;
  23. /** Event map for the {@linkcode Debouncer} */
  24. export type DebouncerEventMap<TArgs> = {
  25. /** Emitted when the debouncer calls all registered listeners, as a pub-sub alternative */
  26. call: DebouncerFunc<TArgs>;
  27. /** Emitted when the timeout or edge type is changed after the instance was created */
  28. change: (timeout: number, type: DebouncerType) => void;
  29. };
  30. //#region debounce class
  31. /**
  32. * A debouncer that calls all listeners after a specified timeout, discarding all calls in-between.
  33. * It is very useful for event listeners that fire quickly, like `input` or `mousemove`, to prevent the listeners from being called too often and hogging resources.
  34. * The exact behavior can be customized with the `type` parameter.
  35. *
  36. * The instance inherits from {@linkcode NanoEmitter} and emits the following events:
  37. * - `call` - emitted when the debouncer calls all listeners - use this as a pub-sub alternative to the default callback-style listeners
  38. * - `change` - emitted when the timeout or edge type is changed after the instance was created
  39. */
  40. export class Debouncer<TArgs> extends NanoEmitter<DebouncerEventMap<TArgs>> {
  41. /** All registered listener functions and the time they were attached */
  42. protected listeners: DebouncerFunc<TArgs>[] = [];
  43. /** The currently active timeout */
  44. protected activeTimeout: ReturnType<typeof setTimeout> | undefined;
  45. /** The latest queued call */
  46. protected queuedCall: DebouncerFunc<TArgs> | undefined;
  47. /**
  48. * Creates a new debouncer with the specified timeout and edge type.
  49. * @param timeout Timeout in milliseconds between letting through calls - defaults to 200
  50. * @param type The edge type to use for the debouncer - see {@linkcode DebouncerType} for details or [the documentation for an explanation and diagram](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer) - defaults to "immediate"
  51. */
  52. constructor(protected timeout = 200, protected type: DebouncerType = "immediate") {
  53. super();
  54. }
  55. //#region listeners
  56. /** Adds a listener function that will be called on timeout */
  57. public addListener(fn: DebouncerFunc<TArgs>): void {
  58. this.listeners.push(fn);
  59. }
  60. /** Removes the listener with the specified function reference */
  61. public removeListener(fn: DebouncerFunc<TArgs>): void {
  62. const idx = this.listeners.findIndex((l) => l === fn);
  63. idx !== -1 && this.listeners.splice(idx, 1);
  64. }
  65. /** Removes all listeners */
  66. public removeAllListeners(): void {
  67. this.listeners = [];
  68. }
  69. //#region timeout
  70. /** Sets the timeout for the debouncer */
  71. public setTimeout(timeout: number): void {
  72. this.emit("change", this.timeout = timeout, this.type);
  73. }
  74. /** Returns the current timeout */
  75. public getTimeout(): number {
  76. return this.timeout;
  77. }
  78. /** Whether the timeout is currently active, meaning any latest call to the {@linkcode call()} method will be queued */
  79. public isTimeoutActive(): boolean {
  80. return typeof this.activeTimeout !== "undefined";
  81. }
  82. //#region type
  83. /** Sets the edge type for the debouncer */
  84. public setType(type: DebouncerType): void {
  85. this.emit("change", this.timeout, this.type = type);
  86. }
  87. /** Returns the current edge type */
  88. public getType(): DebouncerType {
  89. return this.type;
  90. }
  91. //#region call
  92. /** Use this to call the debouncer with the specified arguments that will be passed to all listener functions registered with {@linkcode addListener()} */
  93. public call(...args: TArgs[]): void {
  94. /** When called, calls all registered listeners */
  95. const cl = (...a: TArgs[]) => {
  96. this.queuedCall = undefined;
  97. this.emit("call", ...a);
  98. this.listeners.forEach((l) => l.apply(this, a));
  99. };
  100. /** Sets a timeout that will call the latest queued call and then set another timeout if there was a queued call */
  101. const setRepeatTimeout = () => {
  102. this.activeTimeout = setTimeout(() => {
  103. if(this.queuedCall) {
  104. this.queuedCall();
  105. setRepeatTimeout();
  106. }
  107. else
  108. this.activeTimeout = undefined;
  109. }, this.timeout);
  110. };
  111. switch(this.type) {
  112. case "immediate":
  113. if(typeof this.activeTimeout === "undefined") {
  114. cl(...args);
  115. setRepeatTimeout();
  116. }
  117. else
  118. this.queuedCall = () => cl(...args);
  119. break;
  120. case "idle":
  121. if(this.activeTimeout)
  122. clearTimeout(this.activeTimeout);
  123. this.activeTimeout = setTimeout(() => {
  124. cl(...args);
  125. this.activeTimeout = undefined;
  126. }, this.timeout);
  127. break;
  128. default:
  129. throw new Error(`Invalid debouncer type: ${this.type}`);
  130. }
  131. }
  132. }
  133. //#region debounce fn
  134. /**
  135. * Creates a {@linkcode Debouncer} instance with the specified timeout and edge type and attaches the passed function as a listener.
  136. * The returned function can be called with any arguments and will execute the `call()` method of the debouncer.
  137. * The debouncer instance is accessible via the `debouncer` property of the returned function.
  138. *
  139. * Refer to the {@linkcode Debouncer} class definition or the [Debouncer documentation](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer) for more information.
  140. */
  141. export function debounce<
  142. TFunc extends DebouncerFunc<TArgs>,
  143. TArgs,
  144. > (
  145. fn: TFunc,
  146. timeout = 200,
  147. type: DebouncerType = "immediate"
  148. ): DebouncerFunc<TArgs> & { debouncer: Debouncer<TArgs> } {
  149. const debouncer = new Debouncer<TArgs>(timeout, type);
  150. debouncer.addListener(fn);
  151. const func = (...args: TArgs[]) => debouncer.call(...args);
  152. func.debouncer = debouncer;
  153. return func;
  154. }