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 вызов.

Связанные темы

Ресурсы