# Vue + OpenLayers 完整项目开发指南
文章目录
- 项目概述
- 技术栈
- 功能模块
- 项目初始化
- 1. 创建Vue项目
- 2. 安装依赖
- 3. 项目结构
- 核心代码实现
- 1. 状态管理 (Pinia)
- 2. 地图容器组件
- 3. 图层控制组件
- 4. 标记点功能实现
- 5. 测量工具组件
- 6. 路径规划组件
- 7. 地图工具栏组件
- 8. 主页面集成
- 项目优化与扩展
- 1. 主题切换功能
- 2. 地图事件总线
- 3. 性能优化
- 项目部署
- 1. 生产环境构建
- 2. Docker部署
- 3. CI/CD配置 (GitHub Actions)
- 项目总结

项目概述
技术栈
- Vue 3 (Composition API)
- OpenLayers 7.x
- Vite 构建工具
- Pinia 状态管理
- Element Plus UI组件库
功能模块
- 基础地图展示
- 图层切换与控制
- 地图标记与信息弹窗
- 距离与面积测量
- 路径规划与导航
- 地图截图与导出
- 主题样式切换
- 响应式布局
项目初始化
1. 创建Vue项目
npm create vite@latest vue-ol-app --template vue
cd vue-ol-app
npm install
2. 安装依赖
npm install ol @vueuse/core pinia element-plus axios
3. 项目结构
src/
├── assets/
├── components/
│ ├── MapContainer.vue # 地图容器组件
│ ├── LayerControl.vue # 图层控制组件
│ ├── MeasureTool.vue # 测量工具组件
│ ├── RoutePlanner.vue # 路径规划组件
│ └── MapToolbar.vue # 地图工具栏
├── composables/
│ ├── useMap.js # 地图相关逻辑
│ └── useMapTools.js # 地图工具逻辑
├── stores/
│ └── mapStore.js # Pinia地图状态管理
├── styles/
│ ├── ol.css # OpenLayers样式覆盖
│ └── variables.scss # 样式变量
├── utils/
│ ├── projection.js # 坐标转换工具
│ └── style.js # 样式生成工具
├── views/
│ └── HomeView.vue # 主页面
├── App.vue
└── main.js
核心代码实现
1. 状态管理 (Pinia)
// stores/mapStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';export const useMapStore = defineStore('map', () => {// 地图实例const map = ref(null);// 当前视图状态const viewState = ref({center: [116.404, 39.915],zoom: 10,rotation: 0});// 图层状态const layers = ref({baseLayers: [{ id: 'osm', name: 'OpenStreetMap', visible: true, type: 'tile' },{ id: 'satellite', name: '卫星地图', visible: false, type: 'tile' }],overlayLayers: []});// 当前激活的工具const activeTool = ref(null);// 标记点集合const markers = ref([]);// 获取当前可见的底图const visibleBaseLayer = computed(() => {return layers.value.baseLayers.find(layer => layer.visible);});// 切换底图function toggleBaseLayer(layerId) {layers.value.baseLayers.forEach(layer => {layer.visible = layer.id === layerId;});}return {map,viewState,layers,activeTool,markers,visibleBaseLayer,toggleBaseLayer};
});
2. 地图容器组件
<!-- components/MapContainer.vue -->
<template><div ref="mapContainer" class="map-container"><slot></slot></div>
</template><script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useMapStore } from '../stores/mapStore';
import Map from 'ol/Map';
import View from 'ol/View';
import { fromLonLat } from 'ol/proj';const props = defineProps({viewOptions: {type: Object,default: () => ({center: [116.404, 39.915],zoom: 10})}
});const mapContainer = ref(null);
const mapStore = useMapStore();// 初始化地图
function initMap() {const map = new Map({target: mapContainer.value,view: new View({center: fromLonLat(props.viewOptions.center),zoom: props.viewOptions.zoom,minZoom: 2,maxZoom: 18})});mapStore.map = map;// 保存视图状态变化map.on('moveend', () => {const view = map.getView();mapStore.viewState = {center: view.getCenter(),zoom: view.getZoom(),rotation: view.getRotation()};});return map;
}// 响应式调整地图大小
function updateMapSize() {if (mapStore.map) {mapStore.map.updateSize();}
}onMounted(() => {initMap();window.addEventListener('resize', updateMapSize);
});onUnmounted(() => {window.removeEventListener('resize', updateMapSize);if (mapStore.map) {mapStore.map.setTarget(undefined);mapStore.map = null;}
});
</script><style scoped>
.map-container {width: 100%;height: 100%;position: relative;
}
</style>
3. 图层控制组件
<!-- components/LayerControl.vue -->
<template><div class="layer-control"><el-card shadow="hover"><template #header><div class="card-header"><span>图层控制</span></div></template><div class="base-layers"><div v-for="layer in mapStore.layers.baseLayers" :key="layer.id" class="layer-item"@click="mapStore.toggleBaseLayer(layer.id)"><el-radio v-model="mapStore.visibleBaseLayer.id" :label="layer.id">{{ layer.name }}</el-radio></div></div><el-divider></el-divider><div class="overlay-layers"><div v-for="layer in mapStore.layers.overlayLayers" :key="layer.id" class="layer-item"><el-checkbox v-model="layer.visible" @change="toggleLayerVisibility(layer)">{{ layer.name }}</el-checkbox></div></div></el-card></div>
</template><script setup>
import { useMapStore } from '../stores/mapStore';
import { onMounted, watch } from 'vue';
import TileLayer from 'ol/layer/Tile';
import OSM from 'ol/source/OSM';
import XYZ from 'ol/source/XYZ';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';const mapStore = useMapStore();// 初始化图层
function initLayers() {// 添加OSM底图const osmLayer = new TileLayer({source: new OSM(),properties: {id: 'osm',name: 'OpenStreetMap',type: 'base'}});// 添加卫星底图const satelliteLayer = new TileLayer({source: new XYZ({url: 'https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/{z}/{x}/{y}?access_token=your_mapbox_token'}),properties: {id: 'satellite',name: '卫星地图',type: 'base'}});// 添加标记图层const markerLayer = new VectorLayer({source: new VectorSource(),properties: {id: 'markers',name: '标记点',type: 'overlay'}});mapStore.map.addLayer(osmLayer);mapStore.map.addLayer(satelliteLayer);mapStore.map.addLayer(markerLayer);// 默认隐藏卫星图层satelliteLayer.setVisible(false);// 更新store中的图层状态mapStore.layers.overlayLayers.push({id: 'markers',name: '标记点',visible: true,olLayer: markerLayer});
}// 切换图层可见性
function toggleLayerVisibility(layer) {layer.olLayer.setVisible(layer.visible);
}// 监听底图变化
watch(() => mapStore.visibleBaseLayer, (newLayer) => {mapStore.map.getLayers().forEach(layer => {const props = layer.getProperties();if (props.type === 'base') {layer.setVisible(props.id === newLayer.id);}});
});onMounted(() => {if (mapStore.map) {initLayers();}
});
</script><style scoped>
.layer-control {position: absolute;top: 20px;right: 20px;z-index: 100;width: 250px;
}.layer-item {padding: 8px 0;cursor: pointer;
}.base-layers, .overlay-layers {margin-bottom: 10px;
}
</style>
4. 标记点功能实现
// composables/useMap.js
import { ref, onMounted } from 'vue';
import { useMapStore } from '../stores/mapStore';
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import { fromLonLat } from 'ol/proj';
import { Style, Icon } from 'ol/style';export function useMapMarkers() {const mapStore = useMapStore();const markerSource = ref(null);// 初始化标记源function initMarkerSource() {const markerLayer = mapStore.map.getLayers().getArray().find(layer => layer.get('id') === 'markers');if (markerLayer) {markerSource.value = markerLayer.getSource();}}// 添加标记function addMarker(coordinate, properties = {}) {if (!markerSource.value) return;const marker = new Feature({geometry: new Point(fromLonLat(coordinate)),...properties});marker.setStyle(createMarkerStyle(properties));markerSource.value.addFeature(marker);return marker;}// 创建标记样式function createMarkerStyle(properties) {return new Style({image: new Icon({src: properties.icon || '/images/marker.png',scale: 0.5,anchor: [0.5, 1]})});}// 清除所有标记function clearMarkers() {if (markerSource.value) {markerSource.value.clear();}}onMounted(() => {if (mapStore.map) {initMarkerSource();}});return {addMarker,clearMarkers};
}
5. 测量工具组件
<!-- components/MeasureTool.vue -->
<template><el-card shadow="hover" class="measure-tool"><template #header><div class="card-header"><span>测量工具</span></div></template><el-radio-group v-model="measureType" @change="changeMeasureType"><el-radio-button label="length">距离测量</el-radio-button><el-radio-button label="area">面积测量</el-radio-button></el-radio-group><div v-if="measureResult" class="measure-result"><div v-if="measureType === 'length'">长度: {{ measureResult }} 米</div><div v-else>面积: {{ measureResult }} 平方米</div></div><el-button type="danger" size="small" @click="clearMeasurement":disabled="!measureResult">清除</el-button></el-card>
</template><script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useMapStore } from '../stores/mapStore';
import Draw from 'ol/interaction/Draw';
import { LineString, Polygon } from 'ol/geom';
import { getLength, getArea } from 'ol/sphere';
import { unByKey } from 'ol/Observable';
import { Style, Fill, Stroke } from 'ol/style';const mapStore = useMapStore();
const measureType = ref('length');
const measureResult = ref(null);
const drawInteraction = ref(null);
const measureListener = ref(null);// 测量样式
const measureStyle = new Style({fill: new Fill({color: 'rgba(255, 255, 255, 0.2)'}),stroke: new Stroke({color: 'rgba(0, 0, 255, 0.5)',lineDash: [10, 10],width: 2})
});// 改变测量类型
function changeMeasureType() {clearMeasurement();setupMeasureInteraction();
}// 设置测量交互
function setupMeasureInteraction() {const source = new VectorSource();const vector = new VectorLayer({source: source,style: measureStyle});mapStore.map.addLayer(vector);let geometryType = measureType.value === 'length' ? 'LineString' : 'Polygon';drawInteraction.value = new Draw({source: source,type: geometryType,style: measureStyle});mapStore.map.addInteraction(drawInteraction.value);let sketch;drawInteraction.value.on('drawstart', function(evt) {sketch = evt.feature;measureResult.value = null;});measureListener.value = drawInteraction.value.on('drawend', function(evt) {const feature = evt.feature;const geometry = feature.getGeometry();if (measureType.value === 'length') {const length = getLength(geometry);measureResult.value = Math.round(length * 100) / 100;} else {const area = getArea(geometry);measureResult.value = Math.round(area * 100) / 100;}// 清除临时图形source.clear();});
}// 清除测量
function clearMeasurement() {if (drawInteraction.value) {mapStore.map.removeInteraction(drawInteraction.value);unByKey(measureListener.value);drawInteraction.value = null;}// 移除测量图层mapStore.map.getLayers().getArray().forEach(layer => {if (layer.get('name') === 'measure-layer') {mapStore.map.removeLayer(layer);}});measureResult.value = null;
}onUnmounted(() => {clearMeasurement();
});
</script><style scoped>
.measure-tool {position: absolute;top: 20px;left: 20px;z-index: 100;width: 250px;
}.measure-result {margin: 10px 0;padding: 5px;background: rgba(255, 255, 255, 0.8);border-radius: 4px;
}
</style>
6. 路径规划组件
<!-- components/RoutePlanner.vue -->
<template><el-card shadow="hover" class="route-planner"><template #header><div class="card-header"><span>路径规划</span></div></template><el-form label-position="top"><el-form-item label="起点"><el-input v-model="startPoint" placeholder="输入起点坐标或地址"></el-input></el-form-item><el-form-item label="终点"><el-input v-model="endPoint" placeholder="输入终点坐标或地址"></el-input></el-form-item><el-form-item><el-button type="primary" @click="calculateRoute">计算路线</el-button><el-button @click="clearRoute">清除</el-button></el-form-item></el-form><div v-if="routeDistance" class="route-info"><div>距离: {{ routeDistance }} 公里</div><div>预计时间: {{ routeDuration }} 分钟</div></div></el-card>
</template><script setup>
import { ref } from 'vue';
import { useMapStore } from '../stores/mapStore';
import { useMapMarkers } from '../composables/useMap';
import LineString from 'ol/geom/LineString';
import Feature from 'ol/Feature';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import { Style, Stroke } from 'ol/style';const mapStore = useMapStore();
const { addMarker } = useMapMarkers();
const startPoint = ref('');
const endPoint = ref('');
const routeDistance = ref(null);
const routeDuration = ref(null);let routeLayer = null;
let startMarker = null;
let endMarker = null;// 计算路线
async function calculateRoute() {// 在实际应用中,这里应该调用路线规划API// 这里使用模拟数据// 清除旧路线clearRoute();// 解析起点和终点坐标const startCoords = parseCoordinates(startPoint.value) || [116.404, 39.915];const endCoords = parseCoordinates(endPoint.value) || [116.404, 39.925];// 添加标记startMarker = addMarker(startCoords, { title: '起点',icon: '/images/start-marker.png'});endMarker = addMarker(endCoords, { title: '终点',icon: '/images/end-marker.png'});// 创建路线图层const source = new VectorSource();routeLayer = new VectorLayer({source: source,style: new Style({stroke: new Stroke({color: '#0066ff',width: 4})})});mapStore.map.addLayer(routeLayer);// 模拟路线数据const routeCoords = [startCoords,[startCoords[0] + 0.005, startCoords[1] + 0.005],[endCoords[0] - 0.005, endCoords[1] - 0.005],endCoords];// 计算距离和时间routeDistance.value = calculateDistance(routeCoords).toFixed(2);routeDuration.value = Math.round(routeDistance.value * 10);// 添加路线到图层const routeFeature = new Feature({geometry: new LineString(routeCoords.map(coord => fromLonLat(coord)))});source.addFeature(routeFeature);// 调整视图以显示整个路线const view = mapStore.map.getView();view.fit(source.getExtent(), {padding: [50, 50, 50, 50],duration: 1000});
}// 解析坐标
function parseCoordinates(input) {if (!input) return null;// 尝试解析类似 "116.404,39.915" 的格式const parts = input.split(',');if (parts.length === 2) {const lon = parseFloat(parts[0]);const lat = parseFloat(parts[1]);if (!isNaN(lon) && !isNaN(lat)) {return [lon, lat];}}return null;
}// 计算路线距离 (简化版)
function calculateDistance(coords) {// 在实际应用中应该使用更精确的算法let distance = 0;for (let i = 1; i < coords.length; i++) {const dx = coords[i][0] - coords[i-1][0];const dy = coords[i][1] - coords[i-1][1];distance += Math.sqrt(dx*dx + dy*dy) * 111; // 粗略转换为公里}return distance;
}// 清除路线
function clearRoute() {if (routeLayer) {mapStore.map.removeLayer(routeLayer);routeLayer = null;}if (startMarker) {startMarker.getSource().removeFeature(startMarker);}if (endMarker) {endMarker.getSource().removeFeature(endMarker);}routeDistance.value = null;routeDuration.value = null;
}
</script><style scoped>
.route-planner {position: absolute;top: 20px;left: 300px;z-index: 100;width: 300px;
}.route-info {margin-top: 10px;padding: 10px;background: rgba(255, 255, 255, 0.8);border-radius: 4px;
}
</style>
7. 地图工具栏组件
<!-- components/MapToolbar.vue -->
<template><div class="map-toolbar"><el-button-group><el-tooltip content="放大" placement="top"><el-button @click="zoomIn"><el-icon><zoom-in /></el-icon></el-button></el-tooltip><el-tooltip content="缩小" placement="top"><el-button @click="zoomOut"><el-icon><zoom-out /></el-icon></el-button></el-tooltip><el-tooltip content="复位" placement="top"><el-button @click="resetView"><el-icon><refresh /></el-icon></el-button></el-tooltip><el-tooltip content="全屏" placement="top"><el-button @click="toggleFullscreen"><el-icon><full-screen /></el-icon></el-button></el-tooltip><el-tooltip content="截图" placement="top"><el-button @click="exportMap"><el-icon><camera /></el-icon></el-button></el-tooltip></el-button-group></div>
</template><script setup>
import { useMapStore } from '../stores/mapStore';
import { useFullscreen } from '@vueuse/core';
import { toPng } from 'html-to-image';const mapStore = useMapStore();
const { toggle: toggleFullscreen } = useFullscreen();// 放大
function zoomIn() {const view = mapStore.map.getView();const zoom = view.getZoom();view.animate({zoom: zoom + 1,duration: 200});
}// 缩小
function zoomOut() {const view = mapStore.map.getView();const zoom = view.getZoom();view.animate({zoom: zoom - 1,duration: 200});
}// 复位
function resetView() {const view = mapStore.map.getView();view.animate({center: fromLonLat([116.404, 39.915]),zoom: 10,duration: 500});
}// 导出地图为图片
async function exportMap() {try {const mapElement = mapStore.map.getViewport();const dataUrl = await toPng(mapElement);const link = document.createElement('a');link.download = 'map-screenshot.png';link.href = dataUrl;link.click();} catch (error) {console.error('导出地图失败:', error);ElMessage.error('导出地图失败');}
}
</script><style scoped>
.map-toolbar {position: absolute;bottom: 20px;right: 20px;z-index: 100;background: rgba(255, 255, 255, 0.8);padding: 5px;border-radius: 4px;
}
</style>
8. 主页面集成
<!-- views/HomeView.vue -->
<template><div class="home-container"><MapContainer :view-options="initialView"><LayerControl /><MeasureTool /><RoutePlanner /><MapToolbar /></MapContainer></div>
</template><script setup>
import MapContainer from '../components/MapContainer.vue';
import LayerControl from '../components/LayerControl.vue';
import MeasureTool from '../components/MeasureTool.vue';
import RoutePlanner from '../components/RoutePlanner.vue';
import MapToolbar from '../components/MapToolbar.vue';const initialView = {center: [116.404, 39.915],zoom: 12
};
</script><style scoped>
.home-container {width: 100vw;height: 100vh;position: relative;
}
</style>
项目优化与扩展
1. 主题切换功能
// stores/themeStore.js
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';export const useThemeStore = defineStore('theme', () => {const currentTheme = ref('light');function toggleTheme() {currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light';}watch(currentTheme, (newTheme) => {document.documentElement.setAttribute('data-theme', newTheme);}, { immediate: true });return { currentTheme, toggleTheme };
});
2. 地图事件总线
// utils/eventBus.js
import mitt from 'mitt';export const eventBus = mitt();// 在组件中使用
import { eventBus } from '../utils/eventBus';// 发送事件
eventBus.emit('marker-clicked', markerData);// 接收事件
eventBus.on('marker-clicked', (data) => {// 处理事件
});
3. 性能优化
- 矢量图层聚类:
import Cluster from 'ol/source/Cluster';const clusterSource = new Cluster({distance: 40,source: new VectorSource({url: 'data/points.geojson',format: new GeoJSON()})
});const clusterLayer = new VectorLayer({source: clusterSource,style: function(feature) {const size = feature.get('features').length;// 根据聚类点数量返回不同样式}
});
- WebGL渲染:
import WebGLPointsLayer from 'ol/layer/WebGLPoints';const webglLayer = new WebGLPointsLayer({source: vectorSource,style: {symbol: {symbolType: 'circle',size: ['interpolate', ['linear'], ['get', 'size'], 8, 8, 12, 12],color: ['interpolate', ['linear'], ['get', 'value'], 0, 'blue', 100, 'red']}}
});
- 懒加载图层:
function setupLazyLayer() {const layer = new VectorLayer({source: new VectorSource(),visible: false});map.addLayer(layer);// 当图层可见时加载数据layer.on('change:visible', function() {if (layer.getVisible() && layer.getSource().getFeatures().length === 0) {loadLayerData();}});async function loadLayerData() {const response = await fetch('data/large-dataset.geojson');const geojson = await response.json();layer.getSource().addFeatures(new GeoJSON().readFeatures(geojson));}
}
项目部署
1. 生产环境构建
npm run build
2. Docker部署
# Dockerfile
FROM nginx:alpineCOPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.confEXPOSE 80CMD ["nginx", "-g", "daemon off;"]
# nginx.conf
server {listen 80;server_name localhost;location / {root /usr/share/nginx/html;index index.html;try_files $uri $uri/ /index.html;}gzip on;gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}
3. CI/CD配置 (GitHub Actions)
# .github/workflows/deploy.yml
name: Deploy to Productionon:push:branches: [main]jobs:build-and-deploy:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v2- name: Setup Node.jsuses: actions/setup-node@v2with:node-version: '16'- name: Install dependenciesrun: npm install- name: Build projectrun: npm run build- name: Deploy to serveruses: appleboy/scp-action@masterwith:host: ${{ secrets.SSH_HOST }}username: ${{ secrets.SSH_USERNAME }}key: ${{ secrets.SSH_KEY }}source: "dist/*"target: "/var/www/vue-ol-app"- name: Restart Nginxuses: appleboy/ssh-action@masterwith:host: ${{ secrets.SSH_HOST }}username: ${{ secrets.SSH_USERNAME }}key: ${{ secrets.SSH_KEY }}script: |sudo systemctl restart nginx
项目总结
通过这个完整的Vue + OpenLayers项目,我们实现了:
- 基础地图功能:地图展示、缩放、平移、旋转
- 图层管理:多种底图切换、叠加图层控制
- 交互功能:标记点添加、信息展示、测量工具
- 高级功能:路径规划、地图截图、主题切换
- 性能优化:图层懒加载、WebGL渲染、矢量聚类
项目特点:
- 采用Vue 3 Composition API组织代码
- 使用Pinia进行状态管理
- 组件化设计,高内聚低耦合
- 响应式布局,适配不同设备
- 良好的性能优化策略
扩展方向:
- 集成真实的地图服务API(如Google Maps、Mapbox)
- 添加3D地图支持(通过ol-cesium)
- 实现更复杂的地理分析功能
- 开发移动端专用版本
- 添加用户系统,支持地图数据保存
这个项目展示了如何将OpenLayers的强大功能与Vue的响应式特性相结合,构建出功能丰富、性能优良的WebGIS应用。开发者可以根据实际需求进一步扩展和完善各个功能模块。