Debounce и throttle: частота вызовов, контекст и очистка
Автор: Казачкин Даниил Михайлович · Обновлено
Debounce откладывает действие до паузы в потоке вызовов, а throttle ограничивает частоту выполнения во время продолжающегося потока. Для корректного интерфейса нужно также определить первый и последний вызов, сохранение аргументов и отмену при завершении работы экрана.
Среда и выбор контракта
Основной пример выполняется в Node.js 22 или новее как timing.mjs либо в консоли браузера. Он не обращается к DOM и сети. Для понимания нужны замыкания, this, таймеры и Promise. Реализации учебные и намеренно имеют ограниченный контракт: первый пример — debounce только на последнем краю, второй — throttle только на первом. Название функции само по себе не определяет все варианты поведения.
При наборе поискового запроса обычно интересна последняя строка после небольшой паузы. Это задача debounce. При непрерывном перемещении или прокрутке может требоваться периодическое обновление; ожидание полной остановки было бы неудобным, поэтому рассматривают throttle или механизм кадра. Задержка действия должна соответствовать ожиданиям пользователя, а не просто уменьшать число сообщений в консоли.
Полный пример: debounce с cancel и flush
function debounce(fn, wait) {
if (typeof fn !== 'function' || !Number.isFinite(wait) || wait < 0) {
throw new TypeError('Invalid debounce arguments');
}
let timer;
let args;
let receiver;
function invoke() {
if (!args) return;
const callArgs = args;
const callReceiver = receiver;
clearTimeout(timer);
timer = undefined;
args = undefined;
receiver = undefined;
return fn.apply(callReceiver, callArgs);
}
function wrapped(...nextArgs) {
args = nextArgs;
receiver = this;
clearTimeout(timer);
timer = setTimeout(invoke, wait);
}
wrapped.cancel = () => {
clearTimeout(timer);
timer = undefined;
args = undefined;
receiver = undefined;
};
wrapped.flush = invoke;
return wrapped;
}
const search = {
prefix: 'query:',
report: debounce(function (text) { console.log(this.prefix + text); }, 50),
};
search.report('j');
search.report('javascript');
search.report.flush();
search.report('cancelled');
search.report.cancel();
setTimeout(() => console.log('finished'), 80);Вывод: query:javascript, затем finished. flush выполняет последний отложенный вызов сразу, поэтому демонстрация не зависит от того, успели ли таймеры между отдельными действиями пользователя. Отменённый текст не появится. Для наблюдения обычного ожидания удалите все три строки: search.report.flush(), search.report('cancelled') и search.report.cancel(): после паузы будет выведен последний поисковый текст, но точное время запуска не гарантируется.
Каждый вызов сохраняет последние аргументы и получателя. Внешняя обёртка является обычной функцией, поэтому принимает динамический this; callback таймера использует сохранённое значение через apply. Если заменить обёртку стрелкой, она не получит контекст вызова search.report(...). Это не косметическое различие синтаксиса, а часть контракта переиспользуемой функции.
Почему состояние очищается до вызова
В invoke нужные значения сначала сохраняются локально, затем внутренние ссылки очищаются, и лишь после этого вызывается пользовательская функция. Если она снова вызовет ту же обёртку, новый набор аргументов и таймер не будут затёрты завершением предыдущего вызова. Такой повторный вход редок в простом поиске, но полезен для проверки качества обобщённого помощника.
cancel убирает таймер и ссылки на последние аргументы и получателя. Одного clearTimeout недостаточно для ясного жизненного цикла, если сама обёртка продолжает жить и удерживает большой объект через замыкание. После выполнения также не нужно хранить прошлые аргументы без причины. Внешний код при закрытии экрана должен вызвать отмену и снять обработчик события, который запускает debounce.
Обычный вызов wrapped не возвращает будущий результат функции. flush возвращает непосредственный результат выполненного вызова, если он был. Если callback асинхронный, обработка его отклонения требует отдельного контракта: наш таймер не ждёт возвращённый Promise. Для сетевого поиска удобно, чтобы callback сам обработал ошибку или передал её владельцу состояния; нельзя просто окружить регистрацию таймера внешним try/catch.
Throttle с первым вызовом
Следующая реализация самостоятельна и не ставит отложенных вызовов. Первый вызов проходит сразу, остальные до конца интервала пропускаются. После паузы следующий вход снова проходит. Параметр часов нужен для детерминированной практики; по умолчанию используется монотонная шкала performance.now, а не календарное время.
function throttleLeading(fn, wait, now = () => performance.now()) {
if (typeof fn !== 'function' || !Number.isFinite(wait) || wait < 0) {
throw new TypeError('Invalid throttle arguments');
}
let nextAllowed = -Infinity;
function wrapped(...args) {
const time = now();
if (time < nextAllowed) return;
nextAllowed = time + wait;
return fn.apply(this, args);
}
wrapped.cancel = () => { nextAllowed = -Infinity; };
return wrapped;
}
let time = 0;
const model = {
prefix: 'position:',
update: throttleLeading(function (value) {
console.log(this.prefix + value);
}, 10, () => time),
};
model.update(1);
time = 5;
model.update(2);
time = 10;
model.update(3);Ожидаются position:1 и position:3. На точной границе десяти единиц вызов разрешён. Здесь cancel только сбрасывает ограничение: таймеров и отложенных аргументов нет. Этот вариант не обещает обработать последнее значение серии. Если последняя позиция важна, требуется вариант с последним краем или отдельное завершающее действие.
Частота вызова и фактическая работа
Debounce без максимального ожидания может откладывать действие бесконечно при непрерывном вводе. Для автосохранения часто требуется дополнительный предел ожидания, а также явное сохранение при подтверждении. Эти требования нельзя получить простым изменением числа миллисекунд. Сначала перечислите сценарии: одиночный ввод, непрерывная серия, пауза, отправка формы, закрытие экрана.
Throttle ограничивает моменты запуска, но не число одновременно выполняющихся запросов. Если обработка длится дольше интервала, несколько операций могут пересечься. Для ограничения конкурентности нужна очередь или другой механизм, а для защиты интерфейса от старых ответов — проверка актуальности. Debounce также не отменяет уже начатый запрос: после его запуска таймер больше не владеет сетевой операцией.
Для визуальной синхронизации с кадром рассмотрите requestAnimationFrame. Это другой контракт, зависящий от отрисовки и видимости страницы, а не точный фиксированный интервал. Дорогой callback остаётся дорогим независимо от способа планирования. Измеряйте длительность самой работы и проверяйте поведение на медленном устройстве, прежде чем увеличивать задержки до заметной пользователю паузы.
Практика и проверка границ
Для debounce добавьте три синхронных вызова с разными аргументами, затем один flush: должен выполниться только последний. После cancel вызов flush ничего не делает. Затем вызовите обёртку снова и убедитесь, что отмена не уничтожила её навсегда. Для throttle используйте учебные часы и проверьте моменты 0, 9, 10, 11, 20: при интервале десять пройдут первый, третий и пятый вызовы.
Отдельно проверьте метод с this, пустой список аргументов и снятие подписки. В DOM сохраняйте одну ссылку на обёртку: создавать новый debounce внутри каждого события означает создавать независимые таймеры, которые не отменяют друг друга. При удалении обработчика также нужна та же функция, которая была добавлена.
Частые вопросы
Почему каждый введённый символ всё равно вызывает поиск?
Проверьте место создания обёртки. Она должна жить дольше одного события и хранить общий таймер серии. Если фабрика вызывается внутри обработчика каждый раз, у каждого символа получается собственный независимый debounce.
Можно ли поставить wait равным нулю?
В debounce это всё ещё отложенный таймер, а не синхронный вызов. В нашем throttle ноль снимает практическое ограничение частоты. Не считайте одинаковый параметр гарантией одинакового поведения двух разных алгоритмов.
Что делать при уходе со страницы?
Отмените отложенный вызов, снимите подписку и отдельно отмените уже начатую операцию, если она поддерживает сигнал. Нужно ли вместо отмены выполнить flush, зависит от задачи: поиск обычно не нужен после ухода, а несохранённый ввод требует заранее выбранной стратегии сохранения.