React компоненты для карты

Интеграция Google Maps JavaScript API в React требует адаптации императивной модели API к декларативной парадигме компонентов. Основная сложность заключается в том, что карта создаётся и управляется вне React-дерева, через глобальный объект google.maps, тогда как React ожидает управляемого состояния и предсказуемого жизненного цикла.

Типовая архитектура строится вокруг одного корневого компонента карты и набора дочерних компонентов-обёрток для маркеров, оверлеев и контролов.

Базовый компонент карты

Ключевая задача — корректная инициализация экземпляра карты и привязка его к DOM-элементу:

import { useEffect, useRef } from "react";

export function Map({ center, zoom }) {
  const mapRef = useRef(null);
  const instanceRef = useRef(null);

  useEffect(() => {
    if (!mapRef.current) return;

    instanceRef.current = new google.maps.Map(mapRef.current, {
      center,
      zoom,
    });

    return () => {
      instanceRef.current = null;
    };
  }, []);

  useEffect(() => {
    if (instanceRef.current) {
      instanceRef.current.setCenter(center);
    }
  }, [center]);

  useEffect(() => {
    if (instanceRef.current) {
      instanceRef.current.setZoom(zoom);
    }
  }, [zoom]);

  return <div ref={mapRef} style={{ width: "100%", height: "400px" }} />;
}

В этой модели:

  • DOM-узел управляется React
  • экземпляр карты живёт вне виртуального DOM
  • изменения props синхронизируются через useEffect

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

React-компоненты, работающие с картой, делятся на два типа:

  • контейнерные (инициализация Map)
  • привязанные к карте (Marker, Polyline, InfoWindow)

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

Контекст карты

Для устранения проп-дриллинга используется React Context:

import { createContext, useContext } from "react";

const MapContext = createContext(null);

export function useMap() {
  return useContext(MapContext);
}

Обёртка провайдера:

export function MapProvider({ map, children }) {
  return (
    <MapContext.Provider value={map}>
      {children}
    </MapContext.Provider>
  );
}

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

Маркер является типичным примером побочного объекта, который существует вне React.

import { useEffect } from "react";
import { useMap } from "./MapContext";

export function Marker({ position }) {
  const map = useMap();

  useEffect(() => {
    if (!map) return;

    const marker = new google.maps.Marker({
      position,
      map,
    });

    return () => {
      marker.setMap(null);
    };
  }, [map, position]);

  return null;
}

Особенности:

  • компонент не рендерит DOM
  • жизненный цикл полностью управляет объектом google.maps.Marker
  • очистка обязательна для предотвращения утечек памяти

Синхронизация состояния React и карты

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

Двустороннее обновление центра

useEffect(() => {
  if (!instanceRef.current) return;

  const listener = instanceRef.current.addListener("center_changed", () => {
    const newCenter = instanceRef.current.getCenter().toJSON();
    onCenterChange(newCenter);
  });

  return () => listener.remove();
}, []);

Такой подход создаёт мост:

  • React управляет начальным состоянием
  • карта сообщает об изменениях через события

Обработка событий карты

Google Maps JavaScript API использует событийную модель, не совпадающую с React Synthetic Events.

useEffect(() => {
  if (!map) return;

  const clickListener = map.addListener("click", (e) => {
    console.log(e.latLng.toJSON());
  });

  return () => clickListener.remove();
}, [map]);

Для масштабируемой архитектуры события обычно абстрагируются:

export function useMapEvent(eventName, handler) {
  const map = useMap();

  useEffect(() => {
    if (!map) return;

    const listener = map.addListener(eventName, handler);
    return () => listener.remove();
  }, [map, eventName, handler]);
}

Инкапсуляция через кастомные хуки

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

useGoogleMap

import { useEffect, useRef } from "react";

export function useGoogleMap(options) {
  const ref = useRef(null);
  const mapRef = useRef(null);

  useEffect(() => {
    if (!ref.current) return;

    mapRef.current = new google.maps.Map(ref.current, options);

    return () => {
      mapRef.current = null;
    };
  }, []);

  return { ref, map: mapRef };
}

