При разработке интерактивных карт на базе Mapbox GL JS значительная часть логики привязана к пользовательским событиям: перемещению карты, изменению масштаба, вращению, изменению размеров окна браузера, вводу данных в поисковые поля и другим действиям. Многие из этих события генерируются десятки и даже сотни раз в секунду. Неконтролируемое выполнение обработчиков приводит к избыточным вычислениям, повышенной нагрузке на процессор, лишним сетевым запросам и ухудшению плавности интерфейса.
Для решения подобных проблем используются две распространённые техники оптимизации:
Понимание различий между этими подходами особенно важно при работе с Mapbox GL JS, где многие события возникают непрерывно во время взаимодействия пользователя с картой.
Mapbox GL JS предоставляет большое количество событий:
map.on('move', () => {
console.log('Карта перемещается');
});
map.on('zoom', () => {
console.log('Изменение масштаба');
});
map.on('rotate', () => {
console.log('Вращение карты');
});
Во время перемещения карты событие move может вызываться
десятки раз за одну секунду.
Например:
map.on('move', () => {
fetch('/api/objects');
});
Такой код способен отправить огромное количество запросов к серверу всего за несколько секунд перемещения карты.
Последствия:
Для предотвращения подобных ситуаций применяются throttling и debouncing.
Throttling гарантирует, что функция будет выполняться не чаще заданного интервала времени.
Предположим, установлен интервал 500 миллисекунд.
Если событие возникает постоянно:
События:
| | | | | | | | | | | | |
Выполнение:
|-----500ms-----|-----500ms-----|
Даже если событие срабатывает сотни раз, функция выполнится только один раз каждые 500 миллисекунд.
Простейшая реализация:
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 throttledHandler = throttle(() => {
console.log('Обработчик выполнен');
}, 1000);
window.addEventListener('scroll', throttledHandler);
Функция будет выполняться максимум один раз в секунду.
Одно из самых распространённых применений в Mapbox GL JS — обновление данных во время перемещения карты.
Без ограничения:
map.on('move', () => {
updateMarkers();
});
С throttling:
const throttledUpdate = throttle(() => {
updateMarkers();
}, 300);
map.on('move', throttledUpdate);
Теперь обновление маркеров будет происходить не чаще одного раза каждые 300 миллисекунд.
Часто требуется получать данные в пределах текущей области просмотра.
Пример:
const throttledFetch = throttle(() => {
const bounds = map.getBounds();
loadData({
west: bounds.getWest(),
east: bounds.getEast(),
south: bounds.getSouth(),
north: bounds.getNorth()
});
}, 500);
map.on('move', throttledFetch);
Во время перемещения карта остаётся отзывчивой, а сервер получает ограниченное количество запросов.
При изменении размеров окна браузера событие resize
генерируется очень часто.
Пример:
window.addEventListener(
'resize',
throttle(() => {
map.resize();
}, 200)
);
Такой подход предотвращает лишние перерасчёты интерфейса.
Debouncing работает иначе.
Функция выполняется только тогда, когда серия событий завершилась и прошло определённое время без новых вызовов.
Схема работы:
События:
||||||||||||||||||||
Пауза:
------500ms------
Выполнение:
X
Пока события продолжают поступать, таймер постоянно перезапускается.
Базовый вариант:
function debounce(callback, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
callback.apply(this, args);
}, delay);
};
}
Использование:
const debouncedHandler = debounce(() => {
console.log('Выполнено');
}, 500);
window.addEventListener('scroll', debouncedHandler);
Функция выполнится только после остановки прокрутки.
Распространённая задача — поиск объектов на карте.
Без оптимизации:
input.addEventListener('input', event => {
search(event.target.value);
});
При вводе слова из десяти символов будет выполнено десять запросов.
С debouncing:
input.addEventListener(
'input',
debounce(event => {
search(event.target.value);
}, 500)
);
Теперь запрос отправится только после завершения ввода.
Иногда необходимо загружать данные только после того, как пользователь закончил перемещение карты.
Вместо использования события moveend можно реализовать
аналогичное поведение через debouncing.
const debouncedLoad = debounce(() => {
loadVisibleFeatures();
}, 400);
map.on('move', debouncedLoad);
Каждое новое движение сбрасывает таймер.
Когда пользователь прекращает перемещение, происходит загрузка данных.
Особенности:
Пример:
map.on('move', throttle(updateMarkers, 300));
Результат:
Движение карты → обновление каждые 300 мс
Особенности:
Пример:
map.on('move', debounce(loadData, 500));
Результат:
Движение карты → ожидание остановки → загрузка данных
Throttling хорошо подходит для задач, требующих постоянного обновления данных.
Примеры:
Пример отображения центра карты:
const updateCenter = throttle(() => {
const center = map.getCenter();
document.getElementById('info').textContent =
`${center.lng}, ${center.lat}`;
}, 200);
map.on('move', updateCenter);
Debouncing эффективен для операций, которые должны происходить после завершения действий пользователя.
Примеры:
Пример:
const saveMapState = debounce(() => {
const state = {
center: map.getCenter(),
zoom: map.getZoom()
};
localStorage.setItem(
'map-state',
JSON.stringify(state)
);
}, 1000);
map.on('move', saveMapState);
Состояние сохранится только после окончания взаимодействия с картой.
Во многих проектах используется библиотека Lodash, содержащая готовые реализации.
Подключение:
import throttle from 'lodash/throttle';
import debounce from 'lodash/debounce';
Throttling:
map.on(
'move',
throttle(() => {
updateMarkers();
}, 300)
);
Debouncing:
map.on(
'move',
debounce(() => {
loadVisibleObjects();
}, 500)
);
Преимущества готовых реализаций:
Lodash позволяет управлять моментом вызова функции.
Пример:
throttle(handler, 1000, {
leading: true,
trailing: false
});
Параметры:
| Параметр | Описание |
|---|---|
| leading | запуск в начале интервала |
| trailing | запуск в конце интервала |
Для debouncing:
debounce(handler, 500, {
leading: true,
trailing: false
});
Такой вариант выполняет функцию сразу при первом событии и игнорирует остальные до завершения периода ожидания.
Частая задача в Mapbox GL JS — загрузка данных в зависимости от текущей области просмотра.
Пример с throttling:
const updateData = throttle(async () => {
const bounds = map.getBounds();
const response = await fetch(
`/api/data?west=${bounds.getWest()}
&east=${bounds.getEast()}
&south=${bounds.getSouth()}
&north=${bounds.getNorth()}`
);
const geojson = await response.json();
map.getSource('objects').setData(geojson);
}, 500);
map.on('move', updateData);
Карта получает новые данные постепенно во время перемещения.
Даже при использовании throttling и debouncing возможно получение ответов в неправильном порядке.
Пример решения:
let requestId = 0;
const loadData = debounce(async () => {
const currentId = ++requestId;
const response = await fetch('/api/data');
const data = await response.json();
if (currentId !== requestId) {
return;
}
map.getSource('objects').setData(data);
}, 400);
Устаревшие ответы автоматически игнорируются.
Современный способ отмены старых запросов:
let controller;
const loadData = debounce(async () => {
if (controller) {
controller.abort();
}
controller = new AbortController();
try {
const response = await fetch('/api/data', {
signal: controller.signal
});
const data = await response.json();
map.getSource('objects').setData(data);
} catch (error) {
if (error.name !== 'AbortError') {
throw error;
}
}
}, 300);
map.on('move', loadData);
Подход особенно полезен при быстром перемещении карты между различными регионами.
Неправильно:
map.on('move', () => {
throttle(updateData, 300)();
});
Каждый вызов создаёт новую функцию.
Правильно:
const throttledUpdate =
throttle(updateData, 300);
map.on('move', throttledUpdate);
Плохо:
throttle(loadData, 10);
Интервал в 10 миллисекунд практически не снижает нагрузку.
Чаще используются значения:
100–300 мс — интерфейс
300–1000 мс — запросы данных
500–2000 мс — сохранение состояния
Неправильно:
map.on(
'move',
debounce(updateCoordinates, 500)
);
Координаты будут обновляться только после остановки карты.
Лучше:
map.on(
'move',
throttle(updateCoordinates, 100)
);
| Сценарий | Решение |
|---|---|
| Отображение координат курсора | Throttling |
| Обновление пользовательского интерфейса | Throttling |
| Получение данных во время перемещения | Throttling |
| Поиск по вводу текста | Debouncing |
| Геокодирование адресов | Debouncing |
| Сохранение состояния карты | Debouncing |
| Фильтрация объектов | Debouncing |
| Массовые сетевые запросы | Debouncing |
| Отслеживание текущего центра карты | Throttling |
| Загрузка данных после окончания перемещения | Debouncing |
В крупных проектах на Mapbox GL JS throttling и debouncing становятся
обязательными инструментами оптимизации. События move,
zoom, drag, rotate,
pitch, resize и пользовательский ввод способны
генерировать огромный поток вызовов. Грамотное ограничение частоты
выполнения обработчиков позволяет:
Выбор между throttling и debouncing определяется характером задачи: необходимы ли регулярные обновления во время действия пользователя или единственное выполнение после завершения серии событий. Именно это различие лежит в основе большинства решений по оптимизации событийной модели в приложениях на Mapbox GL JS.