Vue 3 + 高德地图封装实战:点位、区域、聚合、Loca 流光与 Vue 信息窗体
技术栈:Vue 3 + TypeScript + AMap JS API 2.0 + Loca 2.0
本文对高德地图能力做了统一封装,后续将按封装后的调用方式,整理后台地图页面常用功能及示例,并补充封装的底层实现思路。
一、封装能力概览
项目中的 TMap 封装提供以下方法:
TMap
├── initMap()
├── setMapCenter()
├── setMapStyle()
├── addMarker()
├── addMarkers()
├── setMarkerIcon()
├── markerDraggable()
├── addPolygons()
├── drawPolygonTool()
├── updatePolygon()
├── addPolylines()
├── setPolylineDisplay()
├── addPulseLayer()
├── removePulseLayer()
├── addCluster()
├── setClusterData()
├── createdInfoWindow()
├── openInfoWindow()
└── closeInfoWindow()
页面使用时:
import TMap from '@/utils/tmap'
const tmap = new TMap()
onMounted(() => {
tmap.initMap('map-container', () => {
initMapData()
})
})
二、地图初始化
const tmap = new TMap()
tmap.initMap('map-container', () => {
tmap.setMapStyle('amap://styles/your-style-id')
tmap.setMapCenter([116.397428, 39.90923], 14)
})
底层使用 @amap/amap-jsapi-loader 加载 AMap 2.0:
AMapLoader.load({
key,
version: '2.0',
plugins: [
'AMap.MouseTool',
'AMap.Geocoder',
'AMap.PolygonEditor',
'AMap.DistrictSearch',
'AMap.PolylineEditor',
'AMap.MarkerCluster'
],
Loca: {
version: '2.0.0'
}
})
三、点位渲染
1. 使用封装添加点位
const pointList = [
{
id: 1,
name: '一号点位',
shape: [116.397428, 39.90923],
icon: 'point-icon',
color: '#2484f4'
},
{
id: 2,
name: '二号点位',
shape: [116.407428, 39.91923],
icon: 'point-icon',
color: '#2484f4'
}
]
tmap.addMarkers(
pointList,
(item) => {
console.log('点击点位', item)
},
true
)
addMarkers() 内部会把配置转换成 AMap.Marker:
const marker = new AMap.Marker({
position: item.shape,
offset: new AMap.Pixel(-14, -32),
content: this.dealMarkerIcon(item.icon, item.color, []),
extData: item
})
marker.on('click', () => {
onClick?.(item)
})
map.add(marker)
2. 修改点位图标和位置
tmap.setMarkerPosition([116.4, 39.91])
tmap.setMarkerIcon('point-active', '#00ffff', [36, 44])
3. 点位拖拽
tmap.markerSetDraggable(true)
tmap.markerDraggable((event) => {
const lnglat = [event.lnglat.lng, event.lnglat.lat]
tmap.regeoCode(lnglat, (addressInfo) => {
console.log('拖拽后地址', addressInfo.address)
})
})
四、区域绘制与渲染
1. 渲染区域
const areaList = [
{
id: 1,
name: '示例区域',
shape: [
[116.39, 39.9],
[116.41, 39.9],
[116.41, 39.92],
[116.39, 39.92]
],
color: '#2484f4'
}
]
tmap.addPolygons(
areaList,
(area) => {
console.log('点击区域', area)
},
{
show: true
}
)
底层会创建:
const polygon = new AMap.Polygon({
path: item.shape,
fillColor: item.color,
fillOpacity: 0.3,
strokeColor: item.color,
strokeWeight: 4,
extData: item,
zIndex: 50
})
2. 绘制区域
tmap.drawPolygonTool('#2484f4', (path) => {
console.log('绘制完成', path)
})
3. 编辑区域
tmap.updatePolygon((path) => {
const normalizedPath = path.map((point) => [
Number(point.lng),
Number(point.lat)
])
console.log('编辑后的路径', normalizedPath)
})
五、行政区遮罩
tmap.maskCoverMapOtherArea({
searchVal: '示例行政区',
center: [116.397428, 39.90923],
zoom: 14,
fillColor: '#040b31',
fillOpacity: 0.8,
strokeColor: '#2484f4'
})
底层使用 AMap.DistrictSearch 获取行政区边界,再用一个带孔洞的多边形覆盖外部区域。
const district = new AMap.DistrictSearch({
subdistrict: 1,
extensions: 'all',
level: 'district'
})
district.search(keyword, (status, result) => {
if (status !== 'complete') return
const holes = result.districtList[0].boundaries
const outer = [
new AMap.LngLat(-360, 90, true),
new AMap.LngLat(-360, -90, true),
new AMap.LngLat(360, -90, true),
new AMap.LngLat(360, 90, true)
]
map.add(new AMap.Polygon({
path: [outer, ...holes],
fillColor: '#040b31',
fillOpacity: 0.8,
strokeColor: '#2484f4',
strokeWeight: 4
}))
})
六、线路和 Loca 流光
1. 业务线路数据结构
interface FlowLine {
id: string | number
name: string
shape: Array<[number, number]>
color?: string
width?: number
}
2. 添加基础 Polyline
const lineList: FlowLine[] = [
{
id: 1,
name: '示例流向',
shape: [
[116.397428, 39.90923],
[116.405428, 39.91523],
[116.412428, 39.91923]
],
color: '#00d8ff',
width: 4
}
]
tmap.addPolylines(lineList, (line) => {
console.log('点击线路', line)
})
3. 将线路转换成 GeoJSON
Loca PulseLineLayer 需要标准 GeoJSON:
function formatGeoData(list: FlowLine[]) {
return list.reduce((features: any[], line) => {
if (line.shape?.length >= 2) {
features.push({
type: 'Feature',
properties: {
id: line.id,
name: line.name,
color: line.color || '#00d8ff',
width: line.width || 4
},
geometry: {
type: 'LineString',
coordinates: line.shape
}
})
}
return features
}, [])
}
const geo = {
type: 'FeatureCollection',
features: formatGeoData(lineList)
}
4. 使用封装添加流光
tmap.addPulseLayer(geo)
项目中的 addPulseLayer() 会创建 Loca 脉冲层:
addPulseLayer(geo: GeoJSON.FeatureCollection) {
if (!this.pulseLayer) {
this.pulseLayer = new this.Loca.PulseLineLayer({
zIndex: 1000,
opacity: 1,
visible: true,
zooms: [2, 22]
})
}
this.pulseLayer.setStyle({
altitude: 0,
lineWidth: (_, feature) => Number(feature.properties.width),
headColor: (_, feature) => feature.properties.color,
trailColor: 'rgba(128,128,128,.5)',
interval: 0.4,
duration: 4000
})
this.pulseLayer.setSource(
new this.Loca.GeoJSONSource({ data: geo })
)
this.loca.add(this.pulseLayer)
this.loca.animate.start()
}
5. 新增一条流光线路
当业务数据增加一条线路时,将新线路合并到线路列表后重新生成 GeoJSON:
const currentLines: FlowLine[] = []
function addFlowLine(line: FlowLine) {
currentLines.push(line)
// 当前封装是整层替换,先移除旧脉冲层
tmap.removePulseLayer()
tmap.addPulseLayer({
type: 'FeatureCollection',
features: formatGeoData(currentLines)
})
}
示例:
addFlowLine({
id: 2,
name: '新增流向',
shape: [
[116.397428, 39.90923],
[116.417428, 39.92923]
],
color: '#00e5ff',
width: 5
})
6. 显示、隐藏和删除
// 同时控制普通 Polyline 和 Loca 脉冲层
tmap.setPolylineDisplay('show')
tmap.setPolylineDisplay('hide')
// 删除全部脉冲层并停止动画
tmap.removePulseLayer()
七、点位聚合
const markers = pointList.map((point) => {
return new AMap.Marker({
position: point.shape,
extData: point
})
})
tmap.addCluster(markers)
更新聚合数据:
tmap.setClusterData(newMarkers, 'reset')
追加数据:
tmap.setClusterData(newMarkers, 'add')
八、InfoWindow 挂载自定义 Vue 组件
1. 在父组件模板中渲染 Vue 组件
<div ref="infoWindowRef" class="map-info-window">
<GeneralDetail
:type="state.displayType"
:info="state.displayData"
@on-close="handleCloseInfoWindow"
@on-check="state.showFullDetail = true"
/>
</div>
这里的关键是:
GeneralDetail由 Vue 正常创建和挂载;infoWindowRef.value是一个真实的 DOM 容器;TMap只负责把这个 DOM 容器交给高德 InfoWindow。
2. 地图服务创建 InfoWindow
createdInfoWindow(content: HTMLElement, closeWhenClickMap = true) {
this.infoWindow = new this.AMap.InfoWindow({
isCustom: true,
content,
closeWhenClickMap,
offset: new this.AMap.Pixel(0, -50)
})
}
openInfoWindow(position: [number, number]) {
this.infoWindow?.open(this.map, position)
}
3. 点击点位更新并打开
async function openPointInfoWindow(point) {
state.displayType = 'point'
state.displayData = point
// 等待 Vue 把最新数据渲染到容器中
await nextTick()
if (!tmap.infoWindow) {
tmap.createdInfoWindow(infoWindowRef.value, false)
}
tmap.openInfoWindow(point.shape)
}
由于 GeneralDetail 始终由 Vue 管理,更新 state.displayData 后,组件内部会正常响应式更新,不需要重新创建 Vue 实例。
4. 关闭和销毁
function handleCloseInfoWindow() {
tmap.closeInfoWindow()
}
onBeforeUnmount(() => {
tmap.closeInfoWindow()
tmap.destroyInfoWindow()
})
5. 动态创建 Vue 组件作为 InfoWindow
如果 InfoWindow 内容不是模板中预先挂载的,而是运行时动态创建,可以这样处理:
import { createApp, h, reactive, nextTick } from 'vue'
import InfoPopup from './InfoPopup.vue'
let popupApp: ReturnType<typeof createApp> | null = null
let popupHost: HTMLElement | null = null
let popupWindow: AMap.InfoWindow | null = null
async function openDynamicInfoWindow(data: any, position: [number, number]) {
closeDynamicInfoWindow()
popupHost = document.createElement('div')
const props = reactive({ data })
popupApp = createApp({
render() {
return h(InfoPopup, {
data: props.data,
onClose: closeDynamicInfoWindow
})
}
})
popupApp.mount(popupHost)
await nextTick()
popupWindow = new AMap.InfoWindow({
isCustom: true,
content: popupHost,
offset: new AMap.Pixel(0, -20)
})
popupWindow.open(map, position)
}
function closeDynamicInfoWindow() {
popupWindow?.close()
popupWindow = null
popupApp?.unmount()
popupApp = null
popupHost = null
}
动态挂载时需要注意:
createApp创建的是新的 Vue 应用实例;- 如果弹窗组件使用 Pinia、Router、i18n,需要给新应用安装对应插件,或把共享状态作为 props 传入;
- 关闭 InfoWindow 后必须调用
unmount(),否则可能产生重复实例和内存泄漏; - 更推荐父组件预渲染 DOM 的方式,因为可以复用当前 Vue 应用上下文。
九、完整调用示例
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import TMap from '@/utils/tmap'
import GeneralDetail from './GeneralDetail.vue'
const mapRef = ref()
const infoWindowRef = ref<HTMLElement>()
const tmap = new TMap()
const state = reactive({
displayType: 'point',
displayData: {}
})
onMounted(() => {
tmap.initMap('map-container', async () => {
tmap.setMapStyle('amap://styles/your-style-id')
const points = await getPointList()
const areas = await getAreaList()
const lines = await getLineList()
tmap.addMarkers(points, handlePointClick, true)
tmap.addPolygons(areas, handleAreaClick)
tmap.addPolylines(lines)
tmap.addPulseLayer({
type: 'FeatureCollection',
features: formatGeoData(lines)
})
})
})
async function handlePointClick(point: any) {
state.displayType = 'point'
state.displayData = point
await nextTick()
if (!tmap.infoWindow) {
tmap.createdInfoWindow(infoWindowRef.value!, false)
}
tmap.openInfoWindow(point.shape)
}
async function handleAreaClick(area: any) {
state.displayType = 'area'
state.displayData = area
await nextTick()
if (!tmap.infoWindow) {
tmap.createdInfoWindow(infoWindowRef.value!, false)
}
tmap.openInfoWindow(getPolygonCenter(area.shape))
}
function getPolygonCenter(shape: Array<[number, number]>) {
const [lng, lat] = shape.reduce(
(total, point) => [total[0] + point[0], total[1] + point[1]],
[0, 0]
)
return [lng / shape.length, lat / shape.length] as [number, number]
}
onBeforeUnmount(() => {
tmap.removePulseLayer()
tmap.closeInfoWindow()
tmap.destroyInfoWindow()
})
十、功能与封装方法对照
| 功能 | 封装方法 |
|---|---|
| 地图初始化 | initMap() |
| 地图中心与缩放 | setMapCenter() |
| 地图样式 | setMapStyle() |
| 单点 Marker | addMarker() |
| 批量 Marker | addMarkers() |
| 点位拖拽 | markerDraggable() |
| 逆地理编码 | regeoCode() |
| 区域渲染 | addPolygons() |
| 区域绘制 | drawPolygonTool() |
| 区域编辑 | updatePolygon() |
| 线路渲染 | addPolylines() |
| 线路和流光显隐 | setPolylineDisplay() |
| 添加流光 | addPulseLayer() |
| 删除流光 | removePulseLayer() |
| 点位聚合 | addCluster() |
| 更新聚合 | setClusterData() |
| 创建信息窗体 | createdInfoWindow() |
| 打开信息窗体 | openInfoWindow() |
| 关闭信息窗体 | closeInfoWindow() |
总结
高德地图封装层主要负责四类能力:
- 地图实例和底图;
- Marker、Polygon、Polyline;
- Loca 流光和聚合;
- InfoWindow 与 Vue 组件协作。
其中 InfoWindow 使用自定义 Vue 组件时,最简单稳定的方式是先在父组件模板中渲染容器,再把容器 DOM 传给 AMap.InfoWindow。这样 Vue 的响应式、Pinia、Router 和组件样式都能正常复用。