При работе с интерактивными картами большое количество событий может происходить за очень короткий промежуток времени. Перемещение карты, изменение масштаба, обновление слоёв, поиск объектов, загрузка данных с сервера — все эти операции способны генерировать десятки и даже сотни запросов в секунду.
Без механизма ограничения частоты запросов возникают следующие проблемы:
Rate limiting — это набор методов, ограничивающих количество выполняемых операций за определённый промежуток времени.
В контексте Leaflet чаще всего ограничиваются:
Рассмотрим типичный пример.
map.on('move', () => {
fetch('/api/objects');
});
Событие move вызывается непрерывно во время
перетаскивания карты.
Если пользователь перемещает карту в течение пяти секунд, обработчик может выполниться сотни раз.
Подобное поведение приводит к:
Поэтому практически любые операции, связанные с сетью, требуют ограничения частоты выполнения.
Наиболее распространёнными являются три метода:
Каждый решает разные задачи.
Throttle ограничивает частоту вызова функции.
Например, обработчик может выполняться не чаще одного раза в секунду независимо от количества событий.
Схема работы:
События:
||||||||||||||||||||||||
Throttle 1000 ms:
|----|----|----|----|
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 updateData = throttle(() => {
console.log('Обновление данных');
}, 1000);
map.on('move', updateData);
Теперь функция будет вызываться максимум один раз в секунду.
Часто данные необходимо получать при перемещении карты.
Например:
const loadObjects = throttle(async () => {
const bounds = map.getBounds();
const response = await fetch('/api/objects', {
method: 'POST',
body: JSON.stringify({
north: bounds.getNorth(),
south: bounds.getSouth(),
east: bounds.getEast(),
west: bounds.getWest()
})
});
}, 500);
map.on('move', loadObjects);
Даже если карта двигается непрерывно, сервер получит ограниченное количество запросов.
Debounce работает иначе.
Функция выполняется только после того, как поток событий прекратился на определённое время.
Схема:
События:
||||||||||||||||||||||||
Пауза
Вызов:
|
Это особенно полезно, когда действие должно выполняться только после завершения взаимодействия пользователя.
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
Использование:
const updateData = debounce(() => {
console.log('Загрузка данных');
}, 500);
map.on('move', updateData);
Часто требуется получить данные только после окончания перемещения.
const fetchData = debounce(async () => {
const bounds = map.getBounds();
const response = await fetch('/api/markers');
const data = await response.json();
console.log(data);
}, 400);
map.on('move', fetchData);
Пока пользователь перемещает карту, запросы не выполняются.
После остановки выполняется единственный запрос.
Leaflet уже предоставляет событие, которое помогает уменьшить количество вызовов.
map.on('moveend', () => {
console.log('Перемещение завершено');
});
Событие вызывается только один раз после окончания движения карты.
Поэтому во многих случаях debounce вообще не требуется.
Пример:
map.on('moveend', async () => {
const bounds = map.getBounds();
const response = await fetch('/api/objects');
});
Такой подход обычно предпочтительнее постоянного отслеживания
move.
Событие масштабирования также может генерировать большое количество операций.
map.on('zoom', () => {
console.log('Масштаб изменяется');
});
Лучше использовать:
map.on('zoomend', () => {
console.log('Масштаб изменён');
});
Или комбинировать с debounce:
const updateLayers = debounce(() => {
refreshMarkers();
}, 300);
map.on('zoom', updateLayers);
Геокодеры обычно имеют строгие ограничения по количеству запросов.
Например, поиск адресов при вводе текста:
input.addEventListener('input', async (e) => {
const query = e.target.value;
await fetch(`/search?q=${query}`);
});
Каждый символ создаёт новый запрос.
Правильнее использовать debounce.
const searchAddress = debounce(async (query) => {
const response =
await fetch(`/search?q=${query}`);
}, 500);
input.addEventListener('input', e => {
searchAddress(e.target.value);
});
Теперь запрос будет выполнен только после завершения ввода.
Сервис геокодирования OpenStreetMap Nominatim известен строгими лимитами использования.
Неправильно:
map.on('move', async () => {
await fetch(nominatimUrl);
});
Правильно:
const reverseGeocode =
debounce(async () => {
await fetch(nominatimUrl);
}, 1000);
map.on('moveend', reverseGeocode);
Такой подход существенно снижает вероятность блокировки.
Иногда необходимо гарантировать, что одновременно выполняется ограниченное число запросов.
Для этого используется очередь.
class RequestQueue {
constructor(limit = 3) {
this.limit = limit;
this.running = 0;
this.queue = [];
}
add(task) {
return new Promise((resolve, reject) => {
this.queue.push(async () => {
try {
resolve(await task());
} catch (error) {
reject(error);
}
});
this.next();
});
}
next() {
if (
this.running >= this.limit ||
this.queue.length === 0
) {
return;
}
const task = this.queue.shift();
this.running++;
task().finally(() => {
this.running--;
this.next();
});
}
}
Использование:
const queue = new RequestQueue(2);
queue.add(() => fetch('/api/1'));
queue.add(() => fetch('/api/2'));
queue.add(() => fetch('/api/3'));
queue.add(() => fetch('/api/4'));
Одновременно будут выполняться только два запроса.
Во время перемещения карты предыдущий запрос может стать неактуальным ещё до завершения.
Современный подход основан на AbortController.
let controller;
async function loadData() {
if (controller) {
controller.abort();
}
controller = new AbortController();
try {
const response = await fetch(
'/api/data',
{
signal: controller.signal
}
);
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
}
Подключение к карте:
map.on('moveend', loadData);
Каждый новый запрос отменяет предыдущий.
Наиболее распространённый сценарий выглядит так:
let controller;
const updateMapData = debounce(async () => {
if (controller) {
controller.abort();
}
controller = new AbortController();
try {
const response = await fetch(
'/api/markers',
{
signal: controller.signal
}
);
const markers = await response.json();
renderMarkers(markers);
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
}, 500);
map.on('move', updateMapData);
Здесь одновременно решаются две задачи:
Не только сетевые запросы требуют ограничения.
Большое количество маркеров может серьёзно нагружать браузер.
Неправильно:
map.on('move', () => {
rebuildMarkers();
});
При непрерывном перемещении функция будет запускаться десятки раз в секунду.
Лучше:
const rebuild =
throttle(rebuildMarkers, 300);
map.on('move', rebuild);
При использовании плагина MarkerCluster возможны частые перерасчёты кластеров.
map.on('zoom', () => {
clusterLayer.refreshClusters();
});
Рациональнее:
const refresh =
debounce(() => {
clusterLayer.refreshClusters();
}, 300);
map.on('zoom', refresh);
Даже если клиент ограничивает частоту запросов, сервер также должен защищаться.
Типичные ограничения:
100 запросов в минуту
1000 запросов в час
10000 запросов в сутки
Сервер может возвращать:
429 Too Many Requests
При работе с Leaflet необходимо предусматривать обработку подобных ответов.
const response = await fetch(url);
if (response.status === 429) {
console.warn(
'Превышен лимит запросов'
);
}
Если сервер временно отклоняет запросы, применяется стратегия повторных попыток с возрастающей задержкой.
async function fetchWithRetry(
url,
retries = 5
) {
for (let i = 0; i < retries; i++) {
try {
const response =
await fetch(url);
if (response.ok) {
return response;
}
} catch (e) {}
const delay =
Math.pow(2, i) * 1000;
await new Promise(resolve =>
setTimeout(resolve, delay)
);
}
}
Задержки будут следующими:
1 секунда
2 секунды
4 секунды
8 секунд
16 секунд
Такой механизм снижает нагрузку на сервер во время сбоев.
Использовать:
moveend
или:
debounce(300–500 мс)
Использовать:
debounce(500–1000 мс)
Использовать:
throttle(100–300 мс)
Использовать:
AbortController
+
Debounce
Использовать:
Queue
+
Concurrency Limit
Современная схема работы Leaflet-приложения обычно выглядит следующим образом:
Перемещение карты
↓
move
↓
Debounce
↓
AbortController
↓
Получение границ карты
↓
API запрос
↓
Обработка ответа
↓
Обновление маркеров
Такая архитектура обеспечивает: