Vue 3 与 OpenLayers 地图开发踩坑记:一位全栈开发者的实战复盘
译注:本文翻译自 dev.to 社区文章《I built a live map of natural events with Vue 3 and OpenLayers. Here are the mistakes I made.》,作者 Andrea Raccagni 是一位常驻西班牙特内里费岛的意大利全栈开发者。原文链接见文末。
项目背景
作者有约四年 Vue 生产环境开发经验,做过区块链浏览器、电商平台、编辑器、仪表盘和数据可视化页面,但从未接触过地图开发——没有 GIS、没有投影、没有 OpenLayers。于是他给自己定了一个小项目:一张展示当下正在发生的自然事件(风暴、山火、火山、冰山)的世界地图,旁边配一个列表和详情卡片,点击圆点即可查看事件详情。
项目名为 EarthPulse,技术栈为 Vue 3(<script setup> + TypeScript)、OpenLayers 10、Vite、axios、pnpm,部署在 GitHub Pages 上。数据来自 NASA 的 EONET(地球观测站自然事件追踪器)公开 API。
开工前的几个决策
- 选 OpenLayers 而非 Leaflet:Leaflet 在地图标记上更简单,但作者希望投影和矢量样式是内置能力而非插件,也想学习功能更全的库。
- 不做后端:EONET 是浏览器可直接调用的公开 API。代价是没有缓存,NASA 服务宕机时应用只能报错。作为副项目可以接受,生产环境则应在前面加一个带缓存的小型代理。
- 不用 Pinia:三个组件、单一状态持有者,props 向下、事件向上就够了。加 store 只是把同样的代码挪到另一个文件。
OpenLayers 的四个核心概念
- Map:引擎,绘制到某个 DOM 元素(
target)中。 - View:相机,控制中心点、缩放和投影。
- Source:数据来源。
- Layer:数据如何绘制,图层像纸张一样堆叠。
底图是带 OSM 源的 TileLayer,事件则是带 VectorSource 的 VectorLayer。
两个对普通前端开发者不直观的点:
- GeoJSON 坐标是
[经度, 纬度],X 在前、Y 在后,与多数应用展示纬度的习惯相反。 - 地图不使用度数。NASA 返回的是度数(
EPSG:4326),而 OSM 瓦片和默认视图使用米(EPSG:3857)。若把度数直接给期望米的地图,所有点都会挤在[0, 0]附近——非洲旁边的海里。修复方式是在读取数据时加一个选项:
new GeoJSON().readFeatures(collection, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});dataProjection 表示“我的数据是什么坐标系”,featureProjection 表示“地图想要什么坐标系”。
此外还有两处 Vue 习惯与 OpenLayers 冲突的地方:
- 地图需要真实的 DOM 元素。
new Map({ target: 'map' })要放在onMounted中,因为 setup 阶段#mapdiv 还不存在。 - 不要把 OpenLayers 对象放进普通
ref()。ref()会用深层 Proxy 包裹对象,而 OpenLayers 对象内部充满监听器,不喜欢被代理。作者对地图使用shallowRef,对矢量源使用普通变量——Vue 管理自己的数据,OpenLayers 管理自己的对象。
错误一:地图组件包办一切
作者明知组件不应在多个组件需要数据时自行请求,但因为是原型,第一版把一切都塞进了 EventMap.vue:请求数据、维护加载状态、绘制地图、点击时构建详情卡片对象。
问题在需要加第二个组件时暴露:
- “无事件”被当成错误:空数据和网络故障显示同一条消息,但两者并不相同。
finally意味着“结束”而非“成功”:一个布尔isLoading加一个错误字符串不足以描述状态。- 只有地图持有数据:列表无法使用地图组件内部的数据。
于是作者把 HTTP 调用抽到独立文件 src/api/eonet.ts,App.vue 持有数据和状态,状态从布尔值改为四态:
type Status = 'loading' | 'success' | 'empty' | 'error';地图则退化为渲染器:通过 prop 接收事件,用 watch 重绘,并设置 immediate: true,因为数据可能在地图就绪前后到达,这样只有一个地方添加要素。
还有一点地图特有的注意事项:加载和错误消息现在放在地图旁边而非替代地图。普通组件里常用 v-if 切换,但这里如果地图元素消失,OpenLayers 会丢失 target,必须重新创建地图。地图 div 必须始终留在页面上。
错误二:事件传递对象而非 id
第一版中,地图点击会构建完整对象并向上抛出。只有一个点击来源时没问题,但加入列表后,列表持有的是原始 GeoJSON,而非地图坐标系下的 OpenLayers feature。于是要么列表重复坐标逻辑,要么发送不同对象,要么 App 维护两个可能失同步的选中状态。
根源在于:OpenLayers 中点击到的是 Feature 类实例,其几何坐标以米为单位,很容易顺手在那里转成有用对象。但列表没有 Feature,而且 Feature 本就不该进入 Vue 状态。
修复方案:地图和列表都只发送 id。App 维护唯一的 currentEventId,详情卡片由它 computed 得出:
EventList ──emit id──┐
├─→ App.currentEventId ─→ computed NaturalEvent ─→ EventDetails
EventMap ──emit id──┘ │
└─ selectedEventId prop → style + flyTo地图点击逻辑随之简化为通过 forEachFeatureAtPixel 找到 feature 后只发出 feature.getId()。
小结
这篇文章的价值不在于地图本身,而在于它展示了一个有经验的 Vue 开发者在面对全新库和全新数据类型时,哪些习惯仍然适用、哪些会失效。核心教训包括:状态用四态而非布尔、组件职责要尽早拆分、跨组件通信只传 id 而非对象,以及不要把第三方库的实例放进深层响应式 ref()。