Компонентный подход

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

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

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

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


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

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

const map = new mapboxgl.Map({
    container: 'map',
    style: 'mapbox://styles/mapbox/streets-v12',
    center: [37.6176, 55.7558],
    zoom: 10
});

map.on('load', () => {
    map.addSource(...);
    map.addLayer(...);
    map.addLayer(...);
    map.addLayer(...);

    map.on('click', ...);
    map.on('mousemove', ...);
});

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

  • десятки источников данных;
  • множество слоёв;
  • различные режимы отображения;
  • сложная бизнес-логика;
  • динамическая загрузка данных.

В такой ситуации компонентный подход позволяет:

Повысить читаемость кода

Каждая функция находится в отдельном модуле.

Упростить сопровождение

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

Улучшить повторное использование

Компоненты можно переносить между проектами.

Упростить тестирование

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


Базовая структура компонентов

Один из распространённых вариантов организации проекта:

src/
│
├── map/
│   ├── MapManager.js
│   ├── LayerManager.js
│   ├── SourceManager.js
│
├── components/
│   ├── MarkersComponent.js
│   ├── RoutesComponent.js
│   ├── BuildingsComponent.js
│   └── PopupComponent.js
│
├── services/
│   ├── ApiService.js
│   └── GeoService.js
│
└── main.js

Каждый файл отвечает только за одну область ответственности.


Центральный объект карты

Практически все компоненты взаимодействуют с экземпляром Mapbox GL JS.

Часто создаётся специальный класс управления картой.

MapManager

import mapboxgl from 'mapbox-gl';

export class MapManager {

    constructor(container) {

        this.map = new mapboxgl.Map({
            container,
            style: 'mapbox://styles/mapbox/light-v11',
            center: [37.6176, 55.7558],
            zoom: 10
        });
    }

    getMap() {
        return this.map;
    }
}

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

const manager = new MapManager('map');
const map = manager.getMap();

Теперь остальные компоненты получают ссылку на карту через единый интерфейс.


Компоненты как независимые классы

Каждый компонент может представлять собой отдельный класс.

Пример компонента маркеров.

export class MarkersComponent {

    constructor(map) {
        this.map = map;
        this.markers = [];
    }

    addMarker(coordinates) {

        const marker = new mapboxgl.Marker()
            .setLngLat(coordinates)
            .addTo(this.map);

        this.markers.push(marker);
    }

    clear() {

        this.markers.forEach(marker => marker.remove());
        this.markers = [];
    }
}

Подключение:

const markers = new MarkersComponent(map);

markers.addMarker([37.61, 55.75]);
markers.addMarker([37.63, 55.76]);

Компонент полностью скрывает внутреннюю реализацию работы с маркерами.


Изоляция работы со слоями

Слои являются одной из самых важных частей Mapbox GL JS.

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

Компонент зданий

export class BuildingsComponent {

    constructor(map) {
        this.map = map;
    }

    init() {

        this.map.addLayer({
            id: 'buildings',
            source: 'composite',
            'source-layer': 'building',
            type: 'fill-extrusion',
            paint: {
                'fill-extrusion-color': '#aaaaaa',
                'fill-extrusion-height': [
                    'get',
                    'height'
                ]
            }
        });
    }
}

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

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

    const buildings = new BuildingsComponent(map);

    buildings.init();
});

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


Компоненты источников данных

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

Выделение отдельного компонента упрощает управление.

export class GeoJsonSource {

    constructor(map) {
        this.map = map;
    }

    create(data) {

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

    upd ate(data) {

        this.map.getSource('cities')
            .setData(data);
    }
}

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

const source = new GeoJsonSource(map);

source.create(geojsonData);

Изменение данных:

source.update(newGeojsonData);

Инкапсуляция событий

События карты быстро становятся источником хаоса в приложении.

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

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

map.on('click', ...);
map.on('mousemove', ...);
map.on('mouseenter', ...);
map.on('mouseleave', ...);
map.on('zoom', ...);
map.on('move', ...);

Компонентный вариант:

export class PopupComponent {

    constructor(map) {
        this.map = map;
    }

    initialize() {

        this.map.on(
            'click',
            'cities-layer',
            this.handleClick.bind(this)
        );
    }

