Lazy loading

Назначение и роль ленивой загрузки

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

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

В контексте Kepler.gl ленивую загрузку можно применять к нескольким категориям ресурсов:

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

Основная цель заключается в уменьшении размера первоначального JavaScript-бандла и сокращении времени загрузки приложения.


Причины использования lazy loading

Картографические приложения отличаются от обычных веб-интерфейсов рядом особенностей:

  • большие объёмы GeoJSON;
  • работа с CSV-файлами размером в сотни мегабайт;
  • визуализация миллионов точек;
  • использование Deck.gl и WebGL;
  • большое количество вычислений на стороне клиента.

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

Длительное время первого отображения

Пользователь ожидает загрузки:

  • React;
  • Redux;
  • Kepler.gl;
  • Deck.gl;
  • Mapbox GL;
  • данных карты.

Даже при хорошем соединении это может привести к заметным задержкам.

Увеличение размера бандла

Kepler.gl является достаточно крупной библиотекой. Если карта используется только на одной странице приложения, нецелесообразно включать её код в основной пакет, который загружается на всех страницах.

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

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


Ленивое подключение компонента Kepler.gl

Наиболее распространённый сценарий — динамический импорт компонента карты.

Статический импорт

import KeplerGl from 'kepler.gl';

function App() {
  return (
    <KeplerGl
      id="map"
      width={window.innerWidth}
      height={window.innerHeight}
    />
  );
}

В этом случае код Kepler.gl попадает в основной бандл приложения.

Динамический импорт

import React, { lazy, Suspense } from 'react';

const KeplerGl = lazy(() => import('kepler.gl'));

function App() {
  return (
    <Suspense fallback={<div>Загрузка карты...</div>}>
      <KeplerGl
        id="map"
        width={window.innerWidth}
        height={window.innerHeight}
      />
    </Suspense>
  );
}

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

  • отдельный chunk для Kepler.gl;
  • уменьшение стартового пакета;
  • загрузка только при открытии страницы с картой.

Использование React.lazy

Поскольку Kepler.gl обычно используется вместе с React, механизм React.lazy() является базовым инструментом реализации ленивой загрузки.

Принцип работы

const MapPage = React.lazy(() =>
  import('./pages/MapPage')
);

При переходе на страницу:

<Route
  path="/map"
  element={
    <Suspense fallback={<Spinner />}>
      <MapPage />
    </Suspense>
  }
/>

происходит:

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

Разделение кода по маршрутам

Одним из наиболее эффективных способов lazy loading является route-based code splitting.

Структура проекта

src/
├── pages/
│   ├── HomePage.jsx
│   ├── ReportsPage.jsx
│   └── MapPage.jsx
├── App.jsx
└── store.js

Настройка

const HomePage = lazy(() => import('./pages/HomePage'));
const ReportsPage = lazy(() => import('./pages/ReportsPage'));
const MapPage = lazy(() => import('./pages/MapPage'));

Если карта используется редко, пользователи, которые никогда не открывают раздел картографии, не будут загружать Kepler.gl вовсе.


Ленивое получение геоданных

Загрузка самого компонента карты решает лишь часть проблемы. Часто значительно больший объём занимают данные.

Нежелательный подход

import cityData from './data/cities.geojson';

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

Для файлов размером десятки мегабайт это крайне неэффективно.

Получение по запросу

async function loadData() {
  const response = await fetch('/data/cities.geojson');
  return response.json();
}

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


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

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

useEffect(() => {
  async function fetchData() {
    const response = await fetch('/api/locations');
    const data = await response.json();

    dispatch(addDataToMap(data));
  }

  fetchData();
}, []);

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

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

Lazy loading больших GeoJSON-файлов

GeoJSON способен содержать огромные массивы координат.

Пример:

{
  "type": "FeatureCollection",
  "features": [...]
}

Размер файла может достигать сотен мегабайт.

Полная загрузка такого ресурса приводит к:

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

Подход с тайлами

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

tiles/
├── 0_0.geojson
├── 0_1.geojson
├── 1_0.geojson
└── 1_1.geojson

Загружается только видимая область карты.


Подгрузка данных в зависимости от масштаба

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

Общая схема

При масштабе:

zoom < 6

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

При масштабе:

zoom >= 6

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

Пример:

if (zoom < 6) {
  loadRegions();
} else {
  loadBuildings();
}

Подобный подход существенно уменьшает объём передаваемой информации.


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

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

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

const bounds = map.getBounds();

После этого выполняется запрос:

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

Сервер возвращает исключительно объекты внутри видимой области.

Это один из наиболее эффективных методов работы с большими пространственными данными.


