Code前端首页关于Code前端联系我们

Vue3项目里怎么给OpenLayers6+图层加上流畅的实时交互弹窗?

terry 35分钟前 阅读数 11 #Vue

最近接触了好几个做工地人员定位、外卖骑手轨迹可视化的需求,都是用Vue3搭页面,选OpenLayers做地图底的,但一到交互弹窗这块就卡壳——要么弹出错位,要么数据更新时卡得很,要么点地图上其他地方弹窗不会自动关,今天就把自己踩过的坑、试出来的好用方法整理成一步步能直接用的内容,连基础适配Vue3响应式的部分都讲透,新手也能跟着上手。

为什么不用OpenLayers原生的Feature弹窗?

很多刚入门的朋友会直接百度OpenLayers原生弹窗,复制一段用Overlay包裹HTML的代码,确实能弹出内容,但放到Vue3里就会发现两个致命问题: 第一个是响应式失效,比如弹窗里要显示骑手的实时速度、位置描述,你用ref定义好数据,骑手位置变了ref更新,但Overlay里的DOM不会跟着变——因为Overlay是把HTML片段直接挂载到Map的container外面的,脱离了Vue的根实例作用域,Vue的Composition API或者Options API的响应式系统根本追踪不到。 第二个是交互太零散,原生Overlay要手动写打开、关闭、调整位置的事件监听,比如监听click事件打开popup,监听pointermove事件实时调整?不对,pointermove太消耗性能,应该监听pointerup或者dblclick?还要监听popup里的按钮点击事件,查看骑手详情”,要跳转路由或者打开Vue的组件弹窗,原生方法只能通过window.dispatchEvent或者用全局状态库存状态,代码绕得很,维护起来头疼。

所以一开始就别碰原生的HTML片段Overlay,直接把Vue组件嵌进去,利用Vue的响应式和组件系统,这些问题都能迎刃而解。

核心思路:用Vue的Teleport+OpenLayers的Overlay挂载Vue组件

解决问题的关键有两个:第一个是Vue3的Teleport组件,它能把组件的内容“传送”到你指定的DOM节点里,不管那个节点在Vue根实例的内部还是外部,这样就能把Vue组件安全地放到OpenLayers Map外面的Overlay容器里,保持响应式;第二个是OpenLayers 6.2+版本对Overlay的content属性做了优化,之前content只能传HTML字符串或者DOM元素,现在可以直接传HTMLElement,那我们可以把Teleport挂载的那个DOM元素当成Overlay的content,完美结合两者的优势。

从零开始:搭建基础适配框架

先讲通用的适配步骤,不管是智慧社区、轨迹可视化还是别的场景,这个框架都能用。

第一步:准备项目环境

不用多说,你得有一个装了Vue3和OpenLayers 6.2以上版本的项目,如果还没装,可以用npm或者yarn快速安装:

npm install ol @vueuse/core

这里加了@vueuse/core是因为后面要用它的useEventListener钩子,比原生的addEventListener好用,能自动在组件卸载时解绑,防止内存泄漏,新手朋友记得加,不然会出奇怪的bug。

第二步:创建地图组件和弹窗组件

先在components文件夹里建两个组件,一个叫GisMap.vue,负责初始化OpenLayers地图和加载图层;另一个叫MapPopup.vue,负责显示弹窗内容,它是纯Vue组件,完全用Vue的响应式。

先写MapPopup.vue,这个组件很简单,只需要接收数据,渲染出来就行,里面的按钮可以直接用Vue的路由跳转或者emit事件:

<template>
  <div class="popup-box">
    <div class="popup-header">
      <span class="popup-title">{{ popupData.title }}</span>
      <span class="popup-close" @click="handleClose">×</span>
    </div>
    <div class="popup-body">
      <p>当前状态:{{ popupData.status }}</p>
      <p>实时位置:{{ popupData.address }}</p>
      <p>更新时间:{{ formatTime(popupData.updateTime) }}</p>
      <button class="popup-btn" @click="handleDetail">查看详情</button>
    </div>
  </div>
</template>
<script setup>
import { computed } from 'vue'
// 接收父组件传过来的数据
const props = defineProps({
  popupData: {
    type: Object,
    default: () => ({})
  },
  // 关闭弹窗的回调
  onClose: {
    type: Function,
    default: () => {}
  },
  // 查看详情的回调
  onDetail: {
    type: Function,
    default: () => {}
  }
})
// 格式化时间,这个可以自己改成项目需要的格式
const formatTime = (time) => {
  if (!time) return ''
  const date = new Date(time)
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
// 触发关闭和查看详情的回调
const handleClose = () => props.onClose()
const handleDetail = () => props.onDetail(props.popupData.id)
</script>
<style scoped>
.popup-box {
  width: 280px;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
  overflow: hidden;
}
.popup-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: #fff;
  font-size: 16px;
  font-weight: 600;
}
.popup-close {
  cursor: pointer;
  font-size: 24px;
  line-height: 1;
  transition: opacity 0.2s;
}
.popup-close:hover {
  opacity: 0.7;
}
.popup-body {
  padding: 16px;
  font-size: 14px;
  color: #333;
}
.popup-body p {
  margin: 8px 0;
  line-height: 1.6;
}
.popup-btn {
  margin-top: 12px;
  padding: 8px 16px;
  background: #667eea;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background 0.2s;
}
.popup-btn:hover {
  background: #764ba2;
}
</style>

