OpenLayers и Vue

Связка OpenLayers и Vue строится вокруг идеи изоляции императивного API карты внутри жизненного цикла компонента. OpenLayers работает напрямую с DOM и WebGL/Canvas, тогда как Vue управляет реактивностью и виртуальным DOM. Основная архитектурная задача — отделить управление картой от реактивного состояния, сохранив синхронизацию слоёв, центра, зума и объектов.

Ключевой принцип: карта и её instance живут вне реактивной системы Vue, но управляются через неё через refs и watchers.


Установка и базовая подготовка проекта

OpenLayers устанавливается как обычная npm-библиотека:

npm install ol

Vue-проект обычно создаётся через Vite:

npm create vite@latest my-map-app -- --template vue
cd my-map-app
npm install
npm install ol

Минимальная структура для карты:

src/
  components/
    MapView.vue
  composables/
    useMap.js
  main.js

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

Компонент карты строится вокруг DOM-элемента, в который OpenLayers монтирует своё отображение.

<template>
  <div ref="mapContainer" class="map"></div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import Map from 'ol/Map'
import View from 'ol/View'
import TileLayer from 'ol/layer/Tile'
import OSM from 'ol/source/OSM'

const mapContainer = ref(null)
let map = null

onMounted(() => {
  map = new Map({
    target: mapContainer.value,
    layers: [
      new TileLayer({
        source: new OSM()
      })
    ],
    view: new View({
      center: [0, 0],
      zoom: 2
    })
  })
})

onBeforeUnmount(() => {
  if (map) {
    map.setTarget(null)
    map = null
  }
})
</script>

<style>
.map {
  width: 100%;
  height: 100vh;
}
</style>

Ключевой момент

  • target должен быть привязан только после монтирования компонента
  • обязательное освобождение через setTarget(null) предотвращает утечки памяти

Реактивное управление картой

Vue-реактивность не должна напрямую «оборачивать» объект карты. Вместо этого используется связка watch + imperative API OpenLayers.

Центр и масштаб как реактивные значения

import { ref, watch } from 'vue'

const center = ref([0, 0])
const zoom = ref(3)

watch(center, (newCenter) => {
  if (map) {
    map.getView().setCenter(newCenter)
  }
})

watch(zoom, (newZoom) => {
  if (map) {
    map.getView().setZoom(newZoom)
  }
})

Обратная синхронизация (карта → Vue)

map.getView().on('change:center', () => {
  center.value = map.getView().getCenter()
})

map.getView().on('change:zoom', () => {
  zoom.value = map.getView().getZoom()
})

Архитектурное правило

Vue хранит состояние, OpenLayers его исполняет


Использование composables для карты

Для масштабируемости логика карты выносится в composable.

// composables/useMap.js
import { ref } from 'vue'
import Map from 'ol/Map'
import View from 'ol/View'

export function useMap() {
  const map = ref(null)
  const target = ref(null)

  function initMap() {
    map.value = new Map({
      target: target.value,
      view: new View({
        center: [0, 0],
        zoom: 2
      })
    })
  }

  function destroyMap() {
    if (map.value) {
      map.value.setTarget(null)
      map.value = null
    }
  }

  return {
    map,
    target,
    initMap,
    destroyMap
  }
}

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

<script setup>
import { onMounted, onBeforeUnmount } from 'vue'
import { useMap } from '@/composables/useMap'

const { map, target, initMap, destroyMap } = useMap()

onMounted(initMap)
onBeforeUnmount(destroyMap)
</script>

<template>
  <div ref="target" class="map"></div>
</template>

Работа со слоями в Vue-архитектуре

Слои OpenLayers должны храниться либо в массиве состояния, либо в отдельном store.

Добавление слоя через реактивное состояние

import { ref, watch } from 'vue'
import TileLayer from 'ol/layer/Tile'
import OSM from 'ol/source/OSM'

const layers = ref([])

watch(layers, (newLayers) => {
  if (!map) return

  map.getLayers().clear()
  newLayers.forEach(layer => {
    map.addLayer(layer)
  })
})

Пример динамического добавления

layers.value.push(
  new TileLayer({
    source: new OSM()
  })
)

Важный принцип

OpenLayers не предназначен для полного пересоздания слоёв при каждом изменении. Более оптимально:

  • хранить ссылки на слои
  • менять только свойства (setVisible, setOpacity, setSource)

Векторные данные и реактивные источники

Работа с векторами требует отдельного подхода, так как источники (VectorSource) обновляются часто.

import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import Feature from 'ol/Feature'
import Point from 'ol/geom/Point'

const source = new VectorSource()

const vectorLayer = new VectorLayer({
  source
})

function addPoint(coord) {
  const feature = new Feature({
    geometry: new Point(coord)
  })

  source.addFeature(feature)
}

Реактивное обновление списка объектов

import { watch } from 'vue'

watch(featuresList, (list) => {
  source.clear()

  list.forEach(item => {
    source.addFeature(
      new Feature({
        geometry: new Point(item.coords)
      })
    )
  })
})

Интерактивность: события карты

OpenLayers использует собственную систему событий.

map.on('click', (event) => {
  const coordinate = event.coordinate
  console.log('click:', coordinate)
})

Проброс событий в Vue

import { defineEmits } from 'vue'

const emit = defineEmits(['map-click'])

map.on('click', (event) => {
  emit('map-click', event.coordinate)
})

Работа с маркерами и оверлеями

Оверлеи позволяют размещать Vue-компоненты поверх карты.

import Overlay from 'ol/Overlay'

const overlay = new Overlay({
  element: document.getElementById('popup'),
  positioning: 'bottom-center'
})

map.addOverlay(overlay)

Привязка Vue-контента

<template>
  <div ref="popup" class="popup">
    {{ text }}
  </div>
</template>

Производительность в Vue-интеграции

Основные узкие места:

  • реактивное пересоздание слоёв
  • частые вызовы setCenter
  • пересоздание features вместо обновления

Оптимизационные подходы:

Батчинг обновлений

let pending = false

function scheduleUpdate(fn) {
  if (pending) return

  pending = true
  requestAnimationFrame(() => {
    fn()
    pending = false
  })
}

Использование immutable state только для UI, не для карты


Подход с Pinia

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

import { defineStore } from 'pinia'

export const useMapStore = defineStore('map', {
  state: () => ({
    center: [0, 0],
    zoom: 2
  }),
  actions: {
    setCenter(c) {
      this.center = c
    }
  }
})

OpenLayers слушает store, но не хранится в нём напрямую.


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

  • попытка обернуть Map в reactive()
  • пересоздание карты при каждом изменении props
  • хранение Feature объектов в Vue state без контроля ссылок
  • отсутствие очистки map target при unmount
  • прямое манипулирование DOM слоями Vue вместо OpenLayers API

Структура масштабируемого приложения

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

map/
  core/
    createMap.js
    layers.js
  composables/
    useMap.js
    useLayers.js
  components/
    MapView.vue
    MapControls.vue

Разделение ответственности:

  • core — чистый OpenLayers
  • composables — мост Vue ↔︎ OpenLayers
  • components — UI слой

Связь UI и карты через события

map.on('moveend', () => {
  const view = map.getView()
  console.log(view.getCenter(), view.getZoom())
})

UI-компоненты реагируют только на состояние, а не на внутреннюю логику карты.


Итоговая модель взаимодействия

  • OpenLayers управляет рендерингом и геометрией
  • Vue управляет состоянием интерфейса
  • composables связывают обе системы
  • события используются как основной канал синхронизации
  • реактивность применяется точечно, а не глобально