Использование динамического импорта конфигураций

Конфигурации карт также могут занимать значительный объём.

Например:

import worldConfig from './configs/world.json';
import europeConfig from './configs/europe.json';
import asiaConfig from './configs/asia.json';

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

const config = await import(
  `./configs/${region}.json`
);

Загружается только необходимая конфигурация.


Ленивое подключение пользовательских слоёв

В некоторых проектах создаются собственные расширения поверх Deck.gl и Kepler.gl.

Пример

const HeatmapLayer = lazy(() =>
  import('./layers/HeatmapLayer')
);

Загрузка происходит только при выборе соответствующего режима визуализации.

Это особенно полезно для:

  • тепловых карт;
  • 3D-визуализаций;
  • специализированных аналитических слоёв.

Комбинация lazy loading и Redux

Kepler.gl активно использует Redux.

Большие наборы данных не следует помещать в store заранее.

Нежелательный вариант:

const initialState = {
  hugeDataset
};

Лучше загружать данные после инициализации приложения.

dispatch(loadDataset());

Внутри thunk:

export const loadDataset = () => async dispatch => {
  const response = await fetch('/api/data');

  dispatch({
    type: 'DATA_LOADED',
    payload: await response.json()
  });
};

Предварительная загрузка (Prefetch)

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

Идея

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

Пример:

import(
  /* webpackPrefetch: true */
  './MapPage'
);

Webpack создаёт специальную инструкцию браузеру.

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

В результате:

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

Предзагрузка по событию наведения

Интересный вариант оптимизации — загрузка карты при наведении курсора.

function preloadMap() {
  import('./MapPage');
}
<button
  onMouseEn ter={preloadMap}
>
  Карта
</button>

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


Обработка состояний загрузки

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

Индикатор загрузки

<Suspense
  fallback={<LoadingSpinner />}
>
  <MapPage />
</Suspense>

Скелетон-интерфейс

<Suspense
  fallback={<MapSkeleton />}
>
  <MapPage />
</Suspense>

Скелетоны позволяют избежать ощущения «пустого экрана».


Обработка ошибок загрузки

Сетевые ошибки должны учитываться заранее.

Error Boundary

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return <div>Ошибка загрузки карты</div>;
    }

    return this.props.children;
  }
}

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

<ErrorBoundary>
  <Suspense fallback={<Spinner />}>
    <MapPage />
  </Suspense>
</ErrorBoundary>

Влияние lazy loading на производительность

Наиболее заметные улучшения обычно наблюдаются по следующим метрикам:

First Contentful Paint (FCP)

Первые элементы интерфейса отображаются быстрее.

Largest Contentful Paint (LCP)

Ускоряется появление основного контента страницы.

Time To Interactive (TTI)

Приложение раньше становится интерактивным.

JavaScript Payload

Уменьшается объём загружаемого JavaScript.


Стратегии применения в крупных проектах

Уровень приложения

Разделение страниц:

Home
Analytics
Dashboard
Maps
Settings

Каждая страница становится отдельным chunk.

Уровень карты

Разделение:

Map Core
Layers
Filters
Analytics
Export

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

Уровень данных

Разделение:

Countries
Regions
Cities
Buildings
POI

Каждый набор данных получает собственный жизненный цикл загрузки.


Lazy Loading и серверные API

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

Пример:

fetch(
  `/api/events?page=1&limit=500`
);

Затем:

fetch(
  `/api/events?page=2&limit=500`
);

Такой подход сочетает:

  • пагинацию;
  • ленивую загрузку;
  • снижение потребления памяти.

Lazy Loading и потоковая обработка данных

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

Пример использования потоков:

const response = await fetch(url);

const reader = response.body.getReader();

По мере поступления данных можно постепенно формировать наборы для отображения в Kepler.gl.

Это особенно актуально для:

  • телеметрии;
  • GPS-треков;
  • логистических систем;
  • мониторинга транспорта в реальном времени.

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

Не включать GeoJSON напрямую в бандл приложения.

Даже относительно небольшие файлы желательно загружать отдельно.

Разделять маршруты приложения.

Страница карты должна представлять отдельный chunk.

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

Особенно для аналитики, 3D-слоёв и специализированных визуализаций.

Загружать данные по области просмотра карты.

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

Комбинировать lazy loading и prefetch.

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

Использовать многоуровневую загрузку данных.

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

Контролировать размеры chunk-файлов.

Даже после внедрения lazy loading отдельные модули не должны становиться чрезмерно большими.

Организовывать кэширование данных.

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

Обрабатывать ошибки и отображать промежуточные состояния.

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