Throttling и debouncing событий

В браузерных картах на основе Leaflet основная сложность производительности возникает не в рендеринге тайлов, а в обработке событий. Перемещения карты, зумирование, движение курсора и жесты вызывают десятки и сотни событий в секунду. Без ограничения частоты вызовов обработчиков возникает перегрузка JavaScript-потока, падение FPS и рост задержек интерфейса.

Внутренний цикл событий в Leaflet построен вокруг постоянного обновления состояния карты:

  • move — вызывается при каждом изменении центра карты
  • mousemove — при движении курсора по карте
  • zoom — во время изменения масштаба
  • zoomanim — во время анимации масштабирования
  • moveend, zoomend — финальные события после завершения операции

Проблема заключается в том, что часть этих событий генерируется с частотой 30–120 раз в секунду. Если в обработчиках выполняются тяжёлые операции (запросы к серверу, фильтрация данных, перерасчёт слоёв), интерфейс начинает деградировать.

Переполнение обработчиков и узкие места

Типичный антипример:

map.on('move', () => {
  updateMarkers();
  fetchVisibleObjects();
  renderSidebar();
});

Здесь каждая микродвижение карты запускает полный цикл бизнес-логики. Даже при слабой нагрузке это приводит к:

  • блокировке main thread
  • накоплению очереди вызовов
  • дублирующим сетевым запросам
  • дрожанию интерфейса при панорамировании

Для решения используются два базовых механизма: throttling и debouncing.


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

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

Идея: «выполнять не каждый раз, а раз в N миллисекунд».

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

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

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

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

Применение в Leaflet:

const throttledMoveHandler = throttle((e) => {
  console.log('центр карты:', e.target.getCenter());
}, 200);

map.on('move', throttledMoveHandler);

Поведение throttling в карте

При панорамировании:

  • события move продолжают генерироваться
  • обработчик срабатывает раз в 200 мс
  • UI остаётся отзывчивым
  • нагрузка стабилизируется

Варианты throttling

Leading throttle (срабатывание сразу)

Функция выполняется в начале интервала:

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

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

Trailing throttle (срабатывание в конце интервала)

Полезен при обработке итогового состояния карты.


Debouncing: выполнение после паузы

Debouncing (дебаунсинг) — стратегия, при которой функция выполняется только после того, как поток событий остановился на заданное время.

Идея: «выполнить только когда пользователь перестал двигать карту».

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

function debounce(fn, delay) {
  let timer;

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

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

Применение в Leaflet

const debouncedMoveEnd = debounce((e) => {
  const center = e.target.getCenter();
  console.log('финальный центр:', center);
}, 300);

map.on('move', debouncedMoveEnd);

Хотя чаще debounce применяют не к move, а к:

map.on('moveend', (e) => {
  console.log('карта остановилась');
});

Но debounce полезен, когда требуется задержка после активности:

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

Разница throttling и debouncing в картографическом контексте

В Leaflet выбор стратегии зависит от поведения UI:

Throttling

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

Примеры:

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

Debouncing

  • финальное состояние
  • минимизация запросов к серверу
  • обработка «после остановки»

Примеры:

  • загрузка маркеров в пределах viewport
  • запрос объектов по bbox
  • обновление аналитики карты

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

Для высокочастотных событий в Leaflet часто эффективнее throttling через requestAnimationFrame.

Принцип

Синхронизация с циклом перерисовки браузера (~60 FPS).

function rafThrottle(fn) {
  let running = false;

  return function (...args) {
    if (running) return;

    running = true;

    requestAnimationFrame(() => {
      fn.apply(this, args);
      running = false;
    });
  };
}

Использование

map.on('move', rafThrottle((e) => {
  updateOverlay(e.target.getCenter());
}));

Когда предпочтительнее

  • анимации маркеров
  • визуальные эффекты
  • синхронизация DOM с положением карты

Практический сценарий: загрузка объектов в видимой области

Одно из самых нагруженных мест в Leaflet — подгрузка данных при перемещении карты.

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

map.on('move', () => {
  const bounds = map.getBounds();
  loadObjects(bounds);
});

Это приведёт к десяткам запросов в секунду.

С debounce

const loadObjectsDebounced = debounce(() => {
  const bounds = map.getBounds();
  loadObjects(bounds);
}, 400);

map.on('move', loadObjectsDebounced);

Результат:

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

С throttle

const loadObjectsThrottled = throttle(() => {
  const bounds = map.getBounds();
  loadObjects(bounds);
}, 500);

map.on('move', loadObjectsThrottled);

Результат:

  • данные обновляются по мере движения
  • пользователь видит постепенное уточнение

Комбинирование подходов

В реальных приложениях Leaflet часто используется гибрид:

  • throttle для визуальных обновлений
  • debounce для серверных запросов
map.on('move', throttle(updateUI, 100));
map.on('move', debounce(fetchData, 300));

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


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

1. Двойная регистрация обработчиков

map.on('move', debounce(handler, 200));
map.on('move', debounce(handler, 200));

Каждый вызов создаёт новый debounce-объект → утечка логики.

2. Игнорирование контекста map

function handler() {
  this.getCenter(); // может быть undefined
}

Решение — использовать fn.apply(this, args) или стрелочные функции.

3. Перекрытие move и moveend

Часто логика дублируется:

  • debounce на move
  • обработка в moveend

Это приводит к двойным запросам.


Оптимизация под touch-устройства

На мобильных устройствах в Leaflet частота событий выше из-за инерции жестов.

Рекомендуется:

  • увеличивать delay debounce до 400–800 мс
  • использовать throttle не чаще 100–150 мс
  • избегать тяжёлых DOM-операций внутри move

Управление нагрузкой при большом количестве маркеров

При обновлении маркеров в зависимости от viewport:

const updateMarkersDebounced = debounce(() => {
  const bounds = map.getBounds();
  const visible = allMarkers.filter(m => bounds.contains(m.getLatLng()));
  renderMarkers(visible);
}, 250);

map.on('zoom move', updateMarkersDebounced);

Это предотвращает повторный пересчёт при каждом пиксельном сдвиге.


Архитектурный паттерн: event normalization layer

В сложных приложениях на Leaflet вводится промежуточный слой:

  • raw events (move, zoom)
  • normalized events (throttled/debounced)
  • domain events (viewportChanged, dataReloadRequested)
const viewportChanged = debounce(() => {
  eventBus.emit('viewportChanged', map.getBounds());
}, 300);

map.on('move', viewportChanged);
map.on('zoom', viewportChanged);

Это снижает связанность компонентов и упрощает масштабирование логики.


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

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