Debounce и throttle

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

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

Для решения подобных проблем используются два фундаментальных механизма управления частотой вызовов функций:

  • debounce
  • throttle

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


Механизм debounce

Суть подхода

Debounce откладывает выполнение функции до тех пор, пока события не перестанут происходить в течение заданного времени.

Алгоритм работает следующим образом:

  1. Событие вызывает функцию.
  2. Запускается таймер.
  3. Если событие происходит снова до истечения таймера — предыдущий таймер отменяется.
  4. Запускается новый таймер.
  5. Функция выполняется только после паузы между событиями.

Иными словами, функция выполняется один раз после завершения серии событий.


Визуальная схема

Пусть установлен интервал 300 мс.

События:   | | | | |        | | |
Время:     0 50 100 150 200 800 850

Debounce:                 ●         ●

Функция запускается только после паузы.


Простейшая реализация debounce

function debounce(fn, delay) {
  let timer = null;

  return function (...args) {
    clearTimeout(timer);

    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

Ключевые элементы реализации:

  • timer хранит идентификатор таймера
  • clearTimeout отменяет предыдущий запуск
  • setTimeout планирует новое выполнение

Пример использования

Поле поиска с автодополнением:

const handleInput = debounce(function(event) {
  search(event.target.value);
}, 300);

input.addEventListener("input", handleInput);

Без debounce запросы отправлялись бы на каждый ввод символа. С debounce запрос выполняется только после паузы в печати.


Использование debounce в интерфейсах с tabbable

В сложных интерфейсах список tabbable-элементов может пересчитываться при:

  • открытии модального окна
  • изменении структуры DOM
  • изменении размеров контейнера
  • динамическом добавлении элементов

Если пересчёт выполняется слишком часто, производительность падает.

Пример пересчёта tabbable-элементов после изменения DOM:

import { tabbable } from "tabbable";

const updateTabbableElements = debounce(() => {
  const nodes = tabbable(container);
  console.log(nodes);
}, 200);

Теперь пересчёт выполняется только после завершения серии изменений.


Расширенная версия debounce

Иногда требуется дополнительная функциональность:

  • запуск функции в начале серии событий
  • возможность отмены
  • возможность принудительного выполнения

Расширенная реализация:

function debounce(fn, delay, immediate = false) {
  let timer = null;

  function debounced(...args) {
    const callNow = immediate && !timer;

    clearTimeout(timer);

    timer = setTimeout(() => {
      timer = null;
      if (!immediate) {
        fn.apply(this, args);
      }
    }, delay);

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

  debounced.cancel = () => {
    clearTimeout(timer);
    timer = null;
  };

  return debounced;
}

Механизм throttle

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

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

Если события происходят чаще, лишние вызовы игнорируются или откладываются.


Визуальная схема

Интервал — 300 мс.

События:   | | | | | | | | |
Время:     0 50 100 150 200 250 300 350

Throttle:  ●        ●        ●

Функция выполняется регулярно, но не чаще установленного интервала.


Простая реализация throttle

function throttle(fn, delay) {
  let lastCall = 0;

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

    if (now - lastCall >= delay) {
      lastCall = now;
      fn.apply(this, args);
    }
  };
}

Здесь используется проверка времени последнего вызова.


Пример использования

Отслеживание прокрутки страницы:

const handleScroll = throttle(() => {
  console.log(window.scrollY);
}, 200);

window.addEventListener("scroll", handleScroll);

Даже если событие scroll происходит сотни раз в секунду, обработчик будет выполняться не чаще одного раза в 200 мс.


Использование throttle вместе с tabbable

При сложных интерфейсах часто требуется отслеживать:

  • прокрутку контейнера
  • изменение размеров
  • появление элементов

Например, список фокусируемых элементов может зависеть от видимости элементов внутри прокручиваемого контейнера.

import { tabbable } from "tabbable";

const updateFocusable = throttle(() => {
  const elements = tabbable(container);
  cache = elements;
}, 200);

container.addEventListener("scroll", updateFocusable);

Throttle позволяет регулярно обновлять данные, не перегружая приложение.


Отличия debounce и throttle

Характеристика Debounce Throttle
Когда выполняется после паузы между событиями через фиксированные интервалы
Количество запусков обычно один регулярные
Подходит для ввода текста, автопоиска прокрутки, resize
Реакция на поток событий ждёт окончания ограничивает частоту

Типичные сценарии применения

Debounce

Используется когда важно реагировать только на конечный результат действий.

Примеры:

  • автосохранение текста
  • поиск по мере ввода
  • пересчёт layout после изменения размеров
  • анализ DOM после серии мутаций

В контексте tabbable debounce полезен для:

  • обновления списка фокусируемых элементов после изменения DOM
  • пересчёта после закрытия/открытия элементов интерфейса

Throttle

Используется когда требуется регулярная реакция на поток событий.

Примеры:

  • обработка прокрутки
  • анимации
  • отслеживание движения мыши
  • мониторинг размеров окна

В интерфейсах с tabbable throttle применяют при:

  • отслеживании scroll-контейнеров
  • динамическом позиционировании фокуса
  • обновлении списка элементов при постепенной загрузке контента

Комбинирование debounce и throttle

Иногда применяется комбинация двух подходов.

Например:

  1. throttle выполняет обновления во время активных событий
  2. debounce делает финальный пересчёт после завершения

Пример:

const update = () => {
  focusableCache = tabbable(container);
};

const throttledUpdate = throttle(update, 200);
const debouncedUpdate = debounce(update, 300);

window.addEventListener("scroll", throttledUpdate);
window.addEventListener("scrollend", debouncedUpdate);

Такая схема уменьшает нагрузку и обеспечивает точный финальный результат.


Подводные камни

Потеря контекста this

При использовании debounce и throttle важно сохранять контекст вызова.

Использование apply или call решает проблему:

fn.apply(this, args);

Потеря аргументов

Если не передавать аргументы, функция может работать некорректно.

Правильный способ:

function (...args) {
  fn.apply(this, args);
}

Утечки памяти

Если debounce-функция содержит таймер, а компонент удаляется из DOM, таймер может остаться активным.

Решение — поддержка метода отмены:

debounced.cancel();

Использование готовых реализаций

Во многих проектах используются готовые версии из библиотек:

  • lodash.debounce
  • lodash.throttle

Они поддерживают:

  • leading / trailing вызовы
  • cancel
  • flush
  • стабильную работу во всех браузерах

Пример:

import debounce from "lodash.debounce";

const update = debounce(() => {
  const nodes = tabbable(container);
}, 200);

Практическая оптимизация интерфейсов

В интерфейсах, использующих Tabbable, пересчёт фокусируемых элементов может быть дорогой операцией, особенно если:

  • DOM содержит тысячи элементов
  • используются Shadow DOM
  • есть вложенные компоненты
  • выполняются проверки display, visibility, tabindex

Поэтому оптимизация вызовов функций с помощью debounce и throttle становится важной частью архитектуры интерфейса.

Правильное ограничение частоты выполнения:

  • снижает нагрузку на основной поток браузера
  • уменьшает количество layout-пересчётов
  • повышает отзывчивость интерфейса
  • улучшает доступность и стабильность работы фокуса клавиатуры.