Vue компоненты

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

Типовая архитектура Vue-обёртки над картой включает три слоя:

  • загрузчик API (динамическая подгрузка скрипта)
  • компонент карты (инициализация google.maps.Map)
  • слой сущностей (markers, polylines, overlays)
  • реактивные адаптеры (watchers и синхронизация состояния)

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


Динамическая загрузка Google Maps API

API подключается через загрузку внешнего скрипта. В Vue-проектах это обычно реализуется через singleton-загрузчик.

let googleMapsPromise = null;

export function loadGoogleMaps(apiKey) {
  if (googleMapsPromise) return googleMapsPromise;

  googleMapsPromise = new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
    script.async = true;
    script.defer = true;

    script.onl oad = () => resolve(window.google);
    script.oner ror = reject;

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

  return googleMapsPromise;
}

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


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

Компонент карты является контейнером для google.maps.Map и управляет жизненным циклом экземпляра карты.

Composition API вариант

import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { loadGoogleMaps } from './loadGoogleMaps';

export default {
  props: {
    apiKey: String,
    center: Object,
    zoom: Number
  },

  setup(props) {
    const mapContainer = ref(null);
    const map = ref(null);

    onMounted(async () => {
      const google = await loadGoogleMaps(props.apiKey);

      map.value = new google.maps.Map(mapContainer.value, {
        center: props.center,
        zoom: props.zoom
      });
    });

    onBeforeUnmount(() => {
      map.value = null;
    });

    watch(() => props.center, (newCenter) => {
      if (map.value) {
        map.value.setCenter(newCenter);
      }
    }, { deep: true });

    watch(() => props.zoom, (newZoom) => {
      if (map.value) {
        map.value.setZoom(newZoom);
      }
    });

    return { mapContainer };
  }
};

Реактивность и синхронизация состояния

Vue-реактивность не распространяется на объекты Google Maps. Любое изменение состояния должно быть явно синхронизировано.

Типовые сценарии синхронизации:

  • изменение центра карты
  • изменение масштаба
  • обновление маркеров
  • изменение геометрий (polyline, polygon)

Центр и масштаб

Изменения простых параметров обрабатываются через watch:

watch(() => props.center, (val) => {
  map.value?.panTo(val);
});

watch(() => props.zoom, (val) => {
  map.value?.setZoom(val);
});

Использование panTo предпочтительнее setCenter, так как обеспечивает плавное перемещение.


Управление маркерами как реактивной коллекцией

Маркерная система требует синхронизации массива объектов Vue с экземплярами google.maps.Marker.

Структура данных

props: {
  markers: {
    type: Array,
    default: () => []
  }
}

Слой синхронизации

const markerInstances = new Map();

watch(() => props.markers, (newMarkers) => {
  const google = window.google;

  // удаление старых
  markerInstances.forEach((marker, id) => {
    const exists = newMarkers.find(m => m.id === id);
    if (!exists) {
      marker.setMap(null);
      markerInstances.delete(id);
    }
  });

  // добавление новых
  newMarkers.forEach(m => {
    if (!markerInstances.has(m.id)) {
      const marker = new google.maps.Marker({
        position: m.position,
        map: map.value,
        title: m.title
      });

      markerInstances.set(m.id, marker);
    }
  });
}, { deep: true });

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

События Google Maps являются императивными и должны преобразовываться в Vue-события.

map.value.addListener('click', (e) => {
  emit('map-click', {
    lat: e.latLng.lat(),
    lng: e.latLng.lng()
  });
});

Типовая схема:

  • события карты → emit Vue
  • события Vue → watch → вызовы API

Использование provide/inject для вложенных компонентов

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

// MapProvider
provide('googleMap', map);

Дочерние компоненты:

const map = inject('googleMap');

Это позволяет создавать модульные сущности:

  • MarkerComponent
  • PolylineComponent
  • CircleComponent

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


Компонент Marker как отдельная сущность

export default {
  props: {
    position: Object,
    title: String
  },

  setup(props) {
    const map = inject('googleMap');
    let marker = null;

    onMounted(() => {
      marker = new google.maps.Marker({
        position: props.position,
        map: map.value,
        title: props.title
      });
    });

    watch(() => props.position, (pos) => {
      marker?.setPosition(pos);
    });

    onBeforeUnmount(() => {
      marker?.setMap(null);
    });
  }
};

Оптимизация производительности

При работе с картами критично учитывать:

1. Минимизация реактивных обновлений

Избыточные watch вызывают каскад перерисовок.

2. Дебаунс изменений

Особенно для drag-событий:

function debounce(fn, delay) {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), delay);
  };
}

3. Групповая обработка маркеров

Вместо поэлементного обновления предпочтительно diff-алгоритмы по ID.


Работа с bounds и автофит

Автоматическая подгонка карты под набор точек:

const bounds = new google.maps.LatLngBounds();

markers.forEach(m => {
  bounds.extend(m.position);
});

map.value.fitBounds(bounds);

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


Кастомные оверлеи

Google Maps позволяет создавать пользовательские DOM-оверлеи через OverlayView.

class CustomOverlay extends google.maps.OverlayView {
  constructor(position, el) {
    super();
    this.position = position;
    this.el = el;
  }

  onAdd() {
    this.getPanes().overlayLayer.appendChild(this.el);
  }

  draw() {
    const projection = this.getProjection();
    const point = projection.fromLatLngToDivPixel(this.position);

    this.el.style.left = point.x + 'px';
    this.el.style.top = point.y + 'px';
  }

  onRemove() {
    this.el.remove();
  }
}

Во Vue такие оверлеи обычно создаются внутри onMounted и уничтожаются в onBeforeUnmount.


SSR и клиентская инициализация

При использовании Nuxt или SSR-режимов важно предотвращать выполнение API на сервере:

if (typeof window === 'undefined') return;

Инициализация карты должна происходить строго в onMounted.


Управление памятью и утечки

Типичные источники утечек:

  • не удалённые markers
  • активные listeners (addListener)
  • оверлеи без setMap(null)

Рекомендуемая схема очистки:

onBeforeUnmount(() => {
  markerInstances.forEach(m => m.setMap(null));
  markerInstances.clear();

  google.maps.event.clearInstanceListeners(map.value);
});

Масштабируемая компонентная модель

Для крупных приложений формируется компонентная иерархия:

  • MapContainer

    • MarkerLayer

      • Marker
    • PolylineLayer

    • OverlayLayer

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


Интеграция с формами и v-model

Координаты карты часто связываются с формами:

watch(() => mapState.center, (val) => {
  emit('update:center', val);
});

И обратная связь:

watch(() => props.center, (val) => {
  map.value.panTo(val);
});

Это формирует двустороннюю синхронизацию состояния.


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

  • пересоздание карты при каждом изменении props
  • хранение map в reactive() вместо ref
  • отсутствие очистки listeners
  • прямое изменение DOM Google Maps вместо API
  • попытка сделать карту полностью декларативной без императивного слоя

Паттерн composable для карты

Вынос логики в переиспользуемый composable:

export function useGoogleMap(containerRef, options) {
  const map = ref(null);

  onMounted(async () => {
    const google = await loadGoogleMaps(options.apiKey);

    map.value = new google.maps.Map(containerRef.value, options);
  });

  return { map };
}

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