    handleClick(event) {

        const feature = event.features[0];

        new mapboxgl.Popup()
            .setLngLat(feature.geometry.coordinates)
            .setHTML(feature.properties.name)
            .addTo(this.map);
    }
}

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


Управление жизненным циклом компонентов

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

Типичная структура:

class LayerComponent {

    mount() {

    }

    unmount() {

    }
}

Пример:

export class TrafficComponent {

    constructor(map) {
        this.map = map;
    }

    mount() {

        this.map.addLayer({
            id: 'traffic',
            type: 'line',
            source: 'traffic-source'
        });
    }

    unmount() {

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

Включение:

traffic.mount();

Отключение:

traffic.unmount();

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


Контейнер компонентов

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

export class ComponentRegistry {

    constructor() {
        this.components = new Map();
    }

    register(name, component) {
        this.components.se t(name, component);
    }

    get(name) {
        return this.components.get(name);
    }
}

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

const registry = new ComponentRegistry();

registry.register(
    'markers',
    new MarkersComponent(map)
);

registry.register(
    'routes',
    new RoutesComponent(map)
);

Получение компонента:

registry
    .get('markers')
    .addMarker([37.6, 55.7]);

Композиция компонентов

Компоненты могут объединяться друг с другом.

Например:

RouteComponent
│
├── RouteLayer
├── RouteMarkers
└── RoutePopup

Класс маршрута:

export class RouteComponent {

    constructor(map) {

        this.layer =
            new RouteLayer(map);

        this.markers =
            new RouteMarkers(map);

        this.popup =
            new RoutePopup(map);
    }

    initialize() {

        this.layer.initialize();
        this.markers.initialize();
        this.popup.initialize();
    }
}

Каждый вложенный модуль решает свою задачу.


Компоненты и асинхронные данные

Многие компоненты работают с удалёнными API.

Например, слой объектов недвижимости.

export class EstateComponent {

    constructor(map, api) {

        this.map = map;
        this.api = api;
    }

    async load() {

        const data =
            await this.api.getEstates();

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

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

await estateComponent.load();

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


Разделение бизнес-логики и визуализации

Одним из принципов компонентной архитектуры является отделение данных от отображения.

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

async function loadObjects() {

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

    const data =
        await response.json();

    map.addSource(...);
    map.addLayer(...);
}

Более правильный вариант:

Сервис

export class ObjectsService {

    async getObjects() {

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

        return response.json();
    }
}

Компонент

export class ObjectsLayer {

    constructor(map, service) {

        this.map = map;
        this.service = service;
    }

    async render() {

        const data =
            await this.service.getObjects();

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

Визуальный слой больше не знает, откуда поступают данные.


Компонентный подход в React

Mapbox GL JS часто используется совместно с React.

Компонент карты:

function MapView() {

    const mapRef = useRef();

    useEffect(() => {

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

        return () => map.remove();

    }, []);

    return (
        <div
            ref={mapRef}
            className="map"
        />
    );
}

Компонент слоя:

function CitiesLayer({ map, data }) {

    useEffect(() => {

        if (!map) {
            return;
        }

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

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

    }, [map, data]);

    return null;
}

Каждый React-компонент становится отдельным логическим блоком карты.


Компонентный подход в Vue

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

<script setup>
import { onMounted } from 'vue';

onMounted(() => {

    const map = new mapboxgl.Map({
        container: 'map'
    });

});
</script>

<template>
    <div id="map"></div>
</template>

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


Dependency Injection для компонентов

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

Без внедрения:

class LayerComponent {

    constructor(map) {

        this.map = map;
        this.api =
            new ApiService();
    }
}

С внедрением:

class LayerComponent {

    constructor(map, api) {

        this.map = map;
        this.api = api;
    }
}

Создание:

const component =
    new LayerComponent(
        map,
        apiService
    );

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

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

Повторно используемые картографические компоненты

Наиболее часто в Mapbox GL JS выделяются следующие универсальные компоненты:

Компонент Назначение
MarkerComponent Работа с маркерами
PopupComponent Управление всплывающими окнами
RouteComponent Построение маршрутов
LayerComponent Управление слоями
SourceComponent Работа с источниками данных
DrawComponent Редактирование геометрии
SearchComponent Геопоиск
ThemeComponent Переключение стилей
ClusterComponent Кластеризация точек
ControlsComponent Навигационные элементы

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