W3docs

Debounce и throttle в JavaScript

Как ограничить частоту вызовов функций в JavaScript с помощью debounce и throttle: что делает каждый приём, как реализовать и когда использовать.

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

Оба приёма являются классическими паттернами декоратора: они принимают функцию и возвращают новую функцию с тем же поведением плюс правилом ограничения частоты. Они строятся на замыканиях для хранения состояния между вызовами и на таймерах вроде setTimeout для отсрочки или блокировки выполнения.

Основная идея

Оба приёма отвечают на один вопрос — «как часто это должно выполняться?» — но по-разному:

  • Debounce ждёт паузы. Он откладывает вызов до тех пор, пока не пройдёт N миллисекунд с момента последнего обращения. Если вызовы продолжаются, таймер сбрасывается и функция так и не срабатывает. Можно думать об этом так: «подождать тишины».
  • Throttle обеспечивает равномерный ритм. Он позволяет функции выполняться не чаще одного раза каждые N миллисекунд, сколько бы вызовов ни поступило за это время. Можно думать об этом так: «регулярный пульс».
АспектDebounceThrottle
Срабатывает когдаАктивность прекращается на N мсНе чаще одного раза каждые N мс
Во время серии вызововНичего не выполняется до окончания серииВыполняется по фиксированному расписанию
Мысленная модель«Подождать тишины»«Равномерный ритм»
Подходит дляПоиск по мере ввода, автосохранение, завершение изменения размераОтслеживание прокрутки, перетаскивание, бесконечная прокрутка

Debounce

Функция с debounce при каждом вызове сбрасывает ожидающий таймер и устанавливает новый. Обёрнутая функция фактически выполняется лишь тогда, когда вызовы прекращаются на delay миллисекунд.

function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

Два момента делают реализацию надёжной. Обёртка собирает все аргументы с помощью rest-параметров (...args) и передаёт их дальше, так что обёрнутая функция получает ровно то, что передал вызывающий код. А вызов через fn.apply(this, args) сохраняет исходный this — это важно, когда debounce-функция является методом объекта. (Подробнее о пробросе this читайте в статьях call и apply и привязка функций.)

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

javascript— editable

Поскольку каждое нажатие клавиши сбрасывает таймер, debounce идеально подходит в тех случаях, когда нужно отреагировать после того, как пользователь закончил действие: поиск по мере ввода, автосохранение черновика, валидация поля после завершения ввода или пересчёт компоновки лишь по завершении изменения размера окна.

Throttle

Функция с throttle выполняется немедленно, а затем игнорирует дальнейшие вызовы до истечения периода ожидания. Это гарантирует максимальную частоту вызовов, а не ожидание паузы.

function throttle(fn, limit) {
  let inThrottle = false;
  return function (...args) {
    if (!inThrottle) {
      fn.apply(this, args);
      inThrottle = true;
      setTimeout(() => (inThrottle = false), limit);
    }
  };
}

Флаг inThrottle, хранящийся в замыкании, работает как шлюз. Первый вызов проходит, и шлюз закрывается; все вызовы в период ожидания отбрасываются; когда таймер срабатывает, шлюз открывается для следующего вызова.

javascript— editable

Throttle подходит для всего, что непрерывно генерирует события и где нужны регулярные обновления, а не каждое отдельное событие: отслеживание позиции прокрутки, обработка mousemove при перетаскивании, загрузка контента при бесконечной прокрутке или ограничение частоты обращений к API с лимитом запросов.

Ведущий и замыкающий фронт

В обоих обёртках есть тонкий вопрос проектирования: должна ли функция срабатывать на ведущем фронте (первый вызов, немедленно) или на замыкающем фронте (после задержки/периода ожидания)?

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

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

Информация

Для продакшн-кода предпочтительно использовать проверенные реализации, такие как _.debounce и _.throttle из lodash. Они поддерживают ведущий и замыкающий фронты, API cancel()/flush() и опцию maxWait (чтобы debounce-функция всё же выполнялась в условиях непрерывной активности). Понимание базовых версий выше обязательно, но разрабатывать собственные реализации для продакшна почти никогда не нужно.

Реальный пример с DOM

Подключение debounce к полю поиска — классический сценарий использования. Мы навешиваем один обработчик (см. обработка событий в DOM) и доверяем обёртке решение о том, когда фактически выполнять работу:

const input = document.querySelector('#search');

function search(event) {
  console.log('Querying API for:', event.target.value);
  // fetch(`/api/search?q=${event.target.value}`) ...
}

const debouncedSearch = debounce(search, 400);

input.addEventListener('input', debouncedSearch);

Теперь сетевой запрос отправляется только тогда, когда пользователь делает паузу на 400 мс, а не при каждом нажатии клавиши — поле поиска, которое раньше отправляло десяток запросов для слова hello, теперь отправляет один. Обратите внимание, что обработчик получает DOM-объект event, и поскольку наша обёртка передаёт все аргументы, search получает его в неизменном виде.

Внимание

Таймеры и обработчики событий удерживают ссылки, поэтому их нужно очищать, когда они больше не нужны. В одностраничном приложении или компоненте удаляйте обработчик при демонтировании (например, при размонтировании компонента) и очищайте ожидающий таймер, чтобы избежать утечек памяти и вызовов колбэков для элементов, которые больше не существуют:

input.removeEventListener('input', debouncedSearch);

В продакшн-реализации debounce обычно также предоставляет метод cancel(), который вызывает clearTimeout за вас.

Как выбрать

Если вы не уверены, что использовать, спросите себя, что вам важно:

  • Вас интересует только итоговое состояние после серии действий (завершённый поисковый запрос, устоявшийся размер окна)? Используйте debounce.
  • Вам нужна непрерывная, регулярная обратная связь во время активности (прогресс прокрутки, позиция перетаскиваемого элемента)? Используйте throttle.

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

Проверьте свои знания

Практика
Какой приём запускает функцию не чаще одного раза за фиксированный интервал времени, сколько бы раз она ни вызывалась?
Какой приём запускает функцию не чаще одного раза за фиксированный интервал времени, сколько бы раз она ни вызывалась?
Практика
Вы хотите отправлять поисковый запрос только после того, как пользователь прекратил печатать. Какой инструмент правильный?
Вы хотите отправлять поисковый запрос только после того, как пользователь прекратил печатать. Какой инструмент правильный?
Практика
Почему в примерах debounce и throttle оригинальная функция вызывается через `fn.apply(this, args)`, а не просто `fn(args)`?
Почему в примерах debounce и throttle оригинальная функция вызывается через `fn.apply(this, args)`, а не просто `fn(args)`?
Was this page helpful?