Debounce и throttle в JavaScript
Как ограничить частоту вызовов функций в JavaScript с помощью debounce и throttle: что делает каждый приём, как реализовать и когда использовать.
Некоторые события срабатывают гораздо чаще, чем вы успеваете на них реагировать. Ввод текста в поле поиска вызывает событие input при каждом нажатии клавиши; прокрутка страницы может генерировать сотни событий scroll в секунду; resize и mousemove не менее «разговорчивы». Если каждое событие запускает затратную операцию — сетевой запрос, вычисление компоновки, перерендеринг — приложение начинает тормозить. Debounce и throttle — два небольших обёртки, ограничивающие частоту вызовов функции и сохраняющие отзывчивость интерфейса без изменения поведения самой функции.
Оба приёма являются классическими паттернами декоратора: они принимают функцию и возвращают новую функцию с тем же поведением плюс правилом ограничения частоты. Они строятся на замыканиях для хранения состояния между вызовами и на таймерах вроде setTimeout для отсрочки или блокировки выполнения.
Основная идея
Оба приёма отвечают на один вопрос — «как часто это должно выполняться?» — но по-разному:
- Debounce ждёт паузы. Он откладывает вызов до тех пор, пока не пройдёт
Nмиллисекунд с момента последнего обращения. Если вызовы продолжаются, таймер сбрасывается и функция так и не срабатывает. Можно думать об этом так: «подождать тишины». - Throttle обеспечивает равномерный ритм. Он позволяет функции выполняться не чаще одного раза каждые
Nмиллисекунд, сколько бы вызовов ни поступило за это время. Можно думать об этом так: «регулярный пульс».
| Аспект | Debounce | Throttle |
|---|---|---|
| Срабатывает когда | Активность прекращается на 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 и привязка функций.)
Посмотрите на работу в действии. Многократные вызовы обёрнутой функции приводят лишь к одному реальному выполнению — после того как активность затихает:
Поскольку каждое нажатие клавиши сбрасывает таймер, 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, хранящийся в замыкании, работает как шлюз. Первый вызов проходит, и шлюз закрывается; все вызовы в период ожидания отбрасываются; когда таймер срабатывает, шлюз открывается для следующего вызова.
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, и инструментов планирования, с которыми вы уже знакомы.