接下来写GisMap.vue,这个是核心组件,步骤比较多,我拆成小部分讲:

① 引入必要的OpenLayers和Vue相关模块

先把需要的都引进来,包括地图容器、图层、数据源、Overlay、Teleport、ref、onMounted、onUnmounted、computed这些,还有@vueuse/core的useEventListener:

<template>
  <div class="gis-map-container" ref="mapContainer"></div>
  <!-- Teleport把MapPopup组件传送到overlayContainer里 -->
  <Teleport to="#overlayContainer" v-if="showPopup">
    <MapPopup
      :popupData="currentPopupData"
      :onClose="closePopup"
      :onDetail="goToDetail"
    />
  </Teleport>
  <!-- 隐藏的容器,用来放Teleport传过来的MapPopup组件,因为Overlay的content需要DOM元素,而Teleport的to属性必须是已经存在的DOM节点 -->
  <div id="overlayContainer" style="display: none;"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useEventListener } from '@vueuse/core'
// OpenLayers相关模块
import Map from 'ol/Map'
import View from 'ol/View'
import TileLayer from 'ol/layer/Tile'
import OSM from 'ol/source/OSM'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import Feature from 'ol/Feature'
import Point from 'ol/geom/Point'
import { fromLonLat } from 'ol/proj'
import Overlay from 'ol/Overlay'
import { Style, Icon, Stroke, Fill } from 'ol/style'
// 引入自定义的弹窗组件
import MapPopup from './MapPopup.vue'
const router = useRouter()
</script>

② 初始化地图和加载测试数据

先在onMounted钩子里面初始化OpenLayers地图,然后加一个OSM的免费底图,再建一个矢量图层放测试的骑手数据,测试数据可以用fromLonLat把经纬度转成Web墨卡托投影(因为OSM底图用的是这个投影,OpenLayers默认也是这个,新手别搞错投影,不然地图上的点会跑到南极去):

<script setup>
// 上面引过的部分省略
// 地图容器的ref
const mapContainer = ref(null)
// 地图实例
let map = null
// 矢量图层实例,后面要用来加实时更新的点
let vectorLayer = null
// 显示弹窗的ref
const showPopup = ref(false)
// 当前弹窗的数据
const currentPopupData = ref({})
// 刚才Teleport用的overlayContainer,要拿到它的DOM元素传给Overlay的content
let teleportNode = null
// OpenLayers的Overlay实例
let overlay = null
// 测试的骑手数据,实际项目中可以通过WebSocket或者定时器实时更新
const mockRiders = ref([
  {
    id: 1,
    name: '张三',
    status: '配送中',
    lon: 116.397428,
    lat: 39.90923,
    address: '北京市东城区王府井大街1号',
    updateTime: Date.now()
  },
  {
    id: 2,
    name: '李四',
    status: '待接单',
    lon: 116.403874,
    lat: 39.915168,
    address: '北京市东城区故宫博物院',
    updateTime: Date.now()
  }
])
// 格式化测试骑手的样式,配送中用蓝色图标,待接单用灰色
const getRiderStyle = (feature) => {
  const status = feature.get('status')
  let color = '#667eea'
  if (status === '待接单') color = '#999'
  // 这里可以换成自己的图标,比如用外卖平台的骑手图标
  return new Style({
    image: new Icon({
      src: `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="${color}" stroke="#fff" stroke-width="2"/><circle cx="16" cy="12" r="4" fill="#fff"/><path d="M8 24c0-4 4-6 8-6s8 2 8 6" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>`,
      anchor: [0.5, 1],
      scale: 1
    })
  })
}
onMounted(() => {
  // 1. 拿到Teleport用的overlayContainer的DOM元素
  teleportNode = document.getElementById('overlayContainer')
  // 2. 初始化OpenLayers的Overlay
  overlay = new Overlay({
    // 把Teleport传过来的DOM元素当成content,虽然现在里面是空的,但Teleport会在showPopup为true时把MapPopup组件放进去
    element: teleportNode,
    // 弹窗的锚点,设为底部中心,这样弹窗会出现在点的上方
    anchor: [0.5, 1],
    // 弹窗的偏移量,比如向上偏移10px,避免和图标重叠
    offset: [0, -10],
    // 弹窗的自动调整位置,设为true,当地图缩放或者拖拽时,弹窗会自动调整位置,避免超出可视区域
    autoPan: true,
    // 自动调整位置的动画
    autoPanAnimation: {
      duration: 250
    }
  })
  // 3. 初始化地图
  map = new Map({
    target: mapContainer.value,
    // 把刚才建的Overlay加到地图上
    overlays: [overlay],
    layers: [
      // OSM免费底图
      new TileLayer({
        source: new OSM()
      })
    ],
    view: new View({
      // 把测试数据的中心位置转成Web墨卡托投影
      center: fromLonLat([116.400651, 39.912199]),
      zoom: 15
    })
  })
  // 4. 初始化矢量图层和数据源
  const vectorSource = new VectorSource()
  vectorLayer = new VectorLayer({
    source: vectorSource,
    style: getRiderStyle
  })
  // 把矢量图层加到地图上
  map.addLayer(vectorLayer)
  // 5. 把测试数据加到矢量图层上
  addRidersToLayer()
  // 6. 监听地图的click事件,点击点时打开弹窗
  map.on('click', handleMapClick)
  // 7. 监听弹窗外面的点击事件,关闭弹窗
  useEventListener(document, 'click', handleDocumentClick)
  // 8. 模拟实时更新骑手位置,实际项目中可以换成WebSocket连接
  setInterval(updateRiderPosition, 3000)
})
</script>

