Throttling пользователей

При разработке интерактивных карт на базе Mapbox GL JS большое количество пользовательских событий способно создавать чрезмерную нагрузку на приложение. Перемещение карты, масштабирование колесом мыши, изменение размеров окна браузера, работа с поиском по карте, обновление данных в реальном времени — все эти действия могут генерировать десятки и сотни событий в секунду.

Если каждое событие приводит к выполнению ресурсоёмкой логики, возникают следующие проблемы:

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

Для решения подобных задач применяется техника throttling.

Throttling — это ограничение частоты выполнения функции. Независимо от количества вызовов функция будет запускаться не чаще заданного интервала времени.

Например, при ограничении в 200 миллисекунд:

  • произошло 100 событий за секунду;
  • обработчик будет вызван примерно 5 раз;
  • остальные вызовы будут проигнорированы.

Разница между throttling и debouncing

Эти техники часто используются совместно, однако решают разные задачи.

Throttling

Выполняет функцию через равные промежутки времени.

Схема работы:

События:
|||||||||||||||||||||||||||||

Вызовы:
|----|----|----|----|----|

Подходит для:

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

Debouncing

Откладывает выполнение до окончания серии событий.

Схема работы:

События:
|||||||||||||||||||||||||||||

Вызов:
                        |

Подходит для:

  • поиска по карте;
  • фильтрации объектов;
  • ввода текста;
  • обновления данных после завершения взаимодействия.

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

Классическая реализация строится на основе временной метки последнего вызова.

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

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

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

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

const logPosition = throttle(() => {
    console.log("Карта перемещается");
}, 300);

Throttling события move

Событие move является одним из самых часто вызываемых событий Mapbox GL JS.

Оно срабатывает:

  • при перетаскивании карты;
  • во время анимаций;
  • при масштабировании;
  • при программном перемещении камеры.

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

Проблемный вариант

map.on("move", () => {
    const center = map.getCenter();

    console.log(center.lng, center.lat);
});

Во время активного перемещения карта будет постоянно выполнять обработчик.

Оптимизированный вариант

const updateCoordinates = throttle(() => {
    const center = map.getCenter();

    console.log(center.lng, center.lat);
}, 200);

map.on("move", updateCoordinates);

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


Ограничение запросов при перемещении карты

Часто требуется загружать объекты только для текущей области просмотра.

Например:

map.on("move", async () => {
    const bounds = map.getBounds();

    const response = await fetch(
        `/api/objects?bbox=${bounds.toArray()}`
    );

    const data = await response.json();

    updateMarkers(data);
});

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

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

const loadObjects = throttle(async () => {
    const bounds = map.getBounds();

    const response = await fetch(
        `/api/objects?bbox=${bounds.toArray()}`
    );

    const data = await response.json();

    updateMarkers(data);
}, 1000);

map.on("move", loadObjects);

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


Throttling во время масштабирования

Событие zoom также способно вызывать обработчики очень часто.

Например, при обновлении пользовательского интерфейса:

map.on("zoom", () => {
    document.getElementById("zoom-value").textContent =
        map.getZoom().toFixed(2);
});

После оптимизации:

const updateZoom = throttle(() => {
    document.getElementById("zoom-value").textContent =
        map.getZoom().toFixed(2);
}, 100);

map.on("zoom", updateZoom);

Throttling при работе с GeoJSON

На крупных проектах источники данных могут содержать десятки тысяч объектов.

Например:

map.on("move", () => {
    source.setData(generateGeoJSON());
});

Постоянное обновление GeoJSON приводит к:

  • повторной сериализации данных;
  • перерасчёту геометрии;
  • обновлению слоёв;
  • дополнительной нагрузке на GPU.

Более безопасный вариант:

const updateSource = throttle(() => {
    source.setData(generateGeoJSON());
}, 500);

map.on("move", updateSource);

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

Для визуальных операций часто предпочтительнее использовать связку throttling и requestAnimationFrame.

Пример

function throttleAnimation(callback) {
    let scheduled = false;

    return function (...args) {
        if (!scheduled) {
            scheduled = true;

            requestAnimationFrame(() => {
                callback.apply(this, args);
                scheduled = false;
            });
        }
    };
}

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

const updateOverlay = throttleAnimation(() => {
    drawOverlay();
});

map.on("move", updateOverlay);

Такой подход синхронизирует обновления с циклом отрисовки браузера.


Throttling координат курсора

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

Пример без оптимизации:

