Lazy loading данных

Lazy loading (ленивая загрузка) — подход, при котором данные загружаются не целиком при инициализации карты, а только в тот момент, когда они действительно необходимы. Для веб-карт это особенно важно, поскольку геоданные могут содержать десятки тысяч объектов, занимать мегабайты трафика и существенно влиять на производительность браузера.

В контексте Mapbox GL JS ленивую загрузку применяют для:

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

Основная цель — уменьшить объем передаваемых данных и снизить нагрузку на процессор, память и сеть.


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

Рассмотрим ситуацию, когда сервер возвращает GeoJSON с 100 000 объектов.

fetch('/api/points')
    .then(response => response.json())
    .then(data => {
        map.addSource('points', {
            type: 'geojson',
            data: data
        });
    });

Подобный подход приводит к нескольким проблемам:

Долгая загрузка

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

Повышенное потребление памяти

Все объекты одновременно находятся в памяти JavaScript и движка Mapbox GL JS.

Низкая производительность

Каждый объект участвует в:

  • рендеринге;
  • вычислении стилей;
  • обработке событий;
  • поиске пересечений.

Избыточный трафик

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


Стратегии ленивой загрузки

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

Загрузка по границам карты

Подгружаются только объекты внутри текущей области просмотра.

Загрузка по масштабу

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

Загрузка по тайлам

Карта получает небольшие фрагменты данных вместо одного большого файла.

Загрузка по требованию

Данные запрашиваются только после определенного действия пользователя.

Постепенная загрузка

Информация поступает небольшими порциями.


Получение данных для текущей области карты

Одним из наиболее распространенных подходов является запрос объектов внутри текущего viewport.

Получение границ карты

Mapbox GL JS предоставляет объект LngLatBounds.

const bounds = map.getBounds();

Получение координат:

const west = bounds.getWest();
const south = bounds.getSouth();

const east = bounds.getEast();
const north = bounds.getNorth();

Формирование запроса:

const bounds = map.getBounds();

const url =
    `/api/objects?` +
    `west=${bounds.getWest()}&` +
    `south=${bounds.getSouth()}&` +
    `east=${bounds.getEast()}&` +
    `north=${bounds.getNorth()}`;

Загрузка данных после перемещения карты

Для реализации lazy loading часто используется событие moveend.

map.on('moveend', loadVisibleData);

Функция загрузки:

async function loadVisibleData() {

    const bounds = map.getBounds();

    const response = await fetch(
        `/api/objects?` +
        `west=${bounds.getWest()}&` +
        `south=${bounds.getSouth()}&` +
        `east=${bounds.getEast()}&` +
        `north=${bounds.getNorth()}`
    );

    const geojson = await response.json();

    map.getSource('objects').setData(geojson);
}

После завершения панорамирования карта получает только актуальные объекты.


Предотвращение слишком частых запросов

Пользователь может быстро перемещать карту, вызывая множество запросов подряд.

Для борьбы с этим применяется debounce.

Реализация debounce

