js

Debounce и throttle в JavaScript: оптимизация обработчиков событий

Обработчики событий scroll, resize, input и mousemove срабатывают десятки раз в секунду. Если внутри них дёргать тяжёлую логику — пересчёт layout, запрос к API, фильтрацию большого списка — браузер начинает захлёбываться, интерфейс лагает. Debounce и throttle — два приёма, которые ограничивают частоту вызовов функции. В этой статье разберём, чем они отличаются, напишем обе реализации с нуля и посмотрим, где какую применять.

Схема работы debounce и throttle: события, debounce вызывается один раз после паузы, throttle регулярно
Debounce ждёт паузу и вызывает функцию один раз. Throttle вызывает её не чаще одного раза в интервал.

В чём разница между debounce и throttle

Оба приёма решают одну задачу — уменьшить число вызовов функции, но логика у них разная:

  • Debounce откладывает вызов до тех пор, пока события не прекратятся. Каждое новое событие сбрасывает таймер. Функция выполнится один раз — через заданную паузу после последнего события. Классика: поиск по мере ввода (отправляем запрос, когда пользователь перестал печатать).
  • Throttle гарантирует, что функция выполняется не чаще одного раза в заданный интервал, сколько бы событий ни прилетело. Классика: обработчик scroll или mousemove, где нужны регулярные обновления, но не на каждый пиксель.

Простое правило: нужен финальный результат после серии действий — debounce. Нужны регулярные обновления во время серии — throttle.


Реализация debounce с нуля

Идея: храним идентификатор таймера в замыкании. На каждый вызов сбрасываем предыдущий таймер и заводим новый. Функция сработает только если за delay миллисекунд её больше не дёргали.

