Throttling и debouncing событий

Работа с картой в браузере связана с большим количеством высокочастотных событий: перемещение карты, изменение масштаба, перетаскивание, движение мыши, изменение границ видимой области. Многие из этих событий могут вызываться десятки и сотни раз в секунду, что приводит к избыточным вычислениям, сетевым запросам и деградации производительности интерфейса.

Для управления этим потоком используется два фундаментальных приёма: debouncing и throttling.


Высокочастотные события карты и их природа

В API карт особенно часто используются следующие события:

  • bounds_changed
  • center_changed
  • zoom_changed
  • drag
  • mousemove
  • idle

Каждое из них может генерироваться с высокой частотой при одном действии пользователя. Например, при перетаскивании карты событие bounds_changed может вызываться десятки раз в секунду, а mousemove — ещё чаще.

Типичная проблема: привязка логики загрузки данных напрямую к этим событиям приводит к лавинообразному числу запросов.


Отличие debouncing и throttling

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

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


Базовая реализация debouncing

function debounce(fn, delay) {
  let timeoutId;

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

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

Принцип: каждый новый вызов сбрасывает таймер. Реальное выполнение происходит только после паузы в активности.


Базовая реализация throttling

function throttle(fn, limit) {
  let inThrottle = false;

  return function (...args) {
    if (!inThrottle) {
      fn.apply(this, args);
      inThrottle = true;

      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
}

Принцип: выполнение допускается один раз за интервал времени, остальные вызовы игнорируются до завершения периода.


Применение debouncing для bounds_changed

Одна из самых распространённых задач — загрузка объектов в пределах видимой области карты.

Без оптимизации:

map.addListener("bounds_changed", () => {
  loadMarkers();
});

Проблема: loadMarkers() вызывается слишком часто.

С debouncing:

const debouncedLoadMarkers = debounce(() => {
  const bounds = map.getBounds();

  if (!bounds) return;

  loadMarkers(bounds);
}, 400);

map.addListener("bounds_changed", debouncedLoadMarkers);

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


Использование idle как альтернатива

Событие idle срабатывает, когда карта завершает любые изменения и становится «стабильной».

map.addListener("idle", () => {
  const bounds = map.getBounds();
  loadMarkers(bounds);
});

Во многих сценариях idle заменяет необходимость debounce, но не всегда подходит при сложных интерактивных сценариях.


Throttling для mousemove и интерактивных оверлеев

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

const throttledMouseMove = throttle((event) => {
  const lat = event.latLng.lat();
  const lng = event.latLng.lng();

  updateOverlay(lat, lng);
}, 50);

map.addListener("mousemove", throttledMouseMove);

Без throttling событие mousemove создаёт избыточную нагрузку на DOM и рендеринг.


Сценарии использования debounce

Debouncing особенно полезен в следующих задачах:

  • геокодирование адреса при вводе
  • поиск объектов в пределах карты
  • обновление маркеров после pan/zoom
  • фильтрация данных по текущим границам
  • запросы к серверу при изменении параметров карты

Пример с вводом:

const input = document.getElementById("search");

const debouncedSearch = debounce((value) => {
  geocodeAddress(value);
}, 300);

input.addEventListener("input", (e) => {
  debouncedSearch(e.target.value);
});

Сценарии использования throttle

Throttling применяется там, где важно сохранить регулярность обновлений:

  • анимация элементов поверх карты
  • отслеживание положения курсора
  • обновление UI-индикаторов движения карты
  • аналитика поведения пользователя (sampling событий)

Комбинирование с сетевыми запросами

При работе с API часто используется комбинация debouncing и AbortController для отмены устаревших запросов.

let controller;

const debouncedFetch = debounce(async (bounds) => {
  if (controller) controller.abort();

  controller = new AbortController();

  const res = await fetch("/api/markers", {
    signal: controller.signal,
    method: "POST",
    body: JSON.stringify(bounds),
  });

  const data = await res.json();
  renderMarkers(data);
}, 400);

Так исключается ситуация, когда старые запросы перекрывают новые результаты.


requestAnimationFrame как альтернатива throttling

Для визуальных обновлений часто используется requestAnimationFrame, синхронизированный с рендером браузера.

let scheduled = false;

map.addListener("mousemove", (event) => {
  if (scheduled) return;

  scheduled = true;

  requestAnimationFrame(() => {
    scheduled = false;

    const latLng = event.latLng;
    drawPointer(latLng);
  });
});

Этот подход снижает нагрузку и делает обновления плавными.


Работа с событиями bounds_changed и zoom_changed

События изменения масштаба и границ требуют особого внимания, поскольку часто вызываются совместно.

Типичная ошибка — дублирование логики:

map.addListener("zoom_changed", update);
map.addListener("bounds_changed", update);

В результате update() вызывается несколько раз за одно действие пользователя.

Решение — объединение через debounce:

const updateView = debounce(() => {
  const bounds = map.getBounds();
  const zoom = map.getZoom();

  refreshData(bounds, zoom);
}, 300);

map.addListener("zoom_changed", updateView);
map.addListener("bounds_changed", updateView);

Типичные ошибки при использовании debounce и throttle

  • потеря контекста this при передаче методов объекта
  • отсутствие очистки таймеров при уничтожении карты
  • чрезмерно короткие интервалы, сводящие эффект к нулю
  • использование debounce там, где требуется throttle (и наоборот)
  • накопление слушателей без removeListener

Очистка слушателей:

const listener = map.addListener("idle", handler);

// позже
listener.remove();

Оптимизация частых сценариев работы с картой

При проектировании логики взаимодействия с картой обычно применяется комбинация подходов:

  • idle для финального состояния
  • debounce для серверных запросов
  • throttle для визуальных обновлений
  • requestAnimationFrame для анимаций

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