Работа с асинхронной загрузкой API

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

export function loadGoogleMaps(apiKey) {
  return new Promise((resolve) => {
    if (window.google?.maps) {
      resolve(window.google.maps);
      return;
    }

    const script = document.createElement("script");
    script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}`;
    script.async = true;

    script.onl oad = () => resolve(window.google.maps);

    document.head.appendChild(script);
  });
}

Компонент инициализации:

useEffect(() => {
  loadGoogleMaps(apiKey).then(() => {
    setReady(true);
  });
}, []);

Интеграция с React-компонентами через @react-google-maps/api

На практике часто используется обёртка @react-google-maps/api, которая инкапсулирует загрузку и базовые сущности карты.

Модель компонентов:

  • LoadScript
  • GoogleMap
  • Marker
  • InfoWindow

Однако внутренняя логика остаётся аналогичной: синхронизация imperative API с React lifecycle.


Кластеризация маркеров

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

useEffect(() => {
  if (!map) return;

  const clusterer = new markerClusterer.MarkerClusterer({
    map,
    markers,
  });

  return () => clusterer.clearMarkers();
}, [map, markers]);

Проблемный аспект:

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

Оптимизация рендеринга

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

export const Marker = React.memo(function Marker(props) {
  ...
});

Стабилизация ссылок

const position = useMemo(
  () => ({ lat, lng }),
  [lat, lng]
);

Без стабилизации объектов карта будет пересоздавать сущности при каждом рендере.


Контролируемая и неконтролируемая модель карты

Неконтролируемая модель

  • карта инициализируется один раз
  • React не управляет состоянием после создания

Контролируемая модель

  • центр, zoom и состояние синхронизируются через props
  • требуется обработка событий обратной связи

Контролируемая модель увеличивает сложность, но позволяет интегрировать карту в глобальный state (Redux, Zustand).


Работа с InfoWindow

useEffect(() => {
  if (!map) return;

  const infoWindow = new google.maps.InfoWindow({
    content: "<div>Text</div>",
  });

  infoWindow.open(map, marker);

  return () => infoWindow.close();
}, [map, marker]);

Особенность:

  • контент часто генерируется через string или DOM node
  • React нельзя напрямую рендерить внутрь InfoWindow без порталов

Использование React Portal для оверлеев

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

import { createPortal } from "react-dom";

export function Overlay({ map, position, children }) {
  const div = document.createElement("div");

  useEffect(() => {
    const overlay = new google.maps.OverlayView();

    overlay.on Add = () => {
      const panes = overlay.getPanes();
      panes.overlayLayer.appendChild(div);
    };

    overlay.draw = () => {
      const projection = overlay.getProjection();
      const point = projection.fromLatLngToDivPixel(position);
      div.style.transform = `translate(${point.x}px, ${point.y}px)`;
    };

    overlay.setMap(map);

    return () => overlay.setMap(null);
  }, [map, position]);

  return createPortal(children, div);
}

Типизация компонентов (TypeScript)

interface MapProps {
  center: { lat: number; lng: number };
  zoom: number;
  onCenterChange?: (pos: any) => void;
}

Типизация критична для:

  • событий API
  • координат
  • объектов google.maps.*

Частые архитектурные ошибки

  • повторная инициализация карты при каждом рендере
  • отсутствие cleanup слушателей событий
  • хранение google.maps объектов в state React
  • отсутствие стабилизации зависимостей useEffect
  • смешивание DOM управления React и Google Maps без разделения ответственности

Масштабируемая структура проекта

Типовая организация:

/map
  Map.tsx
  MapProvider.tsx
  hooks/
    useGoogleMap.ts
    useMapEvent.ts
  components/
    Marker.tsx
    Polyline.tsx
    InfoWindow.tsx

Такое разделение позволяет:

  • изолировать imperative слой
  • переиспользовать обёртки
  • избегать утечек логики в UI-компоненты