Debounce и Throttle для DOM-событий
Debounce и Throttle — техники ограничения частоты вызовов функции: debounce откладывает вызов до окончания «шума» (последний вызов спустя паузу), throttle — вызывает не чаще заданного интервала.
Зачем нужно
События input, scroll, resize, mousemove могут вызываться сотни раз в секунду. Без ограничения каждое событие запускает дорогостоящую операцию (запрос к API, пересчёт layout, DOM-манипуляции) — страница зависает. Debounce и throttle — обязательные инструменты оптимизации для таких случаев.
Где используется
- Debounce: поле поиска с автодополнением (ждём паузу в вводе), сохранение черновика, resize — финальное значение
- Throttle: скролл для infinite scroll / sticky header, mousemove для drag, кнопка «Купить» (не более раза в секунду)
- Оба: обработка ввода в реальном времени при дорогостоящей обработке
- Аналитика: троттлинг трекинг-событий, debounce отправки
Основной контент
Debounce — ждём паузу
function debounce(fn, delay) {
let timerId;
return function(...args) {
// Отменяем предыдущий таймер
clearTimeout(timerId);
// Запускаем новый
timerId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// Использование: поиск с задержкой 300ms
const searchInput = document.getElementById('search');
const handleSearch = debounce(async (event) => {
const query = event.target.value;
if (!query) return;
const results = await fetch(`/api/search?q=${query}`).then(r => r.json());
renderResults(results);
}, 300);
searchInput.addEventListener('input', handleSearch);
// API вызовется только после 300ms паузы в вводе
Throttle — ограничиваем частоту
function throttle(fn, interval) {
let lastCallTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastCallTime >= interval) {
lastCallTime = now;
fn.apply(this, args);
}
};
}
// Вариант с leading и trailing вызовами
function throttleAdvanced(fn, interval, { leading = true, trailing = true } = {}) {
let lastCallTime = 0;
let timerId = null;
return function(...args) {
const now = Date.now();
const remaining = interval - (now - lastCallTime);
if (remaining <= 0) {
if (timerId) { clearTimeout(timerId); timerId = null; }
lastCallTime = now;
if (leading) fn.apply(this, args);
} else if (trailing && !timerId) {
timerId = setTimeout(() => {
lastCallTime = Date.now();
timerId = null;
fn.apply(this, args);
}, remaining);
}
};
}
// Использование: обновление sticky-хедера при скролле
const handleScroll = throttle(() => {
const scrollY = window.scrollY;
header.classList.toggle('sticky', scrollY > 100);
}, 100); // не чаще раза в 100ms
window.addEventListener('scroll', handleScroll);
Сравнение debounce vs throttle
// Debounce: при быстром вводе 'a', 'b', 'c', 'd' (каждые 100ms), delay=300ms
// Вызов только ОДИН раз — через 300ms после 'd'
// Идеально для: поиск, автосохранение
// Throttle: при скролле 1000ms, interval=200ms
// Вызов примерно 5 раз: на 0ms, 200ms, 400ms, 600ms, 800ms
// Идеально для: scroll handler, resize, mousemove
// Отмена debounce (для cleanup в React useEffect)
function debounceWithCancel(fn, delay) {
let timerId;
const debounced = function(...args) {
clearTimeout(timerId);
timerId = setTimeout( => fn.apply(this, args), delay);
};
debounced.cancel() = () => clearTimeout(timerId);
return debounced;
}
// React пример
useEffect(() => {
const debouncedSearch = debounceWithCancel(performSearch, 300);
input.addEventListener('input', debouncedSearch);
return => {
input.removeEventListener('input', debouncedSearch);
debouncedSearch.cancel(); // отменяем pending вызов при размонтировании
};
}, );
requestAnimationFrame как throttle для визуальных задач
// Для анимаций и визуальных обновлений rAF лучше, чем throttle по времени
function rafThrottle(fn) {
let rafId = null;
return function(...args) {
if (rafId !== null) return; // уже запланировано
rafId = requestAnimationFrame(() => {
fn.apply(this, args);
rafId = null;
});
};
}
const handleMouseMove = rafThrottle((e) => {
cursor.style.left = e.clientX + 'px';
cursor.style.top = e.clientY + 'px';
});
document.addEventListener('mousemove', handleMouseMove);
Частые ошибки
- Создание debounce/throttle внутри обработчика события: каждое событие создаёт новую функцию со своим таймером — debounce не работает. Создавайте один раз вне обработчика.
- Потеря
this: стрелочная функция вfn.apply(this, args)фиксируетthisиз closure — убедитесь, что контекст корректен. - Нет cleanup: не удалённый
setTimeoutв debounce при размонтировании компонента может вызвать обработчик после уничтожения компонента. - Throttle без trailing call: последнее событие в серии (последнее значение scroll) может быть пропущено. Для критичных данных нужен trailing вызов.