Debounce
Debounce спрашивают на 9 из 10 frontend-собеседований: ожидается не игрушечная реализация, а полная — с опциями leading/trailing, методами cancel() и flush(), корректным this-контекстом и тестами граничных случаев. Интервьюер хочет видеть ladder: простое trailing → с опциями → тесты покрытия.
Шаг 1 — базовый trailing debounce (разминка, часто достаточно для начала):
/** * Откладывает вызов fn до истечения delay мс после последнего вызова. * @param {Function} fn * @param {number} delay — мс * @returns {Function & { cancel(): void; flush(): void }} */function debounceSimple(fn, delay) { let timerId;
function debounced(...args) { clearTimeout(timerId); timerId = setTimeout(() => { timerId = undefined; fn.apply(this, args); }, delay); }
debounced.cancel = () => { clearTimeout(timerId); timerId = undefined; };
// flush вызывает немедленно, если таймер был запущен debounced.flush = function (...args) { if (timerId !== undefined) { clearTimeout(timerId); timerId = undefined; fn.apply(this, args); } };
return debounced;}
// usageconst onResize = debounceSimple(() => recalcLayout(), 150);window.addEventListener('resize', onResize);// onResize.cancel() — отменить отложенный вызов// onResize.flush() — вызвать немедленно, если был pendingШаг 2 — полная реализация с { leading, trailing } (interview-grade):
/** * @param {Function} fn * @param {number} wait * @param {{ leading?: boolean; trailing?: boolean }} [opts] * @returns {Function & { cancel(): void; flush(): void; pending(): boolean }} */function debounce(fn, wait, { leading = false, trailing = true } = {}) { let timerId; let lastArgs; let lastThis; let result;
function invokeFunc() { const args = lastArgs; const ctx = lastThis; lastArgs = lastThis = undefined; result = fn.apply(ctx, args); return result; }
function debounced(...args) { lastArgs = args; lastThis = this;
const isLeadingInvoke = leading && timerId === undefined;
clearTimeout(timerId); timerId = setTimeout(() => { timerId = undefined; if (trailing && lastArgs !== undefined) invokeFunc(); }, wait);
if (isLeadingInvoke) invokeFunc(); return result; }
debounced.cancel = () => { clearTimeout(timerId); timerId = lastArgs = lastThis = undefined; };
debounced.flush = function (...args) { if (lastArgs === undefined && args.length) { lastArgs = args; lastThis = this; } if (timerId !== undefined) { clearTimeout(timerId); timerId = undefined; if (lastArgs) invokeFunc(); } return result; };
debounced.pending = () => timerId !== undefined;
return debounced;}
// leading: вызов сразу при первом событии, игнорирует промежуточныеconst search = debounce(fetchResults, 300, { leading: false, trailing: true });input.addEventListener('input', e => search(e.target.value));Шаг 3 — тесты граничных случаев (показать интервьюеру понимание покрытия):
// Граничные случаи debounce// ─────────────────────────
// 1. rapid-fire: только последний вызов срабатываетconst calls = [];const d = debounce(x => calls.push(x), 100);d(1); d(2); d(3);// через 100 мс calls === [3]
// 2. cancel сбрасывает pendingd(4);d.cancel();// через 100 мс calls всё ещё === [3]
// 3. flush вызывает немедленноd(5);d.flush(5);// calls === [3, 5], таймер сброшен
// 4. leading: первый вызов немедленный, trailing выключенconst clicks = [];const dLead = debounce(x => clicks.push(x), 200, { leading: true, trailing: false });dLead('a'); dLead('b'); dLead('c');// clicks === ['a'] — только первый
// 5. Сохранение this-контекстаconst obj = { val: 42, update: debounce(function () { return this.val; }, 50),};obj.update(); // this === obj ✓
// 6. pending() — можно условно показывать spinnerconst d2 = debounce(save, 500);d2(); console.assert(d2.pending() === true);d2.cancel(); console.assert(d2.pending() === false);Сложность
- Время: O(1) на каждый вызов (clearTimeout + setTimeout — константа)
- Память: O(1) — один timerId, ссылки на последние args/this
Итог: Полный debounce: trailing/leading опции, cancel(), flush(), pending() и корректный this. Ladder: базовая trailing → с опциями → тесты граничных случаев.