// debounce: вызов функции через delay мс после ПОСЛЕДНЕГО обращения
function debounce(fn, delay = 300) {
  let timeoutId;

  return function (...args) {
    // отменяем запланированный вызов, если он ещё не произошёл
    clearTimeout(timeoutId);

    // планируем новый вызов; this сохраняем через стрелочную функцию
    timeoutId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// Применение: запрос к API только когда пользователь перестал печатать
const search = debounce((query) => {
  console.log('Отправляем запрос:', query);
  // fetch(`/api/search?q=${encodeURIComponent(query)}`)...
}, 400);

document.querySelector('#search').addEventListener('input', (e) => {
  search(e.target.value);
});

Ключевой момент — clearTimeout(timeoutId) в начале. Пока события идут плотным потоком, таймер постоянно сбрасывается и колбэк не запускается. Как только наступает пауза длиннее delay — срабатывает последний запланированный вызов.

Debounce с немедленным первым вызовом

Иногда нужно отреагировать сразу на первое событие, а последующие — игнорировать до паузы. Например, кнопка «Сохранить», которую защищаем от двойного клика. Добавим флаг immediate:

function debounce(fn, delay = 300, immediate = false) {
  let timeoutId;

  return function (...args) {
    // нужно ли вызвать прямо сейчас (таймера ещё нет)
    const callNow = immediate && !timeoutId;

    clearTimeout(timeoutId);

    timeoutId = setTimeout(() => {
      timeoutId = null;
      // при immediate=false вызываем по хвосту паузы
      if (!immediate) fn.apply(this, args);
    }, delay);

    if (callNow) fn.apply(this, args);
  };
}

// Сработает мгновенно на первый клик, повторные в течение 1с — проигнорирует
const save = debounce(() => console.log('Сохранено'), 1000, true);

Реализация throttle

Здесь храним отметку времени последнего вызова. Если с прошлого вызова прошло меньше limit миллисекунд — пропускаем. Это самый дешёвый вариант — «leading edge», вызов происходит в начале интервала.

// throttle: функция выполняется не чаще раза в limit мс
function throttle(fn, limit = 200) {
  let lastCall = 0;

  return function (...args) {
    const now = Date.now();

    // ещё не прошёл интервал — выходим, ничего не делая
    if (now - lastCall < limit) return;

    lastCall = now;
    fn.apply(this, args);
  };
}

// Применение: обновляем позицию при скролле максимум раз в 150 мс
const onScroll = throttle(() => {
  const progress = window.scrollY / document.body.scrollHeight;
  console.log('Прокрутка:', Math.round(progress * 100) + '%');
}, 150);

window.addEventListener('scroll', onScroll);

У этой простой версии есть нюанс: последний вызов в серии может потеряться. Если события прекратились внутри интервала, финальное состояние не обработается. Для скролла это часто терпимо, но для надёжности добавляют «trailing edge» — гарантированный вызов в конце.

Throttle с trailing-вызовом

function throttle(fn, limit = 200) {
  let lastCall = 0;
  let timeoutId = null;

  return function (...args) {
    const now = Date.now();
    const remaining = limit - (now - lastCall);

    if (remaining <= 0) {
      // интервал прошёл — вызываем немедленно
      if (timeoutId) {
        clearTimeout(timeoutId);
        timeoutId = null;
      }
      lastCall = now;
      fn.apply(this, args);
    } else if (!timeoutId) {
      // планируем финальный вызов на конец интервала
      timeoutId = setTimeout(() => {
        lastCall = Date.now();
        timeoutId = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

Теперь функция и срабатывает регулярно во время серии событий, и гарантированно вызывается ещё раз после её окончания — последнее значение не теряется.


requestAnimationFrame вместо throttle для анимаций

Если throttle нужен ради плавной отрисовки (параллакс, прогресс-бар, перетаскивание), привязывайтесь не к таймеру, а к кадрам браузера через requestAnimationFrame. Это синхронизирует обновления с частотой экрана (обычно 60 FPS) и не выполняет лишней работы между кадрами:

// throttle на основе кадров отрисовки — идеален для scroll/resize с DOM-правками
function rafThrottle(fn) {
  let scheduled = false;

  return function (...args) {
    if (scheduled) return;
    scheduled = true;

    requestAnimationFrame(() => {
      fn.apply(this, args);
      scheduled = false;
    });
  };
}

const onResize = rafThrottle(() => {
  // тяжёлый пересчёт раскладки выполнится максимум раз за кадр
  document.body.dataset.width = window.innerWidth;
});

window.addEventListener('resize', onResize);

Между двумя кадрами все лишние события «схлопываются» в один вызов. Это правильный выбор, когда колбэк трогает DOM или CSS — браузер всё равно перерисовывает не чаще, чем раз в кадр.

Не забывайте про отписку

В SPA (Vue, React) обработчики нужно снимать при размонтировании компонента, иначе утечки и лишние вызовы. Для этого сохраняйте ссылку на debounce/throttle-функцию:

const handler = debounce(updateList, 300);

// подписка
input.addEventListener('input', handler);

// при уничтожении компонента
input.removeEventListener('input', handler);

// в Vue 3 (Composition API):
// onMounted(() => window.addEventListener('scroll', handler));
// onUnmounted(() => window.removeEventListener('scroll', handler));

Если же вы пишете на removeEventListener новую анонимную функцию — отписка не сработает, потому что это будет другая ссылка. Всегда снимайте тот же объект, что вешали.


Чеклист: что выбрать

  • Поиск, автодополнение, валидация формы — debounce (300–500 мс). Запрос уходит, когда пользователь закончил ввод.
  • Кнопки и защита от дабл-клика — debounce с immediate=true.
  • Скролл, бесконечная подгрузка, sticky-элементы — throttle (100–200 мс) или rafThrottle, если меняете DOM.
  • Параллакс, drag&drop, анимации по событиюrequestAnimationFrame.
  • resize окна — throttle или rafThrottle, чтобы не пересчитывать раскладку на каждый пиксель.
  • Не хотите писать руками — в lodash есть готовые _.debounce и _.throttle с опциями leading/trailing, но для одного-двух обработчиков своя реализация в 10 строк избавит от лишней зависимости.

Понимание разницы между этими двумя приёмами — базовый навык фронтендера. Debounce ждёт тишины и реагирует один раз, throttle держит ритм. Выберите правильный — и интерфейс перестанет тормозить даже на слабых устройствах.