Debouncer.ts 6.7 KB

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