function debounce(callback, delay) {

    let timer;

    return function (...args) {

        clearTimeout(timer);

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

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

const debouncedLoad =
    debounce(loadVisibleData, 300);

map.on('moveend', debouncedLoad);

Теперь запрос отправится только после завершения серии перемещений.


Отмена устаревших запросов

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

Для решения используется AbortController.

let controller;

Перед каждым запросом:

async function loadVisibleData() {

    if (controller) {
        controller.abort();
    }

    controller = new AbortController();

    const response = await fetch(
        '/api/objects',
        {
            signal: controller.signal
        }
    );

    const data = await response.json();

    map.getSource('objects').setData(data);
}

Если появляется новый запрос, предыдущий немедленно отменяется.


Lazy loading по уровню масштаба

Разные уровни детализации требуют разных объемов данных.

Например:

Масштаб Данные
0–5 Страны
6–9 Регионы
10–13 Города
14+ Отдельные объекты

Получение текущего масштаба:

const zoom = map.getZoom();

Выбор источника:

async function loadData() {

    const zoom = map.getZoom();

    let endpoint;

    if (zoom < 6) {
        endpoint = '/api/countries';
    }
    else if (zoom < 10) {
        endpoint = '/api/regions';
    }
    else {
        endpoint = '/api/cities';
    }

    const response =
        await fetch(endpoint);

    const data =
        await response.json();

    map.getSource('layer')
        .setData(data);
}

Запуск:

map.on('zoomend', loadData);

Векторные тайлы как встроенный механизм lazy loading

Наиболее эффективным способом ленивой загрузки являются vector tiles.

Mapbox GL JS изначально создавался для работы именно с ними.

Вместо загрузки большого GeoJSON карта получает небольшие тайлы.

Схема выглядит следующим образом:

Карта
   ↓
Видимая область
   ↓
Набор тайлов
   ↓
Загрузка только нужных тайлов

Пример подключения:

map.addSource('roads', {
    type: 'vector',
    tiles: [
        'https://server.com/tiles/{z}/{x}/{y}.pbf'
    ]
});

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


Преимущества векторных тайлов

Масштабируемость

Система способна обслуживать миллионы объектов.

Минимальный трафик

Передаются только данные для видимой области.

Быстрая отрисовка

Формат PBF оптимизирован для работы в браузере.

Кэширование

Загруженные тайлы могут использоваться повторно.


Загрузка данных при открытии объекта

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

Достаточно загрузить основные данные:

{
    "id": 1001,
    "name": "Office"
}

Подробности подгружаются после выбора объекта.

Обработка клика

map.on('click', 'points', async (e) => {

    const feature = e.features[0];

    const response = await fetch(
        `/api/details/${feature.id}`
    );

    const details =
        await response.json();

    console.log(details);
});

Такой подход существенно уменьшает начальный объем данных.


Ленивое добавление слоев

Некоторые тематические слои могут быть скрыты большую часть времени.

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

Например, после нажатия кнопки.

button.addEventListener('click', async () => {

    const response =
        await fetch('/api/buildings');

    const data =
        await response.json();

    map.addSource('buildings', {
        type: 'geojson',
        data: data
    });

    map.addLayer({
        id: 'buildings',
        type: 'fill',
        source: 'buildings'
    });
});

До момента открытия слоя никаких сетевых запросов не происходит.


Подгрузка данных по сетке

При работе с собственными API часто применяется пространственное разбиение данных.

Карта делится на ячейки:

+----+----+----+
| A1 | A2 | A3 |
+----+----+----+
| B1 | B2 | B3 |
+----+----+----+
| C1 | C2 | C3 |
+----+----+----+

Каждая ячейка хранится отдельно.

Когда пользователь перемещает карту, вычисляются нужные клетки:

loadTile("A2");
loadTile("A3");
loadTile("B2");
loadTile("B3");

Этот подход напоминает работу настоящих картографических тайлов.


Кэширование загруженных данных

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

Простейший кэш:

const cache = new Map();

Проверка:

async function getData(tileId) {

    if (cache.has(tileId)) {
        return cache.get(tileId);
    }

    const response =
        await fetch(`/tiles/${tileId}`);

    const data =
        await response.json();

    cache.set(tileId, data);

    return data;
}

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

  • уменьшение нагрузки на сервер;
  • ускорение отображения;
  • снижение сетевого трафика.

Комбинирование lazy loading и кластеризации

Для больших наборов точек эффективна комбинация двух технологий:

  1. Ленивой загрузки.
  2. Кластеризации.

Источник:

map.addSource('earthquakes', {
    type: 'geojson',
    data: '/api/visible-data',
    cluster: true,
    clusterRadius: 50
});

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

Это позволяет отображать сотни тысяч объектов без критического падения производительности.


Предзагрузка соседних областей

Для повышения плавности интерфейса используется предварительная загрузка соседних данных.

Схема:

Текущая область
      ↓
Загрузка
      ↓
Соседние области

Пользователь перемещается:

A → B

Данные области B уже находятся в памяти.

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


Использование события idle

Событие idle возникает тогда, когда карта завершила все текущие операции рендеринга.

map.on('idle', () => {

    preloadNeighborTiles();

});

Это удобный момент для:

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

Контроль объема памяти

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

Обычно вводится ограничение.

const MAX_CACHE_SIZE = 100;

Удаление старых элементов:

if (cache.size > MAX_CACHE_SIZE) {

    const firstKey =
        cache.keys().next().value;

    cache.delete(firstKey);
}

Так реализуется простейшая стратегия очистки памяти.


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

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

Основной поток:

worker.postMessage(data);

Worker:

self.onmess age = function(event) {

    const processed =
        processData(event.data);

    self.postMessage(processed);
};

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

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

Архитектура ленивой загрузки в крупных приложениях

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

Mapbox GL JS
      ↓
Viewport
      ↓
Bounding Box
      ↓
API
      ↓
База данных
      ↓
GeoJSON / Vector Tiles
      ↓
Кэш клиента
      ↓
Отображение

Каждый уровень отвечает за сокращение объема данных, передаваемых между сервером и браузером.


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

Для наборов до нескольких тысяч объектов

  • достаточно загрузки по границам карты;
  • можно использовать GeoJSON.

Для десятков тысяч объектов

  • желательно применять кластеризацию;
  • использовать кэширование;
  • отменять устаревшие запросы.

Для сотен тысяч объектов и более

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

Для высоконагруженных геоинформационных систем

  • комбинировать vector tiles и lazy loading;
  • выполнять предзагрузку соседних тайлов;
  • использовать Web Workers для обработки данных;
  • ограничивать размер клиентского кэша;
  • разделять данные по уровням детализации (LOD);
  • минимизировать объем GeoJSON, передаваемого через сеть.