-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathutil.debounce.mjs
More file actions
349 lines (321 loc) · 10.7 KB
/
util.debounce.mjs
File metadata and controls
349 lines (321 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
/**
* @fileoverview Debounce Utilities - Rate-limiting for frequent events
* @module util.debounce
* @version 2.0.0
* @author hnldesign
* @since 2022
*
* @description
* Provides debouncing and throttling utilities for managing high-frequency events.
* Supports start/during/end execution phases, cleanup functions, and debug integration.
*
* Features:
* - Function wrapper debouncing via debounceThis()
* - Direct event listener debouncing via debouncedEvent()
* - Phase control (start/during/end execution)
* - Cleanup functions for memory management
* - Debug mode integration with logging
*
* @example
* import {debounceThis, debouncedEvent} from './util.debounce.mjs';
*
* // Wrap function
* const debouncedFn = debounceThis(handleResize, {threshold: 150});
* window.addEventListener('resize', debouncedFn);
*
* // Direct listener with cleanup
* const cleanup = debouncedEvent(window, 'scroll', handleScroll, {
* delay: 100,
* after: true,
* during: true
* });
* // Later: cleanup();
*/
import {logger, DEBUG} from './core.log.mjs';
export const NAME = 'debounce';
// ============================================================================
// CONSTANTS
// ============================================================================
/**
* Default debounce configuration
* @private
* @const {Object}
*/
const DEFAULT_CONFIG = {
threshold: 100,
execStart: false,
execWhile: false,
execDone: true
};
// ============================================================================
// VALIDATION UTILITIES
// ============================================================================
/**
* Validates callback is a function
* @private
* @param {*} callback - Value to validate
* @throws {TypeError} If callback is not a function
*/
function validateCallback(callback) {
if (typeof callback !== 'function') {
throw new TypeError('Callback must be a function');
}
}
/**
* Validates event string parameter
* @private
* @param {*} events - Value to validate
* @throws {TypeError} If events is not a non-empty string
*/
function validateEvents(events) {
if (typeof events !== 'string' || !events.trim()) {
throw new TypeError('Events parameter must be a non-empty string');
}
}
/**
* Validates target has addEventListener method
* @private
* @param {*} target - Value to validate
* @throws {TypeError} If target doesn't support addEventListener
*/
function validateTarget(target) {
if (!target || typeof target.addEventListener !== 'function') {
throw new TypeError('Target must support addEventListener');
}
}
// ============================================================================
// PUBLIC API
// ============================================================================
/**
* Wraps a function with debounce/throttle behavior.
*
* Creates a debounced wrapper that controls when the callback executes relative
* to event timing. Supports execution at start, during (throttled), and end phases.
* Event objects receive a `debounceType` property indicating execution phase.
*
* @param {Function} callback - Function to debounce
* @param {Object} [options] - Debounce configuration
* @param {number} [options.threshold=100] - Wait time in milliseconds
* @param {boolean} [options.execStart=false] - Execute on first event
* @param {boolean} [options.execWhile=false] - Execute during event sequence (throttled)
* @param {boolean} [options.execDone=true] - Execute after events stop
* @returns {Function} Debounced wrapper function
*
* @throws {TypeError} If callback is not a function
*
* @example
* // Basic debounce (fires after 150ms of inactivity)
* const debouncedResize = debounceThis(() => {
* console.log('Window resized');
* }, {threshold: 150});
* window.addEventListener('resize', debouncedResize);
*
* @example
* // Throttle (fires immediately, then waits)
* const throttledScroll = debounceThis((e) => {
* console.log('Scroll position:', window.scrollY);
* }, {
* threshold: 100,
* execStart: true,
* execDone: false
* });
* window.addEventListener('scroll', throttledScroll);
*
* @example
* // All phases (start + throttle + end)
* const allPhases = debounceThis((e) => {
* console.log('Phase:', e.debounceType); // 'start', 'while', or 'done'
* }, {
* threshold: 200,
* execStart: true,
* execWhile: true,
* execDone: true
* });
* window.addEventListener('input', allPhases);
*/
export function debounceThis(callback, options = {}) {
validateCallback(callback);
const config = {
...DEFAULT_CONFIG,
...options,
// Internal state
timer: 0,
whileTimer: 0,
busy: false
};
return function debounced(...args) {
clearTimeout(config.timer);
// Start phase: first event in sequence
if (!config.busy && config.execStart) {
if (args[0]) args[0].debounceType = 'start';
callback.apply(this, args);
config.busy = true;
}
// While phase: throttled execution during sequence
if (config.execWhile && !config.whileTimer) {
config.whileTimer = setTimeout(() => {
if (args[0]) args[0].debounceType = 'while';
callback.apply(this, args);
config.whileTimer = 0;
}, config.threshold);
}
// Done phase: after sequence ends
config.timer = setTimeout(() => {
if (args[0]) args[0].debounceType = 'done';
config.busy = false;
if (config.execDone) callback.apply(this, args);
clearTimeout(config.whileTimer);
config.whileTimer = 0;
}, config.threshold);
};
}
/**
* Creates debounced event listener with automatic cleanup.
*
* Attaches listener directly to target with debounce/throttle behavior.
* Returns cleanup function that removes listeners and clears timers.
* Event objects include `debounceStateFinal` boolean indicating final execution.
*
* @param {EventTarget} target - Element or window to attach listener
* @param {string} events - Comma-separated event names ('resize, scroll')
* @param {Function} callback - Function to execute
* @param {Object|number} [options] - Configuration or delay (backward compat)
* @param {number} [options.delay=100] - Debounce delay in milliseconds
* @param {boolean} [options.after=true] - Fire after event sequence ends
* @param {boolean} [options.during=false] - Fire continuously during sequence
* @returns {Function} Cleanup function that removes listeners and clears timers
*
* @throws {TypeError} If target, events, or callback are invalid
*
* @example
* // Basic debounce (fires after scroll stops)
* const cleanup = debouncedEvent(window, 'scroll', () => {
* console.log('Scroll stopped');
* });
* // Later: cleanup();
*
* @example
* // Multiple events
* const cleanup = debouncedEvent(window, 'resize, orientationchange', () => {
* recalculateLayout();
* }, {delay: 150});
*
* @example
* // Throttle (fires during + after)
* const cleanup = debouncedEvent(document, 'mousemove', (e) => {
* if (e.debounceStateFinal) {
* console.log('Movement stopped');
* } else {
* console.log('Still moving...');
* }
* }, {
* delay: 50,
* after: true,
* during: true
* });
*
* @example
* // Immediate execution only (throttle pattern)
* const cleanup = debouncedEvent(window, 'scroll', () => {
* updateScrollIndicator();
* }, {
* delay: 100,
* after: false,
* during: false
* });
*/
export function debouncedEvent(target, events, callback, options, ...legacyParams) {
// Input validation
validateTarget(target);
validateEvents(events);
validateCallback(callback);
// Backward compatibility: convert old signature (delay, after, during) to options object
let config;
if (typeof options === 'number' || legacyParams.length > 0) {
if (DEBUG) {
logger.warn(NAME,
'debouncedEvent(target, events, callback, delay, after, during) signature is deprecated. ' +
'Use debouncedEvent(target, events, callback, {delay, after, during}) instead.'
);
}
config = {
delay: typeof options === 'number' ? options : 100,
after: legacyParams[0] !== undefined ? legacyParams[0] : true,
during: legacyParams[1] !== undefined ? legacyParams[1] : false
};
} else {
config = {
delay: 100,
after: true,
during: false,
...options
};
}
// Parse comma-separated events
const eventList = events.split(',').map(e => e.trim()).filter(Boolean);
// Internal state
let timeoutId = 0;
let intervalId = 0;
let isThrottled = false;
let lastEvent = null;
/**
* Creates event wrapper with debounce metadata
* @private
*/
const createEventData = (originalEvent, isFinal) => ({
...originalEvent,
debounceStateFinal: isFinal,
originalEvent
});
/**
* Event handler with debounce logic
* @private
*/
const handler = (e) => {
lastEvent = e;
clearTimeout(timeoutId);
if (config.after) {
// Start interval for 'during' callbacks
if (config.during && !intervalId) {
intervalId = setInterval(() => {
callback(createEventData(lastEvent, false));
}, config.delay);
}
// Schedule final callback
timeoutId = setTimeout(() => {
clearInterval(intervalId);
intervalId = 0;
callback(createEventData(lastEvent, true));
}, config.delay);
} else {
// Immediate firing mode (throttle)
if (!isThrottled) {
callback(createEventData(lastEvent, true));
isThrottled = true;
if (config.during && !intervalId) {
intervalId = setInterval(() => {
callback(createEventData(lastEvent, false));
}, config.delay);
}
}
timeoutId = setTimeout(() => {
clearInterval(intervalId);
intervalId = 0;
isThrottled = false;
}, config.delay);
}
};
// Attach listeners
const listenerOptions = {passive: true};
eventList.forEach(event => {
target.addEventListener(event, handler, listenerOptions);
});
// Return cleanup function
return function cleanup() {
clearTimeout(timeoutId);
clearInterval(intervalId);
eventList.forEach(event => {
target.removeEventListener(event, handler, listenerOptions);
});
};
}