Перейти к содержимому

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;
}
// usage
const 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 сбрасывает pending
d(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() — можно условно показывать spinner
const 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 → с опциями → тесты граничных случаев.