📖 前言
在现代 Web 应用中,地图可视化是一个常见的需求。无论是展示位置信息、轨迹追踪,还是数据统计分析,地图都能提供直观的视觉体验。本文将详细介绍如何在 Vue 3 项目中集成 Leaflet 地图库,实现一个功能完整的地图可视化组件。
🎯 项目背景
本项目是一个检测管理系统,需要在地图上展示检测点的位置信息。主要需求包括:
- 支持大量标记点的展示
- 标记点聚合功能,提升性能
- 多种地图类型切换(路网图、卫星图、地形图)
- 标记点点击查看详情
- 全屏模式支持
- 实时统计可见标记数量
📦 技术栈
- Vue 3.5.11 - 渐进式 JavaScript 框架
- Leaflet 1.9.4 - 开源地图库
- leaflet.markercluster 1.5.3 - 标记聚类插件
- Ant Design Vue 3 - UI 组件库
- Vite 5.0 - 构建工具
🚀 安装配置
1. 安装依赖
npm install leaflet leaflet.markercluster
2. 引入样式文件
在组件中引入 Leaflet 的 CSS 文件:
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
3. 修复图标路径问题
Leaflet 在打包后可能会出现图标路径问题,需要手动配置:
// 修复 leaflet 默认图标路径问题
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});
🏗️ 核心功能实现
1. 地图初始化
let map = null;
const initMap = () => {
if (!mapContainer.value) return;
// 如果地图已存在,先清理
if (map) {
map.off('moveend', updateVisibleStats);
map.off('zoomend', updateVisibleStats);
map.off('click');
map.remove();
map = null;
}
// 创建地图实例
map = L.map(mapContainer.value, {
zoomControl: true,
attributionControl: true,
});
// 将缩放控件移到右上角
if (map.zoomControl) {
map.zoomControl.setPosition('topright');
}
// 添加初始瓦片图层
switchMapType('road');
// 等待地图加载完成后再添加事件监听
map.whenReady(() => {
map.on('moveend', updateVisibleStats);
map.on('zoomend', updateVisibleStats);
map.on('click', (e) => {
// 点击地图空白处取消高亮
if (e.originalEvent && e.originalEvent.target) {
const target = e.originalEvent.target;
if (!target.closest('.leaflet-popup') && !target.closest('.leaflet-marker-icon')) {
clearHighlight();
}
}
});
});
};
2. 地图类型切换
支持路网图、卫星图、地形图三种类型:
const switchMapType = (type) => {
if (!map) return;
mapType.value = type || mapType.value;
// 移除旧图层
if (currentTileLayer) {
map.removeLayer(currentTileLayer);
}
// 根据类型添加新图层
let tileUrl = '';
let attribution = '';
switch (mapType.value) {
case 'satellite':
// 高德地图卫星图
tileUrl = 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}';
attribution = '© 高德地图';
break;
case 'terrain':
// 高德地图地形图
tileUrl = 'https://webst0{s}.is.autonavi.com/appmaptile?style=8&x={x}&y={y}&z={z}';
attribution = '© 高德地图';
break;
case 'road':
default:
// 高德地图路网图
tileUrl = 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}';
attribution = '© 高德地图';
break;
}
currentTileLayer = L.tileLayer(tileUrl, {
subdomains: ['1', '2', '3', '4'],
attribution: attribution,
maxZoom: 18,
}).addTo(map);
};
3. 标记点创建
创建自定义图标:
// 创建默认图标
const createDefaultIcon = () => {
return L.icon({
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [0, -41]
});
};
// 创建高亮图标
const createHighlightIcon = () => {
return L.icon({
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
iconSize: [30, 46],
iconAnchor: [15, 46],
popupAnchor: [0, -46],
className: 'highlighted-marker'
});
};
4. 标记聚合功能
使用 leaflet.markercluster 实现标记聚合:
let markerClusterGroup = null;
const renderMarkers = (validData) => {
clearMarkers();
if (displayMode.value === 'cluster') {
// 聚合模式
markerClusterGroup = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: true,
zoomToBoundsOnClick: true,
iconCreateFunction: function(cluster) {
const count = cluster.getChildCount();
let size = 40;
let fontSize = 14;
let bgColor = '#1890ff';
// 根据数量设置不同颜色和大小
if (count > 100) {
size = 50;
fontSize = 16;
bgColor = '#ff4d4f';
} else if (count > 50) {
size = 45;
fontSize = 15;
bgColor = '#fa8c16';
} else if (count > 20) {
size = 42;
fontSize = 14;
bgColor = '#52c41a';
}
return L.divIcon({
html: `<div style="background-color: ${bgColor}; color: white; border-radius: 50%; width: ${size}px; height: ${size}px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: ${fontSize}px; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.3);">${count}</div>`,
className: 'marker-cluster-custom',
iconSize: L.point(size, size)
});
}
});
// 监听聚类点击事件
markerClusterGroup.on('clusterclick', function(cluster) {
const markers = cluster.getAllChildMarkers();
if (markers && markers.length > 0) {
const firstMarker = markers[0];
const latlng = firstMarker.getLatLng();
const item = validData.find(d =>
d.latitude === latlng.lat && d.longitude === latlng.lng
);
if (item) {
highlightMarker(firstMarker, item);
}
}
});
// 添加标记到聚类组
validData.forEach(item => {
const marker = L.marker([item.latitude, item.longitude], {
icon: createDefaultIcon()
}).bindPopup(createPopupContent(item));
marker.on('click', function() {
highlightMarker(this, item);
});
markerClusterGroup.addLayer(marker);
});
markerClusterGroup.addTo(map);
} else {
// 平铺模式
validData.forEach(item => {
const marker = L.marker([item.latitude, item.longitude], {
icon: createDefaultIcon()
})
.addTo(map)
.bindPopup(createPopupContent(item));
marker.on('click', function() {
highlightMarker(this, item);
});
markers.push(marker);
});
}
// 自动调整视图范围
fitBounds(validData);
};
5. 弹窗内容定制
创建丰富的弹窗内容:
const createPopupContent = (item) => {
// 图片预览部分
const imagesHtml = item.imageList && item.imageList.length > 0
? `
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #e8e8e8;">
<div style="font-weight: bold; margin-bottom: 8px; color: #333;">📷 现场图片:</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
${item.imageList.slice(0, 3).map((img, idx) =>
`<img src="${img}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid #e8e8e8;" onclick="window.previewImage('${img}')" title="点击预览" />`
).join('')}
</div>
</div>
`
: '';
return `
<div style="min-width: 280px; max-width: 400px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #1890ff;">
<h4 style="margin: 0; color: #1890ff; font-size: 16px;">📋 ${item.serviceCode || '-'}</h4>
</div>
<div style="margin-bottom: 8px;">
<div style="margin-bottom: 8px; display: flex; align-items: flex-start;">
<span style="font-weight: bold; min-width: 80px; color: #666;">🛣️ 线路:</span>
<span style="flex: 1;">${item.towerNumber || '-'}</span>
</div>
<div style="margin-bottom: 8px; display: flex; align-items: flex-start;">
<span style="font-weight: bold; min-width: 80px; color: #666;">📅 发布时间:</span>
<span style="flex: 1;">${item.createTime || '-'}</span>
</div>
</div>
${imagesHtml}
<div style="margin-top: 12px; display: flex; gap: 8px; padding-top: 12px; border-top: 1px solid #e8e8e8;">
<button
onclick="window.viewDetail('${item.id}')"
style="flex: 1; padding: 8px 16px; background: #1890ff; color: white; border: none; border-radius: 4px; cursor: pointer;">
查看详情
</button>
<button
onclick="window.centerMap(${item.latitude}, ${item.longitude})"
style="flex: 1; padding: 8px 16px; background: #52c41a; color: white; border: none; border-radius: 4px; cursor: pointer;">
居中定位
</button>
</div>
</div>
`;
};
6. 视图范围自适应
自动调整地图视图以显示所有标记点:
const fitBounds = (points) => {
if (points.length === 0 || !map) return;
// 临时移除事件监听器,避免触发统计更新
map.off('moveend', updateVisibleStats);
map.off('zoomend', updateVisibleStats);
try {
if (points.length === 1) {
const point = points[0];
map.setView([point.latitude, point.longitude], 13);
} else {
const bounds = L.latLngBounds(
points.map(p => [p.latitude, p.longitude])
);
map.fitBounds(bounds, { padding: [50, 50] });
}
} catch (e) {
console.warn('设置地图视图失败:', e);
} finally {
// 重新添加事件监听器
setTimeout(() => {
if (map) {
map.on('moveend', updateVisibleStats);
map.on('zoomend', updateVisibleStats);
}
}, 500);
}
};
7. 可见标记统计
实时统计当前视图范围内的标记数量:
// 防抖函数
const debounce = (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
// 更新可见标记统计(防抖处理)
let updateStatsTimer = null;
const updateVisibleStats = () => {
if (!map || currentDataList.length === 0) {
visibleMarkersCount.value = 0;
return;
}
if (updateStatsTimer) {
clearTimeout(updateStatsTimer);
}
updateStatsTimer = setTimeout(() => {
try {
const bounds = map.getBounds();
if (!bounds) {
visibleMarkersCount.value = 0;
return;
}
// 计算当前视图范围内的标记数
const visible = currentDataList.filter(item => {
if (!item.longitude || !item.latitude) return false;
try {
const lat = parseFloat(item.latitude);
const lng = parseFloat(item.longitude);
if (isNaN(lat) || isNaN(lng)) return false;
return bounds.contains([lat, lng]);
} catch (e) {
return false;
}
});
visibleMarkersCount.value = visible.length;
} catch (e) {
console.warn('更新可见标记统计失败:', e);
visibleMarkersCount.value = 0;
}
}, 200);
};
8. 全屏功能
支持全屏查看地图:
const toggleFullscreen = () => {
if (!mapContainerRef.value) return;
if (!isFullscreen.value) {
if (mapContainerRef.value.requestFullscreen) {
mapContainerRef.value.requestFullscreen();
} else if (mapContainerRef.value.webkitRequestFullscreen) {
mapContainerRef.value.webkitRequestFullscreen();
} else if (mapContainerRef.value.mozRequestFullScreen) {
mapContainerRef.value.mozRequestFullScreen();
} else if (mapContainerRef.value.msRequestFullscreen) {
mapContainerRef.value.msRequestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
};
// 监听全屏状态变化
const handleFullscreenChange = () => {
isFullscreen.value = !!(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
);
// 全屏时重新调整地图大小
if (map) {
setTimeout(() => {
map.invalidateSize();
}, 100);
}
};
🎨 样式定制
高亮标记样式
:deep(.highlighted-marker) {
filter: drop-shadow(0 0 8px rgba(24, 144, 255, 0.8));
z-index: 1000 !important;
}
聚类样式
:deep(.marker-cluster-custom) {
background-color: transparent !important;
border: none !important;
}
:deep(.marker-cluster-custom div) {
transition: all 0.3s ease;
}
:deep(.marker-cluster-custom:hover div) {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
}
🔧 组件使用
<template>
<map-modal ref="mapModalRef" />
<a-button @click="openMap">打开地图</a-button>
</template>
<script setup>
import { ref } from 'vue';
import MapModal from '@/components/mapModal.vue';
const mapModalRef = ref(null);
const openMap = () => {
const dataList = [
{
id: 1,
latitude: 39.9042,
longitude: 116.4074,
serviceCode: 'BJ001',
towerNumber: '北京-001',
createTime: '2024-01-01',
imageList: ['https://example.com/image1.jpg']
},
// ... 更多数据
];
mapModalRef.value?.openMap(dataList);
};
</script>
💡 最佳实践
1. 性能优化
- 使用标记聚合:当标记点数量超过 100 个时,建议使用聚合模式
- 防抖处理:地图移动和缩放事件使用防抖,避免频繁计算
- 延迟加载:大数据量时,可以分批加载标记点
2. 内存管理
-
及时清理:组件卸载时,务必清理地图实例和事件监听器
-
避免内存泄漏 :使用
onBeforeUnmount钩子进行清理onBeforeUnmount(() => {
clearMarkers();
if (map) {
map.off('moveend', updateVisibleStats);
map.off('zoomend', updateVisibleStats);
map.off('click');
map.remove();
map = null;
}
});
3. 错误处理
-
数据验证:添加标记前,验证经纬度数据的有效性
-
异常捕获:使用 try-catch 包裹可能出错的操作
const validData = dataList.filter(item =>
item.longitude && item.latitude &&
!isNaN(parseFloat(item.longitude)) &&
!isNaN(parseFloat(item.latitude))
);
🐛 常见问题
1. 图标不显示
问题:打包后图标路径错误
解决方案:使用 CDN 地址或配置 webpack/vite 的静态资源处理
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});
2. 地图显示空白
问题:容器未正确初始化或样式问题
解决方案:
- 确保容器有明确的高度
- 使用
nextTick确保 DOM 已渲染 - 调用
map.invalidateSize()重新计算尺寸
3. 聚合不生效
问题:未正确引入插件或配置错误
解决方案:
- 确保引入了
leaflet.markercluster及其样式文件 - 检查
markerClusterGroup是否正确添加到地图
📚 扩展功能
1. 添加自定义控件
// 创建自定义控件
const CustomControl = L.Control.extend({
onAdd: function(map) {
const container = L.DomUtil.create('div', 'custom-control');
container.innerHTML = '<button>自定义按钮</button>';
return container;
}
});
// 添加到地图
new CustomControl({ position: 'topleft' }).addTo(map);
2. 绘制路线
// 使用 Polyline 绘制路线
const polyline = L.polyline([
[39.9042, 116.4074],
[39.9142, 116.4174],
[39.9242, 116.4274]
], {
color: 'red',
weight: 3
}).addTo(map);
3. 添加圆形区域
// 添加圆形区域
const circle = L.circle([39.9042, 116.4074], {
color: 'red',
fillColor: '#f03',
fillOpacity: 0.5,
radius: 500
}).addTo(map);
🎯 总结
通过本文的介绍,我们实现了一个功能完整的地图可视化组件,包括:
- ✅ 地图初始化和配置
- ✅ 多种地图类型切换
- ✅ 标记点聚合和平铺模式
- ✅ 丰富的弹窗内容
- ✅ 实时统计功能
- ✅ 全屏支持
- ✅ 性能优化
Leaflet 作为一个轻量级、功能强大的地图库,非常适合在 Vue 3 项目中使用。通过合理的架构设计和性能优化,可以轻松处理大量标记点的展示需求。