Vue 地图组件:高德/百度地图 Vue 封装
【摘要】 一、引言1.1 地图组件在Web应用中的重要性在现代Web应用中,地图功能已成为位置服务、数据可视化、业务展示的核心需求。Vue生态中,高德地图和百度地图作为国内主流地图服务,提供丰富的API和稳定的服务。1.2 技术选型对比分析class MapServiceComparison { constructor() { this.comparison = { ...
一、引言
1.1 地图组件在Web应用中的重要性
1.2 技术选型对比分析
class MapServiceComparison {
constructor() {
this.comparison = {
'高德地图': {
'市场份额': '国内市场份额第一,覆盖广泛',
'API丰富度': '⭐⭐⭐⭐⭐ (接口完善,文档清晰)',
'性能表现': '⭐⭐⭐⭐ (加载速度快,渲染流畅)',
'Vue支持': '官方Vue组件,生态完善',
'费用政策': '免费额度充足,商用友好',
'适用场景': '企业应用、物流系统、位置服务'
},
'百度地图': {
'市场份额': '市场份额第二,用户基数大',
'API丰富度': '⭐⭐⭐⭐ (功能全面,接口稳定)',
'性能表现': '⭐⭐⭐ (功能丰富但稍重)',
'Vue支持': '社区组件丰富,自定义灵活',
'费用政策': '免费+付费,个性化服务',
'适用场景': 'O2O应用、导航系统、大数据可视化'
}
};
}
getSelectionGuide(requirements) {
return {
'选择高德地图当': [
'需要官方Vue组件支持',
'追求最佳性能和加载速度',
'企业级应用需要稳定服务',
'需要丰富的POI数据'
],
'选择百度地图当': [
'项目已深度集成百度生态',
'需要特殊地图样式定制',
'大数据量地图可视化',
'需要与百度其他服务集成'
]
};
}
}
1.3 性能基准对比
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
二、技术背景
2.1 地图技术架构演进
graph TB
A[Web地图技术演进] --> B[静态图片地图]
A --> C[Flash地图插件]
A --> D[原生JavaScript API]
A --> E[框架封装组件]
B --> B1[简单但交互性差]
B --> B2[已淘汰]
C --> C1[丰富交互但兼容性差]
C --> C2[逐渐被HTML5替代]
D --> D1[百度/高德原生API]
D --> D2[直接DOM操作复杂]
E --> E1[Vue/React组件化]
E --> E2[高德Vue组件]
E --> E3[百度Vue组件]
E1 --> F[现代化地图开发生态]
E2 --> F
E3 --> F
F --> G[声明式开发]
F --> H[状态管理集成]
F --> I[响应式更新]
2.2 核心渲染技术对比
class MapRenderingTechnology {
constructor() {
this.technologies = {
'Canvas渲染': {
'原理': 'HTML5 Canvas 2D绘图',
'优势': '高性能大数据量渲染',
'劣势': 'DOM交互复杂,内存管理要求高',
'适用': '高德地图主要采用'
},
'WebGL渲染': {
'原理': '硬件加速3D图形API',
'优势': '极致性能,复杂特效',
'劣势': '学习曲线陡峭,兼容性要求',
'适用': '百度地图3D模式'
},
'SVG渲染': {
'原理': '矢量图形描述',
'优势': '无限缩放,CSS样式控制',
'劣势': '性能随元素数量下降',
'适用': '简单标记和覆盖物'
},
'混合渲染': {
'原理': '多种技术结合使用',
'优势': '平衡性能和功能',
'劣势': '实现复杂度高',
'适用': '现代地图应用主流'
}
};
}
getPerformanceCharacteristics(dataSize) {
return {
'小数据量(<1000点)': {
'Canvas': '性能优秀,60FPS',
'WebGL': '杀鸡用牛刀,初始化慢',
'SVG': '开发简单,性能良好'
},
'中数据量(1000-10000点)': {
'Canvas': '最佳选择,性能稳定',
'WebGL': '优势初显,适合动画',
'SVG': '开始卡顿,不推荐'
},
'大数据量(>10000点)': {
'Canvas': '需要优化,可能卡顿',
'WebGL': '最佳性能,流畅渲染',
'SVG': '完全不可用'
}
};
}
}
三、环境准备与项目配置
3.1 安装与基础配置
// package.json 依赖配置
{
"dependencies": {
"vue": "^3.3.0",
"vue-router": "^4.2.0",
// 高德地图相关
"@amap/amap-jsapi-loader": "^1.0.1",
"vue-amap": "^0.5.10",
// 百度地图相关
"vue-baidu-map": "^0.21.22",
// UI组件库
"element-plus": "^2.3.0",
"ant-design-vue": "^3.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.0.0",
"typescript": "^5.0.0"
}
}
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
optimizeDeps: {
include: ['@amap/amap-jsapi-loader', 'vue-amap']
},
build: {
rollupOptions: {
external: ['AMap', 'BMap'] // 外部化地图SDK
}
}
});
3.2 高德地图Vue配置
// src/plugins/amap.js
import AMapLoader from '@amap/amap-jsapi-loader';
// 高德地图全局配置
export const amapConfig = {
key: process.env.VUE_APP_AMAP_KEY, // 高德地图Key
version: '2.0', // API版本
plugins: [
'AMap.Scale', // 比例尺
'AMap.ToolBar', // 工具条
'AMap.HawkEye', // 鹰眼
'AMap.MapType', // 地图类型
'AMap.Geolocation' // 地理定位
],
// UI样式配置
uiStyle: 'amap://styles/light' // 标准样式
};
// 初始化高德地图
export async function initAMap() {
try {
const AMap = await AMapLoader.load(amapConfig);
// 设置安全配置
window._AMapSecurityConfig = {
securityJsCode: process.env.VUE_APP_AMAP_SECRET
};
return AMap;
} catch (error) {
console.error('高德地图加载失败:', error);
throw error;
}
}
// Vue插件安装
export const AMapPlugin = {
install(app, options = {}) {
app.config.globalProperties.$amap = {
async init() {
return await initAMap();
},
config: { ...amapConfig, ...options }
};
// 提供注入
app.provide('amap', app.config.globalProperties.$amap);
}
};
3.3 百度地图Vue配置
// src/plugins/bmap.js
import BMap from 'vue-baidu-map';
// 百度地图配置
export const bmapConfig = {
ak: process.env.VUE_APP_BMAP_KEY, // 百度地图Key
v: '3.0', // API版本
// 初始化配置
initOptions: {
enableMapClick: false, // 禁用底图默认点击事件
tilt: 0,
zoom: 12,
enableAutoResize: true
}
};
// 百度地图插件配置
export const BMapPlugin = {
install(app, options = {}) {
app.use(BMap, {
ak: bmapConfig.ak,
...options
});
// 全局方法
app.config.globalProperties.$bmap = {
// 坐标转换
coordinateConvert(lng, lat, from = 'gps', to = 'bd09') {
// 坐标转换逻辑
return convertCoordinate(lng, lat, from, to);
},
// 地理编码
async geocode(address, city = '') {
return new Promise((resolve, reject) => {
const geocoder = new BMap.Geocoder();
geocoder.getPoint(address, point => {
if (point) resolve(point);
else reject(new Error('地址解析失败'));
}, city);
});
}
};
app.provide('bmap', app.config.globalProperties.$bmap);
}
};
四、基础地图组件封装
4.1 高德地图基础组件
<template>
<div class="amap-container">
<!-- 地图容器 -->
<div
ref="mapContainer"
class="amap-wrapper"
:style="containerStyle"
></div>
<!-- 地图控件 -->
<div v-if="showControls" class="map-controls">
<slot name="controls">
<ZoomControl
:map="mapInstance"
@zoom-change="handleZoomChange"
/>
<LayerSwitch
:layers="availableLayers"
@layer-change="handleLayerChange"
/>
</slot>
</div>
<!-- 自定义覆盖物插槽 -->
<slot :map="mapInstance" :amap="AMap"></slot>
</div>
</template>
<script>
import { ref, onMounted, onUnmounted, watch, provide } from 'vue';
import { initAMap } from '@/plugins/amap';
export default {
name: 'AmapContainer',
props: {
// 地图中心点
center: {
type: Array,
default: () => [116.397428, 39.90923] // 北京
},
// 缩放级别
zoom: {
type: Number,
default: 12
},
// 地图样式
mapStyle: {
type: String,
default: 'amap://styles/normal'
},
// 是否显示控件
showControls: {
type: Boolean,
default: true
},
// 容器样式
containerStyle: {
type: Object,
default: () => ({
height: '400px',
width: '100%'
})
}
},
emits: ['init', 'zoom-change', 'center-change', 'click'],
setup(props, { emit }) {
const mapContainer = ref(null);
const mapInstance = ref(null);
const AMap = ref(null);
// 可用图层
const availableLayers = ref([
{ name: '标准地图', value: 'normal' },
{ name: '卫星地图', value: 'satellite' },
{ name: '路网地图', value: 'roadnet' }
]);
// 初始化地图
const initMap = async () => {
try {
// 加载高德地图API
AMap.value = await initAMap();
// 创建地图实例
mapInstance.value = new AMap.value.Map(mapContainer.value, {
zoom: props.zoom,
center: props.center,
mapStyle: props.mapStyle,
viewMode: '3D', // 3D视图
resizeEnable: true // 自适应大小
});
// 添加默认控件
addDefaultControls();
// 绑定事件
bindMapEvents();
// 提供地图实例给子组件
provide('amapInstance', mapInstance.value);
provide('AMap', AMap.value);
emit('init', mapInstance.value);
} catch (error) {
console.error('地图初始化失败:', error);
}
};
// 添加默认控件
const addDefaultControls = () => {
if (!mapInstance.value) return;
// 比例尺控件
mapInstance.value.addControl(new AMap.value.Scale({
position: 'LB' // 左下角
}));
// 工具条控件
mapInstance.value.addControl(new AMap.value.ToolBar({
position: 'RT' // 右上角
}));
// 图层切换控件
mapInstance.value.addControl(new AMap.value.MapType({
defaultType: 0 // 默认标准地图
}));
};
// 绑定地图事件
const bindMapEvents = () => {
if (!mapInstance.value) return;
// 缩放事件
mapInstance.value.on('zoomchange', () => {
const zoom = mapInstance.value.getZoom();
emit('zoom-change', zoom);
});
// 中心点变化事件
mapInstance.value.on('moveend', () => {
const center = mapInstance.value.getCenter();
emit('center-change', [center.lng, center.lat]);
});
// 点击事件
mapInstance.value.on('click', (event) => {
emit('click', {
lnglat: [event.lnglat.lng, event.lnglat.lat],
pixel: [event.pixel.x, event.pixel.y]
});
});
};
// 处理缩放变化
const handleZoomChange = (zoom) => {
mapInstance.value.setZoom(zoom);
};
// 处理图层切换
const handleLayerChange = (layerType) => {
mapInstance.value.setMapStyle(`amap://styles/${layerType}`);
};
// 响应式更新
watch(() => props.center, (newCenter) => {
if (mapInstance.value && newCenter) {
mapInstance.value.setCenter(newCenter);
}
});
watch(() => props.zoom, (newZoom) => {
if (mapInstance.value && newZoom) {
mapInstance.value.setZoom(newZoom);
}
});
onMounted(() => {
initMap();
});
onUnmounted(() => {
if (mapInstance.value) {
mapInstance.value.destroy();
}
});
return {
mapContainer,
mapInstance,
availableLayers,
handleZoomChange,
handleLayerChange
};
}
};
</script>
<style scoped>
.amap-container {
position: relative;
width: 100%;
height: 100%;
}
.amap-wrapper {
width: 100%;
height: 100%;
}
.map-controls {
position: absolute;
top: 20px;
right: 20px;
z-index: 1000;
background: white;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
padding: 10px;
}
</style>
4.2 百度地图基础组件
<template>
<baidu-map
class="bmap-container"
:center="center"
:zoom="zoom"
:map-type="mapType"
:style="containerStyle"
@ready="handleMapReady"
@zoomend="handleZoomEnd"
@moveend="handleMoveEnd"
@click="handleMapClick"
>
<!-- 地图控件插槽 -->
<slot name="controls">
<bm-navigation anchor="BMAP_ANCHOR_TOP_LEFT"></bm-navigation>
<bm-scale anchor="BMAP_ANCHOR_TOP_RIGHT"></bm-scale>
<bm-map-type
:map-types="['BMAP_NORMAL_MAP', 'BMAP_SATELLITE_MAP']"
anchor="BMAP_ANCHOR_TOP_RIGHT"
></bm-map-type>
</slot>
<!-- 自定义内容插槽 -->
<slot :map="mapInstance" :bmap="BMap"></slot>
</baidu-map>
</template>
<script>
import { ref, provide, watch } from 'vue';
import { BaiduMap, BmNavigation, BmScale, BmMapType } from 'vue-baidu-map';
export default {
name: 'BaiduMapContainer',
components: {
BaiduMap,
BmNavigation,
BmScale,
BmMapType
},
props: {
center: {
type: Object,
default: () => ({ lng: 116.404, lat: 39.915 })
},
zoom: {
type: Number,
default: 12
},
mapType: {
type: String,
default: 'BMAP_NORMAL_MAP'
},
containerStyle: {
type: Object,
default: () => ({
height: '400px',
width: '100%'
})
}
},
emits: ['ready', 'zoom-change', 'center-change', 'click'],
setup(props, { emit }) {
const mapInstance = ref(null);
const BMap = ref(null);
// 地图就绪回调
const handleMapReady = ({ map, BMap: bmap }) => {
mapInstance.value = map;
BMap.value = bmap;
// 提供地图实例
provide('bmapInstance', mapInstance.value);
provide('BMap', BMap.value);
emit('ready', { map, BMap: bmap });
};
// 事件处理
const handleZoomEnd = (event) => {
emit('zoom-change', event.target.getZoom());
};
const handleMoveEnd = (event) => {
const center = event.target.getCenter();
emit('center-change', { lng: center.lng, lat: center.lat });
};
const handleMapClick = (event) => {
emit('click', {
point: event.point,
pixel: event.pixel
});
};
// 响应式更新
watch(() => props.center, (newCenter) => {
if (mapInstance.value && newCenter) {
mapInstance.value.setCenter(new BMap.value.Point(newCenter.lng, newCenter.lat));
}
});
watch(() => props.zoom, (newZoom) => {
if (mapInstance.value && newZoom) {
mapInstance.value.setZoom(newZoom);
}
});
return {
handleMapReady,
handleZoomEnd,
handleMoveEnd,
handleMapClick
};
}
};
</script>
<style scoped>
.bmap-container {
position: relative;
width: 100%;
height: 100%;
}
</style>
五、高级功能组件封装
5.1 标记点组件(Marker)
<template>
<!-- 高德地图标记点 -->
<div v-if="mapType === 'amap'">
<amap-marker
v-for="marker in markers"
:key="marker.id"
:position="marker.position"
:content="generateMarkerContent(marker)"
:offset="[-15, -30]"
:events="markerEvents"
@click="handleMarkerClick(marker)"
/>
</div>
<!-- 百度地图标记点 -->
<div v-else-if="mapType === 'bmap'">
<bm-marker
v-for="marker in markers"
:key="marker.id"
:position="marker.position"
:icon="generateBmapIcon(marker)"
@click="handleMarkerClick(marker)"
>
<bm-info-window
v-if="marker.info"
:show="activeMarker === marker.id"
@close="activeMarker = null"
>
<div class="info-window">
<h3>{{ marker.title }}</h3>
<p>{{ marker.description }}</p>
</div>
</bm-info-window>
</bm-marker>
</div>
</template>
<script>
import { ref, computed } from 'vue';
export default {
name: 'MapMarkers',
props: {
markers: {
type: Array,
default: () => []
},
mapType: {
type: String,
default: 'amap',
validator: (value) => ['amap', 'bmap'].includes(value)
},
cluster: {
type: Boolean,
default: false
}
},
emits: ['marker-click', 'marker-mouseover'],
setup(props, { emit }) {
const activeMarker = ref(null);
// 标记点事件
const markerEvents = {
click: (event, marker) => {
emit('marker-click', marker);
},
mouseover: (event, marker) => {
emit('marker-mouseover', marker);
}
};
// 生成高德地图标记内容
const generateMarkerContent = (marker) => {
if (marker.customContent) {
return marker.customContent;
}
return `
<div class="custom-marker ${marker.type || 'default'}">
<div class="marker-icon">📍</div>
<div class="marker-label">${marker.title}</div>
</div>
`;
};
// 生成百度地图图标
const generateBmapIcon = (marker) => {
const icon = new BMap.Icon(
marker.iconUrl || '/default-marker.png',
new BMap.Size(30, 30),
{
anchor: new BMap.Size(15, 30)
}
);
return icon;
};
// 处理标记点点击
const handleMarkerClick = (marker) => {
activeMarker.value = marker.id;
emit('marker-click', marker);
};
return {
activeMarker,
markerEvents,
generateMarkerContent,
generateBmapIcon,
handleMarkerClick
};
}
};
</script>
<style scoped>
.custom-marker {
position: relative;
text-align: center;
}
.marker-icon {
font-size: 24px;
}
.marker-label {
background: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.info-window {
max-width: 200px;
}
.info-window h3 {
margin: 0 0 5px 0;
font-size: 14px;
}
.info-window p {
margin: 0;
font-size: 12px;
color: #666;
}
</style>
5.2 信息窗口组件(InfoWindow)
<template>
<!-- 高德地图信息窗口 -->
<amap-info-window
v-if="mapType === 'amap' && visible"
:position="position"
:content="content"
:size="size"
:offset="offset"
:isCustom="isCustom"
:closeWhenClickMap="closeWhenClickMap"
@open="handleOpen"
@close="handleClose"
>
<template v-if="isCustom" #default>
<div class="custom-info-window">
<slot name="content" :data="windowData">
<div class="info-header">
<h3>{{ title }}</h3>
<button class="close-btn" @click="handleClose">×</button>
</div>
<div class="info-content">
<p>{{ content }}</p>
</div>
<div class="info-footer">
<slot name="actions"></slot>
</div>
</slot>
</div>
</template>
</amap-info-window>
<!-- 百度地图信息窗口 -->
<bm-info-window
v-else-if="mapType === 'bmap' && visible"
:position="position"
:title="title"
:width="width"
:height="height"
:show="visible"
@close="handleClose"
>
<div class="bmap-info-window">
<slot name="content" :data="windowData">
<div v-html="content"></div>
</slot>
</div>
</bm-info-window>
</template>
<script>
import { ref, watch } from 'vue';
export default {
name: 'MapInfoWindow',
props: {
visible: {
type: Boolean,
default: false
},
position: {
type: Array,
required: true
},
title: {
type: String,
default: ''
},
content: {
type: String,
default: ''
},
mapType: {
type: String,
default: 'amap'
},
width: {
type: Number,
default: 250
},
height: {
type: Number,
default: 200
},
// 高德地图特有属性
size: {
type: Array,
default: () => [250, 200]
},
offset: {
type: Array,
default: () => [0, 0]
},
isCustom: {
type: Boolean,
default: true
},
closeWhenClickMap: {
type: Boolean,
default: true
}
},
emits: ['open', 'close', 'update:visible'],
setup(props, { emit }) {
const windowData = ref({});
// 监听可见性变化
watch(() => props.visible, (newVal) => {
if (newVal) {
loadWindowData();
}
});
// 加载窗口数据
const loadWindowData = async () => {
// 可以在这里加载异步数据
try {
// 模拟数据加载
windowData.value = await fetchInfoData(props.position);
} catch (error) {
console.error('信息窗口数据加载失败:', error);
}
};
// 事件处理
const handleOpen = () => {
emit('open', props.position);
};
const handleClose = () => {
emit('close');
emit('update:visible', false);
};
return {
windowData,
handleOpen,
handleClose
};
}
};
// 模拟数据获取
async function fetchInfoData(position) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
address: '模拟地址信息',
phone: '138****0000',
businessHours: '09:00-18:00',
rating: 4.5
});
}, 300);
});
}
</script>
<style scoped>
.custom-info-window {
padding: 0;
background: white;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.info-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
}
.close-btn:hover {
color: #666;
}
.info-content {
padding: 16px;
max-height: 200px;
overflow-y: auto;
}
.info-footer {
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
.bmap-info-window {
padding: 10px;
}
</style>
六、实际应用案例
6.1 地点搜索组件
<template>
<div class="location-search">
<!-- 搜索输入框 -->
<div class="search-box">
<input
v-model="searchKeyword"
type="text"
placeholder="搜索地点..."
class="search-input"
@input="handleInput"
@keyup.enter="handleSearch"
/>
<button class="search-btn" @click="handleSearch">
<span>搜索</span>
</button>
</div>
<!-- 搜索结果 -->
<div v-if="showResults" class="search-results">
<div
v-for="item in searchResults"
:key="item.id"
class="result-item"
@click="handleResultClick(item)"
>
<div class="result-name">{{ item.name }}</div>
<div class="result-address">{{ item.address }}</div>
</div>
<div v-if="searchResults.length === 0" class="no-results">
未找到相关地点
</div>
</div>
<!-- 搜索历史 -->
<div v-if="showHistory && searchHistory.length > 0" class="search-history">
<div class="history-title">搜索历史</div>
<div
v-for="item in searchHistory"
:key="item.id"
class="history-item"
@click="handleHistoryClick(item)"
>
{{ item.keyword }}
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
export default {
name: 'LocationSearch',
props: {
mapType: {
type: String,
default: 'amap'
},
city: {
type: String,
default: '全国'
},
showHistory: {
type: Boolean,
default: true
}
},
emits: ['place-select', 'search-complete'],
setup(props, { emit }) {
const searchKeyword = ref('');
const searchResults = ref([]);
const searchHistory = ref([]);
const showResults = ref(false);
// 搜索相关变量
const placeSearch = ref(null);
const autoComplete = ref(null);
// 初始化搜索服务
const initSearchService = async () => {
if (props.mapType === 'amap') {
const AMap = await initAMap();
placeSearch.value = new AMap.PlaceSearch({
city: props.city,
pageSize: 20
});
// 自动完成
autoComplete.value = new AMap.AutoComplete({
city: props.city
});
} else {
// 百度地图搜索初始化
// 实际项目中需要加载百度地图搜索服务
}
};
// 处理输入
const handleInput = () => {
if (searchKeyword.value.length > 1) {
handleAutoComplete();
} else {
showResults.value = false;
}
};
// 自动完成
const handleAutoComplete = () => {
if (props.mapType === 'amap' && autoComplete.value) {
autoComplete.value.search(searchKeyword.value, (status, result) => {
if (status === 'complete' && result.tips) {
searchResults.value = result.tips.map(tip => ({
id: tip.id,
name: tip.name,
address: tip.district,
location: tip.location
}));
showResults.value = true;
}
});
}
};
// 执行搜索
const handleSearch = () => {
if (!searchKeyword.value.trim()) return;
if (props.mapType === 'amap' && placeSearch.value) {
placeSearch.value.search(searchKeyword.value, (status, result) => {
if (status === 'complete' && result.poiList) {
searchResults.value = result.poiList.pois.map(poi => ({
id: poi.id,
name: poi.name,
address: poi.address,
location: [poi.location.lng, poi.location.lat],
phone: poi.tel,
type: poi.type
}));
// 保存到搜索历史
saveToHistory(searchKeyword.value);
showResults.value = true;
emit('search-complete', searchResults.value);
}
});
}
};
// 处理结果点击
const handleResultClick = (item) => {
emit('place-select', item);
showResults.value = false;
searchKeyword.value = item.name;
};
// 处理历史点击
const handleHistoryClick = (item) => {
searchKeyword.value = item.keyword;
handleSearch();
};
// 保存搜索历史
const saveToHistory = (keyword) => {
const history = JSON.parse(localStorage.getItem('searchHistory') || '[]');
// 去重
const index = history.findIndex(item => item.keyword === keyword);
if (index > -1) {
history.splice(index, 1);
}
// 添加到开头
history.unshift({
id: Date.now(),
keyword: keyword,
timestamp: new Date().toISOString()
});
// 限制历史记录数量
if (history.length > 10) {
history.pop();
}
localStorage.setItem('searchHistory', JSON.stringify(history));
searchHistory.value = history;
};
// 加载搜索历史
const loadSearchHistory = () => {
const history = JSON.parse(localStorage.getItem('searchHistory') || '[]');
searchHistory.value = history;
};
onMounted(() => {
initSearchService();
loadSearchHistory();
});
return {
searchKeyword,
searchResults,
searchHistory,
showResults,
handleInput,
handleSearch,
handleResultClick,
handleHistoryClick
};
}
};
</script>
<style scoped>
.location-search {
position: relative;
width: 100%;
max-width: 400px;
}
.search-box {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.search-btn {
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
margin-top: 4px;
}
.result-item {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.result-item:hover {
background: #f5f5f5;
}
.result-item:last-child {
border-bottom: none;
}
.result-name {
font-weight: bold;
margin-bottom: 4px;
}
.result-address {
font-size: 12px;
color: #666;
}
.no-results {
padding: 12px;
text-align: center;
color: #999;
}
.search-history {
margin-top: 8px;
}
.history-title {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.history-item {
display: inline-block;
background: #f5f5f5;
padding: 4px 8px;
margin: 0 4px 4px 0;
border-radius: 2px;
font-size: 12px;
cursor: pointer;
}
</style>
6.2 路线规划组件
<template>
<div class="route-planning">
<!-- 路线规划表单 -->
<div class="route-form">
<div class="input-group">
<label>起点:</label>
<input
v-model="startPoint"
type="text"
placeholder="请输入起点"
class="route-input"
/>
<button @click="useCurrentLocation('start')" class="location-btn">
📍
</button>
</div>
<div class="input-group">
<label>终点:</label>
<input
v-model="endPoint"
type="text"
placeholder="请输入终点"
class="route-input"
/>
<button @click="useCurrentLocation('end')" class="location-btn">
📍
</button>
</div>
<div class="route-options">
<label>出行方式:</label>
<select v-model="routeType" class="route-select">
<option value="driving">驾车</option>
<option value="transit">公交</option>
<option value="walking">步行</option>
<option value="bicycling">骑行</option>
</select>
</div>
<button @click="calculateRoute" class="plan-btn">
开始规划
</button>
</div>
<!-- 路线结果 -->
<div v-if="routeResult" class="route-result">
<div class="route-summary">
<h3>路线概要</h3>
<div class="summary-item">
<span>距离:</span>
<strong>{{ routeResult.distance }}</strong>
</div>
<div class="summary-item">
<span>时间:</span>
<strong>{{ routeResult.duration }}</strong>
</div>
<div class="summary-item">
<span>费用:</span>
<strong>{{ routeResult.cost || '--' }}</strong>
</div>
</div>
<div class="route-steps">
<h4>详细路线</h4>
<div
v-for="(step, index) in routeResult.steps"
:key="index"
class="route-step"
>
<div class="step-number">{{ index + 1 }}</div>
<div class="step-content">
<div class="step-instruction">{{ step.instruction }}</div>
<div class="step-distance">{{ step.distance }}</div>
</div>
</div>
</div>
</div>
<!-- 在地图上显示路线 -->
<div v-if="showOnMap" class="map-route">
<MapRouteDisplay
:route-data="routeResult"
:map-type="mapType"
@marker-click="handleMarkerClick"
/>
</div>
</div>
</template>
<script>
import { ref } from 'vue';
import MapRouteDisplay from './MapRouteDisplay.vue';
export default {
name: 'RoutePlanning',
components: {
MapRouteDisplay
},
props: {
mapType: {
type: String,
default: 'amap'
},
showOnMap: {
type: Boolean,
default: true
}
},
emits: ['route-calculated', 'marker-click'],
setup(props, { emit }) {
const startPoint = ref('');
const endPoint = ref('');
const routeType = ref('driving');
const routeResult = ref(null);
// 使用当前位置
const useCurrentLocation = (type) => {
if (!navigator.geolocation) {
alert('浏览器不支持地理定位');
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
const address = `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`;
if (type === 'start') {
startPoint.value = address;
} else {
endPoint.value = address;
}
},
(error) => {
console.error('获取位置失败:', error);
alert('获取当前位置失败');
}
);
};
// 计算路线
const calculateRoute = async () => {
if (!startPoint.value || !endPoint.value) {
alert('请输入起点和终点');
return;
}
try {
let result;
if (props.mapType === 'amap') {
result = await calculateAMapRoute();
} else {
result = await calculateBMapRoute();
}
routeResult.value = result;
emit('route-calculated', result);
} catch (error) {
console.error('路线规划失败:', error);
alert('路线规划失败,请重试');
}
};
// 高德地图路线规划
const calculateAMapRoute = () => {
return new Promise((resolve, reject) => {
// 实际项目中需要加载高德地图路线规划服务
const driving = new AMap.Driving({
policy: AMap.DrivingPolicy.LEAST_TIME // 最快路线
});
driving.search(
startPoint.value,
endPoint.value,
(status, result) => {
if (status === 'complete') {
resolve(parseAMapRouteResult(result));
} else {
reject(new Error('路线规划失败'));
}
}
);
});
};
// 解析高德地图路线结果
const parseAMapRouteResult = (result) => {
if (!result.routes || result.routes.length === 0) {
throw new Error('未找到可行路线');
}
const route = result.routes[0];
return {
distance: `${(route.distance / 1000).toFixed(1)}公里`,
duration: `${Math.ceil(route.time / 60)}分钟`,
steps: route.steps.map(step => ({
instruction: step.instruction,
distance: `${(step.distance / 1000).toFixed(2)}公里`,
road: step.road,
action: step.action
})),
polyline: route.path // 路线坐标点
};
};
// 处理标记点点击
const handleMarkerClick = (marker) => {
emit('marker-click', marker);
};
return {
startPoint,
endPoint,
routeType,
routeResult,
useCurrentLocation,
calculateRoute,
handleMarkerClick
};
}
};
</script>
<style scoped>
.route-planning {
max-width: 500px;
margin: 0 auto;
}
.route-form {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.input-group {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.input-group label {
width: 60px;
font-weight: bold;
}
.route-input {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.location-btn {
background: none;
border: 1px solid #ddd;
padding: 8px;
margin-left: 8px;
border-radius: 4px;
cursor: pointer;
}
.route-options {
display: flex;
align-items: center;
margin-bottom: 16px;
}
.route-select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
margin-left: 8px;
}
.plan-btn {
width: 100%;
padding: 12px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.route-result {
background: white;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 20px;
}
.route-summary {
margin-bottom: 20px;
}
.route-summary h3 {
margin: 0 0 16px 0;
color: #333;
}
.summary-item {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.route-steps h4 {
margin: 0 0 12px 0;
color: #333;
}
.route-step {
display: flex;
margin-bottom: 12px;
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
}
.step-number {
width: 24px;
height: 24px;
background: #1890ff;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
font-size: 12px;
}
.step-content {
flex: 1;
}
.step-instruction {
margin-bottom: 4px;
font-size: 14px;
}
.step-distance {
font-size: 12px;
color: #666;
}
</style>
七、测试与部署
7.1 组件单元测试
// tests/unit/MapComponent.spec.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
// 模拟高德地图API
vi.mock('@amap/amap-jsapi-loader', () => ({
default: {
load: vi.fn(() => Promise.resolve({
Map: vi.fn(() => ({
setCenter: vi.fn(),
setZoom: vi.fn(),
on: vi.fn(),
destroy: vi.fn()
})),
Marker: vi.fn(),
InfoWindow: vi.fn()
}))
}
}));
describe('地图组件测试', () => {
let wrapper;
describe('AmapContainer 组件', () => {
it('应该正确初始化地图', async () => {
wrapper = mount(AmapContainer, {
props: {
center: [116.397428, 39.90923],
zoom: 12
}
});
await nextTick();
await new Promise(resolve => setTimeout(resolve, 100));
expect(wrapper.find('.amap-wrapper').exists()).toBe(true);
});
it('应该响应属性变化', async () => {
wrapper = mount(AmapContainer, {
props: {
center: [116.397428, 39.90923],
zoom: 12
}
});
await wrapper.setProps({ zoom: 14 });
await nextTick();
// 验证地图缩放被调用
expect(wrapper.vm.mapInstance.setZoom).toHaveBeenCalledWith(14);
});
it('应该处理地图点击事件', async () => {
const handleClick = vi.fn();
wrapper = mount(AmapContainer, {
props: {
onClick: handleClick
}
});
await nextTick();
// 模拟地图点击
wrapper.vm.handleMapClick({ lnglat: [116.397428, 39.90923] });
expect(handleClick).toHaveBeenCalledWith({
lnglat: [116.397428, 39.90923],
pixel: expect.any(Array)
});
});
});
describe('MapMarkers 组件', () => {
const mockMarkers = [
{
id: 1,
position: [116.397428, 39.90923],
title: '测试标记1',
type: 'default'
},
{
id: 2,
position: [116.407428, 39.91923],
title: '测试标记2',
type: 'important'
}
];
it('应该正确渲染标记点', () => {
wrapper = mount(MapMarkers, {
props: {
markers: mockMarkers,
mapType: 'amap'
}
});
// 验证标记点数量
expect(wrapper.vm.markers.length).toBe(2);
});
it('应该处理标记点点击事件', async () => {
const handleMarkerClick = vi.fn();
wrapper = mount(MapMarkers, {
props: {
markers: mockMarkers,
mapType: 'amap'
},
emits: {
'marker-click': handleMarkerClick
}
});
// 模拟标记点点击
await wrapper.vm.handleMarkerClick(mockMarkers[0]);
expect(handleMarkerClick).toHaveBeenCalledWith(mockMarkers[0]);
});
});
});
7.2 性能测试
// tests/performance/MapPerformance.spec.js
describe('地图性能测试', () => {
it('应该高效处理大量标记点', async () => {
const largeDataset = generateLargeMarkerData(10000); // 1万个标记点
const startTime = performance.now();
const wrapper = mount(MapMarkers, {
props: {
markers: largeDataset,
mapType: 'amap',
cluster: true // 启用聚合
}
});
const renderTime = performance.now() - startTime;
// 渲染时间应小于2秒
expect(renderTime).toBeLessThan(2000);
// 内存使用检查
const memoryUsage = performance.memory.usedJSHeapSize;
expect(memoryUsage).toBeLessThan(500 * 1024 * 1024); // 小于500MB
});
it('应该优化地图操作性能', async () => {
const wrapper = mount(AmapContainer);
await wrapper.vm.$nextTick();
// 测试连续操作性能
const operations = 100;
let totalTime = 0;
for (let i = 0; i < operations; i++) {
const start = performance.now();
// 模拟地图操作
await wrapper.vm.handleZoomChange(10 + i % 5);
await wrapper.vm.$nextTick();
totalTime += performance.now() - start;
}
const avgOperationTime = totalTime / operations;
// 平均操作时间应小于50ms
expect(avgOperationTime).toBeLessThan(50);
});
});
// 生成测试数据
function generateLargeMarkerData(count) {
const markers = [];
for (let i = 0; i < count; i++) {
markers.push({
id: i,
position: [
116.3 + Math.random() * 0.2, // 经度
39.8 + Math.random() * 0.2 // 纬度
],
title: `标记点 ${i + 1}`,
type: Math.random() > 0.5 ? 'default' : 'important'
});
}
return markers;
}
八、部署与生产优化
8.1 生产环境配置
// vite.config.prod.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
build: {
// 生产环境优化
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 移除console
drop_debugger: true // 移除debugger
}
},
rollupOptions: {
external: ['AMap', 'BMap'], // 外部化地图SDK
output: {
manualChunks: {
// 代码分割
vue: ['vue', 'vue-router'],
'vue-amap': ['vue-amap'],
'vue-baidu-map': ['vue-baidu-map'],
'ui-library': ['element-plus', 'ant-design-vue']
},
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
}
},
// 打包分析
brotliSize: false,
chunkSizeWarningLimit: 1000
},
// CDN配置
define: {
__MAP_CONFIG__: JSON.stringify({
amapKey: process.env.VUE_APP_AMAP_KEY,
bmapKey: process.env.VUE_APP_BMAP_KEY,
cdnBase: 'https://cdn.example.com/map-sdk/'
})
}
});
8.2 按需加载优化
// src/utils/lazy-load.js
export class MapLazyLoader {
constructor() {
this.loadedMaps = new Set();
this.loadingPromises = new Map();
}
// 懒加载高德地图
async loadAMap() {
if (this.loadedMaps.has('amap')) {
return window.AMap;
}
if (this.loadingPromises.has('amap')) {
return this.loadingPromises.get('amap');
}
const loadPromise = new Promise((resolve, reject) => {
// 动态加载高德地图SDK
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=${process.env.VUE_APP_AMAP_KEY}`;
script.onload = () => {
this.loadedMaps.add('amap');
resolve(window.AMap);
};
script.onerror = reject;
document.head.appendChild(script);
});
this.loadingPromises.set('amap', loadPromise);
return loadPromise;
}
// 预加载地图资源
preloadMapResources() {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => {
this.loadAMap().catch(() => {
// 静默失败,不影响主流程
});
});
}
}
}
// 使用示例
export const createLazyMap = async (mapType, container, options) => {
const loader = new MapLazyLoader();
const MapClass = await loader.loadAMap();
return new MapClass(container, options);
};
九、总结
9.1 技术成果总结
核心功能实现
- •
基础地图容器:支持高德/百度双引擎,响应式设计 - •
标记点系统:支持自定义图标、聚合、动画效果 - •
信息窗口:灵活的内容插槽,丰富的交互功能 - •
搜索服务:地点搜索、自动完成、搜索历史 - •
路线规划:多出行方式,详细导航指引
性能优化成果
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9.2 最佳实践总结
架构设计原则
const bestPractices = {
组件设计: [
'单一职责原则:每个组件专注特定功能',
'Props驱动:数据流向清晰可控',
'事件通信:使用emit进行父子组件通信',
'插槽扩展:提供灵活的UI定制能力'
],
性能优化: [
'按需加载:地图SDK动态导入',
'虚拟滚动:大量标记点使用虚拟化',
'防抖节流:搜索和渲染操作优化',
'内存管理:及时销毁地图实例和监听器'
],
用户体验: [
'加载状态:显示地图加载进度',
'错误处理:友好的错误提示和重试机制',
'交互反馈:操作即时反馈和状态指示',
'无障碍支持:键盘导航和屏幕阅读器兼容'
]
};
9.3 未来发展方向
技术演进趋势
const futureTrends = {
'2024-2025': {
'WebGL 2.0': '更高效的地图渲染技术',
'WebAssembly': '复杂地理计算性能提升',
'3D地图': '实景三维地图普及',
'AR集成': '增强现实与地图结合'
},
'2026-2027': {
'AI地图': '智能路线推荐和预测',
'语义地图': '自然语言交互地图',
'分布式地图': '边缘计算支持',
'元宇宙地图': '虚拟现实地图体验'
}
};
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)