③ 实现添加骑手、更新骑手位置、打开/关闭弹窗的功能

继续在GisMap.vue里写这些功能,重点是更新骑手位置时Vue的响应式怎么和OpenLayers的Feature结合,还有打开弹窗时怎么把Teleport的DOM元素的display属性改成block(因为Teleport的to属性设的是display: none的容器,不然初始化时会在页面上看到一个空的弹窗):

<script setup>
// 上面的部分省略
// 添加测试骑手到矢量图层
const addRidersToLayer = () => {
  const vectorSource = vectorLayer.getSource()
  vectorSource.clear()
  mockRiders.value.forEach(rider => {
    const feature = new Feature({
      geometry: new Point(fromLonLat([rider.lon, rider.lat]))
    })
    // 把骑手的所有数据都存在Feature的properties里,方便后面点击时获取
    feature.setProperties(rider)
    vectorSource.addFeature(feature)
  })
}
// 模拟实时更新骑手位置
const updateRiderPosition = () => {
  mockRiders.value.forEach(rider => {
    // 随机改变一点经纬度,模拟移动
    rider.lon += (Math.random() - 0.5) * 0.001
    rider.lat += (Math.random() - 0.5) * 0.001
    rider.updateTime = Date.now()
    // 更新位置描述,实际项目中可以调用地图的逆地理编码API
    rider.address = rider.status === '配送中' ? `北京市东城区王府井大街${Math.floor(Math.random() * 100)}号` : '北京市东城区故宫博物院附近'
  })
  // 重新添加骑手到矢量图层,或者直接修改Feature的geometry,后者性能更好
  addRidersToLayer()
  // 如果当前弹窗打开了,并且是某个骑手的,更新弹窗数据
  if (showPopup.value && currentPopupData.value.id) {
    const updatedRider = mockRiders.value.find(r => r.id === currentPopupData.value.id)
    if (updatedRider) {
      currentPopupData.value = { ...updatedRider }
      // 同时更新Overlay的位置,不然数据变了但弹窗位置没变
      overlay.setPosition(fromLonLat([updatedRider.lon, updatedRider.lat]))
    }
  }
}
// 监听地图的click事件
const handleMapClick = (e) => {
  // 阻止事件冒泡,不然会触发handleDocumentClick,导致弹窗刚打开就关闭
  e.stopPropagation()
  // 找到点击位置的第一个矢量要素
  const feature = map.forEachFeatureAtPixel(e.pixel, (f) => f)
  if (feature) {
    // 拿到要素的properties
    const properties = feature.getProperties()
    // 更新当前弹窗数据
    currentPopupData.value = { ...properties }
    // 设置Overlay的位置
    overlay.setPosition(fromLonLat([properties.lon, properties.lat]))
    // 显示弹窗
    showPopup.value = true
    // 把Teleport的容器的display属性改成block,因为初始化时是none
    teleportNode.style.display = 'block'
  } else {
    // 点击空白处关闭弹窗
    closePopup()
  }
}
// 监听文档的click事件,关闭弹窗
const handleDocumentClick = (e) => {
  // 如果点击的是地图容器或者弹窗本身,不关闭
  if (
    mapContainer.value.contains(e.target) ||
    teleportNode.contains(e.target)
  ) {
    return
  }
  closePopup()
}
// 关闭弹窗
const closePopup = () => {
  showPopup.value = false
  // 把Teleport的容器的display属性改成none
  teleportNode.style.display = 'none'
}
// 查看骑手详情
const goToDetail = (id) => {
  // 这里可以换成自己的路由跳转逻辑
  console.log('查看骑手详情,ID:', id)
  // router.push(`/rider-detail/${id}`)
}
// 组件卸载时清理资源,防止内存泄漏
onUnmounted(() => {
  if (map) {
    map.un('click', handleMapClick)
    map.setTarget(null)
    map = null
  }
  if (overlay) {
    overlay.setPosition(undefined)
    overlay = null
  }
})
</script>
<style scoped>
.gis-map-container {
  width: 100%;
  height: 100vh;
}
</style>