map.on("mousemove", (e) => {
    showCoordinates(
        e.lngLat.lng,
        e.lngLat.lat
    );
});

Во время движения мыши генерируется огромное количество событий.

Оптимизированная версия:

const updateMousePosition = throttle((e) => {
    showCoordinates(
        e.lngLat.lng,
        e.lngLat.lat
    );
}, 100);

map.on("mousemove", updateMousePosition);

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

Во многих проектах throttling реализуется через библиотеку Lodash.

Установка:

npm install lodash

Импорт:

import throttle from "lodash/throttle";

Пример:

const updateData = throttle(() => {
    console.log("Обновление");
}, 500);

map.on("move", updateData);

Преимущества:

  • проверенная реализация;
  • высокая надёжность;
  • дополнительные настройки;
  • поддержка отмены вызовов.

Настройка leading и trailing

Lodash предоставляет гибкие параметры.

Только первый вызов

const handler = throttle(
    callback,
    500,
    {
        leading: true,
        trailing: false
    }
);

Поведение:

События:
||||||||||||||||||||

Вызов:
|

Только последний вызов

const handler = throttle(
    callback,
    500,
    {
        leading: false,
        trailing: true
    }
);

Поведение:

События:
||||||||||||||||||||

Вызов:
                    |

Первый и последний вызовы

const handler = throttle(
    callback,
    500,
    {
        leading: true,
        trailing: true
    }
);

Поведение:

События:
||||||||||||||||||||

Вызовы:
|                  |

Отмена throttled-функции

Lodash позволяет отменять ожидающий вызов.

const updateData = throttle(
    loadData,
    1000
);

Отмена:

updateData.cancel();

Особенно полезно при уничтожении карты.

map.remove();

updateData.cancel();

Очистка обработчиков

При работе с одностраничными приложениями необходимо удалять обработчики событий.

const moveHandler = throttle(() => {
    updateSidebar();
}, 300);

map.on("move", moveHandler);

Удаление:

map.off("move", moveHandler);

Для приложений на React, Vue или Angular это особенно важно, поскольку предотвращает утечки памяти.


Throttling при кластеризации данных

Во время перемещения карты может выполняться перерасчёт кластеров.

Пример:

map.on("move", () => {
    recalculateClusters();
});

На больших объёмах данных такая операция становится дорогой.

Оптимизация:

const clusterUpdate = throttle(() => {
    recalculateClusters();
}, 500);

map.on("move", clusterUpdate);

Ограничение обновления боковой панели

Частая задача — отображение объектов, находящихся в текущем окне карты.

Без ограничений:

map.on("move", () => {
    renderVisibleObjects();
});

После оптимизации:

const updateSidebar = throttle(() => {
    renderVisibleObjects();
}, 300);

map.on("move", updateSidebar);

Это особенно важно при работе с тысячами записей.


Комбинирование move и moveend

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

Во время движения карта получает лёгкие обновления:

const previewUpdate = throttle(() => {
    updatePreview();
}, 300);

map.on("move", previewUpdate);

После завершения движения выполняется полная операция:

map.on("moveend", () => {
    loadFullDataset();
});

Преимущества подхода:

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

Выбор интервала throttling

Рекомендуемые значения для различных задач:

Задача Интервал
Отображение координат курсора 50–100 мс
Обновление интерфейса 100–200 мс
Перемещение карты 200–300 мс
Загрузка данных 500–1000 мс
Тяжёлые вычисления 1000–3000 мс

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

Слишком большой интервал делает интерфейс менее отзывчивым.


Типичные ошибки

Создание throttled-функции внутри обработчика

Неправильно:

map.on("move", () => {
    const handler = throttle(() => {
        updateData();
    }, 300);

    handler();
});

Каждый вызов создаёт новый экземпляр throttling.

Правильно:

const handler = throttle(() => {
    updateData();
}, 300);

map.on("move", handler);

Слишком агрессивное ограничение

const updateData = throttle(
    refresh,
    10000
);

Интерфейс будет обновляться раз в десять секунд, что создаёт ощущение зависания приложения.


Отсутствие отмены

const updateData = throttle(
    loadData,
    1000
);

Если приложение уничтожает карту, но throttled-функция остаётся активной, возможно выполнение лишнего кода после удаления компонентов.


Практические рекомендации

Throttling следует использовать для всех событий высокой частоты в Mapbox GL JS:

  • move;
  • mousemove;
  • zoom;
  • drag;
  • rotate;
  • pitch;
  • resize.

Особенно полезен throttling для:

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

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