Vue 3 + 高德地图封装实战:点位、区域、Loca 流光与 Vue 信息窗体

Vue 3 + 高德地图封装实战:点位、区域、聚合、Loca 流光与 Vue 信息窗体

技术栈:Vue 3 + TypeScript + AMap JS API 2.0 + Loca 2.0

本文对高德地图能力做了统一封装,后续将按封装后的调用方式,整理后台地图页面常用功能及示例,并补充封装的底层实现思路。

一、封装能力概览

项目中的 TMap 封装提供以下方法:

text 复制代码
TMap
├── initMap()
├── setMapCenter()
├── setMapStyle()
├── addMarker()
├── addMarkers()
├── setMarkerIcon()
├── markerDraggable()
├── addPolygons()
├── drawPolygonTool()
├── updatePolygon()
├── addPolylines()
├── setPolylineDisplay()
├── addPulseLayer()
├── removePulseLayer()
├── addCluster()
├── setClusterData()
├── createdInfoWindow()
├── openInfoWindow()
└── closeInfoWindow()

页面使用时:

ts 复制代码
import TMap from '@/utils/tmap'

const tmap = new TMap()

onMounted(() => {
  tmap.initMap('map-container', () => {
    initMapData()
  })
})

二、地图初始化

ts 复制代码
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:

ts 复制代码
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. 使用封装添加点位

ts 复制代码
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:

ts 复制代码
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. 修改点位图标和位置

ts 复制代码
tmap.setMarkerPosition([116.4, 39.91])
tmap.setMarkerIcon('point-active', '#00ffff', [36, 44])

3. 点位拖拽

ts 复制代码
tmap.markerSetDraggable(true)

tmap.markerDraggable((event) => {
  const lnglat = [event.lnglat.lng, event.lnglat.lat]

  tmap.regeoCode(lnglat, (addressInfo) => {
    console.log('拖拽后地址', addressInfo.address)
  })
})

四、区域绘制与渲染

1. 渲染区域

ts 复制代码
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
  }
)

底层会创建:

ts 复制代码
const polygon = new AMap.Polygon({
  path: item.shape,
  fillColor: item.color,
  fillOpacity: 0.3,
  strokeColor: item.color,
  strokeWeight: 4,
  extData: item,
  zIndex: 50
})

2. 绘制区域

ts 复制代码
tmap.drawPolygonTool('#2484f4', (path) => {
  console.log('绘制完成', path)
})

3. 编辑区域

ts 复制代码
tmap.updatePolygon((path) => {
  const normalizedPath = path.map((point) => [
    Number(point.lng),
    Number(point.lat)
  ])
  console.log('编辑后的路径', normalizedPath)
})

五、行政区遮罩

ts 复制代码
tmap.maskCoverMapOtherArea({
  searchVal: '示例行政区',
  center: [116.397428, 39.90923],
  zoom: 14,
  fillColor: '#040b31',
  fillOpacity: 0.8,
  strokeColor: '#2484f4'
})

底层使用 AMap.DistrictSearch 获取行政区边界,再用一个带孔洞的多边形覆盖外部区域。

ts 复制代码
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. 业务线路数据结构

ts 复制代码
interface FlowLine {
  id: string | number
  name: string
  shape: Array<[number, number]>
  color?: string
  width?: number
}

2. 添加基础 Polyline

ts 复制代码
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:

ts 复制代码
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. 使用封装添加流光

ts 复制代码
tmap.addPulseLayer(geo)

项目中的 addPulseLayer() 会创建 Loca 脉冲层:

ts 复制代码
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:

ts 复制代码
const currentLines: FlowLine[] = []

function addFlowLine(line: FlowLine) {
  currentLines.push(line)

  // 当前封装是整层替换,先移除旧脉冲层
  tmap.removePulseLayer()

  tmap.addPulseLayer({
    type: 'FeatureCollection',
    features: formatGeoData(currentLines)
  })
}

示例:

ts 复制代码
addFlowLine({
  id: 2,
  name: '新增流向',
  shape: [
    [116.397428, 39.90923],
    [116.417428, 39.92923]
  ],
  color: '#00e5ff',
  width: 5
})

6. 显示、隐藏和删除

ts 复制代码
// 同时控制普通 Polyline 和 Loca 脉冲层
tmap.setPolylineDisplay('show')
tmap.setPolylineDisplay('hide')

// 删除全部脉冲层并停止动画
tmap.removePulseLayer()

七、点位聚合

ts 复制代码
const markers = pointList.map((point) => {
  return new AMap.Marker({
    position: point.shape,
    extData: point
  })
})

tmap.addCluster(markers)

更新聚合数据:

ts 复制代码
tmap.setClusterData(newMarkers, 'reset')

追加数据:

ts 复制代码
tmap.setClusterData(newMarkers, 'add')

八、InfoWindow 挂载自定义 Vue 组件

1. 在父组件模板中渲染 Vue 组件

html 复制代码
<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

ts 复制代码
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. 点击点位更新并打开

ts 复制代码
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. 关闭和销毁

ts 复制代码
function handleCloseInfoWindow() {
  tmap.closeInfoWindow()
}

onBeforeUnmount(() => {
  tmap.closeInfoWindow()
  tmap.destroyInfoWindow()
})

5. 动态创建 Vue 组件作为 InfoWindow

如果 InfoWindow 内容不是模板中预先挂载的,而是运行时动态创建,可以这样处理:

ts 复制代码
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 应用上下文。

九、完整调用示例

ts 复制代码
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()

总结

高德地图封装层主要负责四类能力:

  1. 地图实例和底图;
  2. Marker、Polygon、Polyline;
  3. Loca 流光和聚合;
  4. InfoWindow 与 Vue 组件协作。

其中 InfoWindow 使用自定义 Vue 组件时,最简单稳定的方式是先在父组件模板中渲染容器,再把容器 DOM 传给 AMap.InfoWindow。这样 Vue 的响应式、Pinia、Router 和组件样式都能正常复用。

相关推荐
掘金者阿豪17 分钟前
AI数据库不只是向量检索:从数据语义到库内智能计算的技术演进
前端
梦里寻码20 分钟前
我用 NestJS + Vue3 写了一套完整的研发管理系统(58 表 / 229 API / 130 项断言,全开源
前端·后端·算法
FRing0322 分钟前
vue2干货总结8(路由模块封装、router-link、声明式导航、路由重定向、编程式导航)
开发语言·前端·vue.js·vue2
SameX32 分钟前
HarmonyOS ArkUI Dialog 复用 用 @CustomDialog + enum type 做多类型统一管理的做法
前端
AiMan1 小时前
豆包爱学还是不可以导出左侧所有对话,只能单容导出,方法不对吗
前端
无懈可击1 小时前
Vue 可视化AI表单设计器 FcDesigner v3.6 版本发布,支持导出 SQL功能!
vue.js·开源·ai编程
AiMan1 小时前
Grok生成的内容能否下载吗?
前端
徐小夕2 小时前
JitArchives,一款基于JitWord协同AI文档打造的企业级电子档案系统
前端·vue.js·github
陈随易2 小时前
Node.js 2026上半年都有哪些性能提升?
前端·后端·程序员