Ленивая загрузка

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

Для проектов на Mapbox GL JS этот подход особенно важен, поскольку карты могут содержать:

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

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


Основные цели ленивой загрузки

Грамотно реализованная ленивая загрузка решает несколько задач одновременно:

Ускорение первоначального рендеринга

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

Снижение потребления памяти

В браузере хранятся только используемые ресурсы.

Экономия сетевого трафика

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

Повышение отзывчивости интерфейса

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


Архитектура загрузки данных в Mapbox GL JS

Mapbox GL JS уже использует ленивую загрузку на уровне тайлов.

При перемещении карты движок самостоятельно:

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

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

Пользователь перемещает карту
           ↓
Вычисляется новый viewport
           ↓
Определяются необходимые тайлы
           ↓
Загружаются отсутствующие тайлы
           ↓
Выполняется отрисовка

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


Ленивая загрузка GeoJSON-источников

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

Плохой вариант:

const map = new mapboxgl.Map({
    container: 'map',
    style: 'mapbox://styles/mapbox/streets-v12'
});

map.on('load', () => {
    map.addSource('buildings', {
        type: 'geojson',
        data: '/data/buildings.geojson'
    });
});

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

Более эффективный подход:

let buildingsLoaded = false;

map.on('moveend', async () => {
    if (buildingsLoaded) {
        return;
    }

    const zoom = map.getZoom();

    if (zoom >= 14) {
        const response = await fetch('/data/buildings.geojson');
        const data = await response.json();

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

        map.addLayer({
            id: 'buildings',
            type: 'fill',
            source: 'buildings',
            paint: {
                'fill-color': '#4a90e2'
            }
        });

        buildingsLoaded = true;
    }
});

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


Загрузка данных по уровню масштабирования

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

Например:

Масштаб Данные
0–5 Страны
6–9 Регионы
10–13 Города
14+ Здания

Реализация:

let cityLayerLoaded = false;

map.on('zoomend', async () => {
    const zoom = map.getZoom();

    if (zoom >= 10 && !cityLayerLoaded) {
        const response = await fetch('/api/cities');
        const cities = await response.json();

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

        cityLayerLoaded = true;
    }
});

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


Ленивая загрузка слоёв

Не все слои должны существовать в карте с момента запуска.

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

document
    .getElementById('show-cameras')
    .addEventListener('click', async () => {

        if (map.getSource('cameras')) {
            return;
        }

        const response = await fetch('/api/cameras');
        const data = await response.json();

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

        map.addLayer({
            id: 'cameras',
            type: 'circle',
            source: 'cameras'
        });
    });

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


Ленивая загрузка изображений

Mapbox GL JS позволяет использовать пользовательские иконки.

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

map.loadImage('/icons/airport.png', (error, image) => {
    if (error) throw error;

    map.addImage('airport', image);
});

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

Более эффективный вариант:

async function loadAirportIcon() {

    if (map.hasImage('airport')) {
        return;
    }

    const image = await map.loadImage('/icons/airport.png');

    map.addImage('airport', image.data);
}

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


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

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

Это удобная точка для фоновой подгрузки данных.

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

    if (cacheLoaded) {
        return;
    }

    const response = await fetch('/api/preload');
    cache = await response.json();

    cacheLoaded = true;
});

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


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

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

Получение текущих границ:

const bounds = map.getBounds();

Запрос:

async function loadVisibleData() {

    const bounds = map.getBounds();

    const response = await fetch(
        `/api/objects?bbox=${
            bounds.getWest()
        },${
            bounds.getSouth()
        },${
            bounds.getEast()
        },${
            bounds.getNorth()
        }`
    );

    return response.json();
}

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

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

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

Наиболее распространённый механизм основан на событии moveend.

map.on('moveend', async () => {

    const data = await loadVisibleData();

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

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

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


Дебаунсинг запросов

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

Для ограничения используется debounce.

function debounce(callback, delay) {

    let timer;

    return (...args) => {

        clearTimeout(timer);

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

Применение:

const upd ateData = debounce(async () => {

    const data = await loadVisibleData();

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

}, 500);

map.on('moveend', updateData);

Сервер будет получать значительно меньше запросов.


Динамическое подключение модулей

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

Например, модуль тепловой карты нужен только после выбора определённого режима отображения.

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

    const heatmapModule =
        await import('./heatmap.js');

    heatmapModule.initialize(map);
});

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

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

Ленивая загрузка пользовательских контролов

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

async function loadMeasurementTool() {

    const module =
        await import('./measurement-tool.js');

    const control =
        new module.MeasurementControl();

    map.addControl(control);
}

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


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

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

Сначала отображается только необходимый набор данных:

loadCurrentViewport();

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

map.on('idle', preloadNearbyAreas);

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


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

Без кэширования одни и те же данные могут запрашиваться многократно.

Простейший вариант:

const cache = new Map();

Получение данных:

async function getData(url) {

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

    const response = await fetch(url);
    const data = await response.json();

    cache.se t(url, data);

    return data;
}

Кэш значительно снижает нагрузку на сервер и сеть.


Очистка неиспользуемых данных

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

Удаление слоя:

if (map.getLayer('traffic')) {
    map.removeLayer('traffic');
}

Удаление источника:

if (map.getSource('traffic')) {
    map.removeSource('traffic');
}

Это особенно важно для длительно работающих веб-приложений.


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

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

Для отмены используется AbortController.

let controller;

Перед выполнением нового запроса:

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

controller = new AbortController();

const response = await fetch(url, {
    signal: controller.signal
});

В результате обрабатываются только актуальные данные.


Комбинированная стратегия ленивой загрузки

В крупных проектах обычно применяется несколько механизмов одновременно:

  1. автоматическая загрузка тайлов Mapbox;
  2. подгрузка данных по границам карты;
  3. загрузка слоёв по масштабу;
  4. динамический импорт модулей;
  5. кэширование результатов;
  6. отмена устаревших запросов;
  7. фоновая предзагрузка соседних областей.

Пример общего сценария:

Инициализация карты
        ↓
Загрузка базового стиля
        ↓
Отображение интерфейса
        ↓
Пользователь меняет масштаб
        ↓
Подгружаются необходимые слои
        ↓
Пользователь перемещает карту
        ↓
Запрашиваются данные текущего bbox
        ↓
Результаты помещаются в кэш
        ↓
В фоне загружаются соседние области

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

Загрузка всех данных при старте

fetch('/all-data.geojson');

Такой подход плохо масштабируется.

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

map.on('move', loadData);

Может создавать десятки запросов каждую секунду.

Отсутствие кэша

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

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

Устаревшие ответы способны перезаписывать актуальные данные.

Избыточная предзагрузка

Слишком агрессивная фоновая загрузка сводит преимущества ленивого подхода к минимуму.


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

Для небольших проектов

  • загрузка данных по событию moveend;
  • использование кэша;
  • загрузка слоёв по требованию.

Для средних проектов

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

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

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

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