踩过的坑和优化建议

刚才的代码已经能用了,但我还踩过几个坑,给大家提个醒,顺便加几个优化建议:

坑1:Teleport的to属性必须是已经存在的DOM节点

一开始我把Teleport的to属性设为#overlayContainer,但没在模板里加那个div,导致控制台报错“Failed to locate target container for Teleport”,后来加了那个div,还要注意把它放在模板的最后,或者加style="display: none;",不然初始化时会在页面上看到一个空的弹窗框,影响用户体验。

坑2:点击地图上的点时会触发handleDocumentClick

刚才的代码里已经解决了这个问题,就是在handleMapClick里加了e.stopPropagation(),阻止事件冒泡到document上,另外还要在handleDocumentClick里判断点击的是不是地图容器或者弹窗本身,如果是就不关闭。

坑3:更新骑手位置时弹窗不跟着动

刚才的代码里也解决了这个问题,就是在updateRiderPosition里,当当前弹窗打开并且是某个骑手的,就更新Overlay的位置,用overlay.setPosition(fromLonLat([updatedRider.lon, updatedRider.lat]))

优化1:直接修改Feature的geometry,而不是重新添加

刚才的updateRiderPosition里是用addRidersToLayer重新添加所有骑手,这样做虽然简单,但性能不好,尤其是当有几百上千个骑手时,更好的做法是把每个Feature存到一个ref数组里,更新时直接修改对应的Feature的geometry:

// 存储所有骑手Feature的ref数组
const riderFeatures = ref([])
// 修改addRidersToLayer函数
const addRidersToLayer = () => {
  const vectorSource = vectorLayer.getSource()
  vectorSource.clear()
  riderFeatures.value = []
  mockRiders.value.forEach(rider => {
    const feature = new Feature({
      geometry: new Point(fromLonLat([rider.lon, rider.lat]))
    })
    feature.setProperties(rider)
    vectorSource.addFeature(feature)
    riderFeatures.value.push(feature)
  })
}
// 修改updateRiderPosition函数
const updateRiderPosition = () => {
  mockRiders.value.forEach((rider, index) => {
    rider.lon += (Math.random() - 0.5) * 0.001
    rider.lat += (Math.random() - 0.5) * 0.001
    rider.updateTime = Date.now()
    rider.address = rider.status === '配送中' ? `北京市东城区王府井大街${Math.floor(Math.random() * 100)}号` : '北京市东城区故宫博物院附近'
    // 直接修改对应的Feature的geometry
    riderFeatures.value[index].getGeometry().setCoordinates(fromLonLat([rider.lon, rider.lat]))
    // 同时更新Feature的properties,不然点击时拿到的还是旧数据
    riderFeatures.value[index].setProperties(rider)
  })
  // 后面更新弹窗的部分不变
}

优化2:用OpenLayers的Select交互代替click事件

刚才的代码里是用map.on('click')来监听点击事件,然后手动找要素,OpenLayers有一个专门的Select交互组件,能更好地处理选中状态,比如给选中的要素加高亮样式,还能同时选中多个要素(如果需要的话)。

优化3:加载本地的瓦片底图或者付费的高清底图

OSM免费底图虽然方便,但在中国国内访问速度慢,而且分辨率不够高,建议换成高德地图、百度地图或者腾讯地图的瓦片底图,注意要遵守它们的使用条款。

优化4:用防抖节流优化实时更新

如果实际项目中是通过WebSocket实时接收骑手位置,数据更新频率可能会很高(比如每秒几十次),这时候可以用lodash的throttle或者@vueuse/core的useThrottleFn来优化,避免频繁修改DOM,提升性能。

今天给大家讲了Vue3项目里怎么给OpenLayers6+图层加上流畅的实时交互弹窗,核心思路是用Vue3的Teleport+OpenLayers的Overlay挂载Vue组件,这样既能保持Vue的响应式,又能利用OpenLayers的地图功能,还讲了从零开始搭建的步骤,踩过的坑和优化建议,新手朋友可以直接复制代码去试,然后根据自己的需求修改。

如果有什么问题,欢迎在评论区留言讨论,我会尽量回复大家。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门