☰
Vue3+高德地图API实战:实时公交地图从初始化到避坑全指南
2026/10/17 14:47:03 网站建设 项目流程

简介:面向 Vue 开发者的高德地图集成实战参考,聚焦实时公交场景,系统讲解地图初始化、附近站点检索、线路实时详情、输入提示与换乘查询五大功能模块,适合需要快速掌握地图 API 与 Vue 搭配用法的中高级前端人员。整套资料仅 1 个 PDF 文件,压缩包约 82KB,内容以代码思路、API 调用要点和页面效果说明为主,便于按模块对照学习。目前已有 724 人浏览学习,可作为独立阅读的轻量参考。资源从 webpack.base.conf.js 中配置 externals: {'AMap': 'AMap'}、SDK 的同步/异步引入方式,到 PlaceSearch 周边搜索、LineSearch 线路查询、Autocomplete 输入提示、TransferPlan 换乘查询等具体接口均有涉及,并包含中心点坐标定位、搜索结果渲染、线路首末班时间展示等实现细节,能帮助读者少走弯路,快速搭建同类实时公交应用。

1. 实时公交地图的四个真实痛点:为什么值得用 Vue 重构一遍

如果你没做过实时公交类应用,直觉上会觉得这不过是“地图上加几个点、拉一个列表”。真正上手后才发现,“附近站点”、“线路详情”、“输入提示”和“换乘详情”这四个功能,光是把它们串进同一个页面,就能让前端团队忙上两周:坐标偏移、定时器轮询、并发请求乱序、地图实例销毁不干净,每一项都在跟真实设备和网络环境较劲。这篇笔记按 Vue 3 + 高德地图 JS API 的常见写法,把从地图初始化到换乘链路完整串一遍,代码可以直接抄进你的项目里。适合已经写过 Vue 组件、但还没在业务中正经用过地图 SDK 的同学;如果你后端有人配合,那更好,我会在后文把数据契约设计成前端与后端代理之间的接口,而不是让前端直接抱着一堆敏感 key 裸奔。

2. 高德地图接入 Vue 工程:密钥配置与地图初始化避坑

地图接入做得好不好,取决于三件事:密钥怎么配、SDK 怎么加载、地图实例怎么被业务层拿到。这三件事在 demo 里全都看不出问题,一进生产环境就变成“玄学报错”的重灾区。常见现象是本地好好的,部署到测试环境地图白屏;或者页面开了十分钟,控制台抛出一堆事件绑定错误。下面这组写法是我在多个项目里沉淀下来的,基本能绕开这些暗坑。

2.1 用 Promise 封装动态 SDK 加载:重复点击不再重复插入 script

很多教程会让你在 index.html 里直接放一个全局 script。这种做法有两个隐患:第一,不是每个访问者都进公交页,白白消耗一次 HTTP 请求;第二,插件列表被写死在 HTML 里,后面想按业务拆分时还得回来改页面。所以我一般单独建一个 mapLoader 模块,把“是否已加载、加载成功还是失败”统一收敛到一个 Promise 上。

// src/utils/mapLoader.js let loadingPromise = null export function loadAMap({ key, securityJsCode }) { // 已经加载过就直接复用,避免重复拼接 script 标签 if (window.AMap) return Promise.resolve(window.AMap) // 上一次加载还没结束,直接返回同一个 Promise,而不是再插入一次 script if (loadingPromise) return loadingPromise // 安全密钥必须先于 SDK 脚本写入,否则地图初始化阶段就失败 window._AMapSecurityConfig = { securityJsCode, } loadingPromise = new Promise((resolve, reject) => { const script = document.createElement('script') script.src = `https://webapi.amap.com/maps?v=2.0&key=${key}` script.async = true script.onload = () => { resolve(window.AMap) } script.onerror = () => { loadingPromise = null reject(new Error('高德 SDK 脚本加载失败')) } document.head.appendChild(script) }) return loadingPromise }

这段代码有两个容易被忽略的点。一个是loadingPromise的复用:它保证 SDK 还没加载完时,即使多个组件同时调用loadAMap,也不会向页面插入两份高德脚本。另一个是onerror回调里把loadingPromise置回null,这样用户短暂断网后再点一次还能重试,不需要刷新整页。很多人踩过“断网后地图永远加载不出来”的坑,根因就是失败后 Promise 状态被永久缓存了。

2.2 地图实例初始化参数:以业务场景反向推导缩放级别

地图创建不难,难的是参数。很多人在onMounted里new完AMap.Map就交差了,结果做到“附近站点”时才发现视野范围不对,站点图标全挤在地图边缘。我习惯把地图初始参数和业务场景绑定:实时公交页面要看清站点分布,又要让用户对自己所在位置有感知,zoom 取 13 到 15 比较稳妥;城市级别用 10 到 11,街道级别用 16 到 18。

// components/BusMap.vue <template> <div ref="mapContainer" class="map-container"></div> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' import { loadAMap } from '@/utils/mapLoader' const props = defineProps({ center: { type: Array, default: () => [116.397428, 39.90923], // [经度, 纬度] }, zoom: { type: Number, default: 15, }, }) const emit = defineEmits(['mapReady']) const mapContainer = ref(null) let mapInstance = null onMounted(async () => { const AMap = await loadAMap({ key: '替换成你的高德 key', securityJsCode: '替换成你的安全密钥', }) // 按需加载子插件,避免业务代码里临时再补 await new Promise((resolve) => { AMap.plugin( ['AMap.Geolocation', 'AMap.PlaceSearch', 'AMap.AutoComplete', 'AMap.Transfer'], resolve ) }) mapInstance = new AMap.Map(mapContainer.value, { center: props.center, zoom: props.zoom, viewMode: '2D', pitch: 0, mapStyle: 'amap://styles/fresh', }) emit('mapReady', { map: mapInstance, AMap }) }) onBeforeUnmount(() => { if (mapInstance) mapInstance.destroy() }) </script>

参数里,viewMode固定为2D是我刻意为之:实时公交页的折线和 Marker 在平面视野里更清晰,开启 3D 后,pitch会让边缘建筑遮挡站点标记,视觉上反而更乱。地图容器尺寸为 0 也是高频问题,AMap.Map实例化不会报错,但地图区块不渲染,建议在初始化后自查一轮:

const rect = mapContainer.value.getBoundingClientRect() if (rect.width === 0 || rect.height === 0) { console.warn('地图容器尺寸为 0,请检查外层布局是否有 v-if/display:none') }

2.3 组件拆分:地图实例不直接暴露给业务层

最后一步是组件边界设计。常见的翻车场景是:业务组件拿到地图实例后到处存取,最后连销毁逻辑都不知道写在哪。更好的做法是在BusMap组件内部只负责地图生命周期,通过mapReady事件把两个句柄交出去:

// 父组件里 <BusMap :center="center" @map-ready="handleMapReady" /> const map = ref(null) const AMapRef = ref(null) function handleMapReady({ map: m, AMap }) { map.value = m AMapRef.value = AMap // 后续附近站点、线路描边、换乘规划都在这里注册 }

这样业务组件里不会出现裸的高德 API 调用,后续升级 SDK 版本也只需改BusMap内部。等写单元测试时,你甚至能用一个假的 map 对象替换掉BusMap,业务层完全不受影响。这一步虽然花不了几行代码,但对后面两章的复杂交互影响巨大。

3. 附近站点与线路详情:从“我在哪”到“坐哪路”的业务闭环

附近站点是公交应用使用频率最高的功能之一,完整链路是:拿到用户坐标、用坐标检索周边站点、把站点渲染成列表和地图标点。这三步看似简单,实际藏着坐标纠偏、数据去重、类型匹配三个容易被忽略的细节。

3.1 Geolocation 定位:权限、超时与 IP 兜底三层处理

用户一进页面就要定位,这一步的体验决定了后续所有功能的准确性。浏览器定位申请权限后,用户可能同意、拒绝,也可能长时间不响应,所以必须做三层兜底。

function getUserLocation({ map, AMap }) { return new Promise((resolve) => { const geolocation = new AMap.Geolocation({ enableHighAccuracy: true, timeout: 10000, // 10 秒内拿不到结果就交给 error 回调 maximumAge: 0, // 不取缓存,保证每次进页面都是新定位 zoomToAccuracy: true, }) geolocation.on('complete', (result) => { const position = result.position // AMap.LngLat 对象 resolve({ lng: position.lng, lat: position.lat, accuracy: result.accuracy, }) }) geolocation.on('error', () => { // 用户拒绝授权或超时,用地图默认中心点做兜底 resolve({ lng: 116.397428, lat: 39.90923, accuracy: -1, // 标记为无效精度 }) }) geolocation.getCurrentPosition() }) }

当accuracy为 -1 时,界面上就不要做“附近”强排序,而是改成普通站点列表展示,避免用户看到一堆离他几公里远的站点。实际项目里,用户拒绝授权后还应该弹一个引导提示,把“去设置里打开定位”的路径写清楚,这是产品决策点,但技术上不要在这一步卡死流程。

3.2 附近站点检索:PlaceSearch 的 radius 与 type 经验值

拿到用户坐标后,用PlaceSearch的searchNearBy方法检索周边站点。这个接口的radius参数我一般取 800 到 1000 米,太短会漏站,太长会把另一条街的站也带进来,用户反而困惑。

function fetchNearbyStations({ AMap, lng, lat, radius = 1000 }) { const placeSearch = new AMap.PlaceSearch({ city: '全国', pageSize: 20, // 一页上限是 20,别设 50,接口只会返回 20 pageIndex: 1, type: '公交车站', // 类型关键词,不同城市的 POI 类别可能有差异 extensions: 'all', // 返回地址、经纬度等全量字段 }) return new Promise((resolve) => { placeSearch.searchNearBy([lng, lat], radius, (status, result) => { if (status === 'complete' && result.poiList) { const pois = result.poiList.pois.filter( (poi) => poi.location && poi.location.lng && poi.location.lat ) resolve(pois) } else { resolve([]) } }) }) }

返回结果里有个常见脏数据:同一站点因为朝向不同被拆成多条记录,比如“人民广场(东侧)”和“人民广场(西侧)”。做公交信息展示时,这两条要看成两个独立站点,因为停靠线路不同;但如果只是地图展示,可以按名称合并:

const uniqueMap = new Map() for (const poi of pois) { const key = `${poi.name}_${poi.location.lng.toFixed(4)}_${poi.location.lat.toFixed(4)}` if (!uniqueMap.has(key)) uniqueMap.set(key, poi) } const stationList = [...uniqueMap.values()]

3.3 线路详情解析:从线路 ID 到站点坐标串的完整链路

附近站点列表里每条 POI 只是一个“站牌”,不是一条线路。用户点开一个站点,想知道的是“哪几路车经过这里、下一班还有几分钟”。这部分纯靠前端拉起高德的导航数据不太现实,公交实时数据一般需要后端做代理,或直接对接城市的公交开放接口。我常用的是后端封装两条接口,前端只负责消费:

  • GET /api/bus/around?lng=&lat=&radius=&page=
  • GET /api/bus/line?line_id=&station_id=

第二条接口返回的线路详情结构大致如下:

{ "line_id": "B00025", "name": "32路", "direction_desc": "公主坟-五棵松", "first_bus_time": "05:30", "last_bus_time": "22:30", "stops": [ { "name": "公主坟", "location": [116.32, 39.9] }, { "name": "军博", "location": [116.33, 39.91] } ], "polyline": [[116.32, 39.9], [116.325, 39.905]] }

前端拿到polyline后转成高德坐标数组,同时把站点渲染成 Marker:

const linePath = data.polyline.map((item) => new AMap.LngLat(item[0], item[1])) const stopMarkers = data.stops.map((stop, index) => { const marker = new AMap.Marker({ position: new AMap.LngLat(stop.location[0], stop.location[1]), title: stop.name, label: { content: `${index + 1}`, // 用站序做角标,用户能直观知道下一站是几 direction: 'top', }, }) marker.setMap(map) return marker })

不要小看这个坐标转换。很多人直接把坐标系搞混——后端给的是 GCJ-02,前端又拿 WGS-84 的坐标去套,结果 Marker 全飘到马路对面。这里的polyline和后端都必须约定使用高德坐标系,前端不做任何额外转换。

3.4 站点列表与地图高亮:两个区域的联动交互

站点列表和地图标点必须联动,这是公交应用的体验分水岭。点击列表某一项,对应的 Marker 要突出显示,地图还要平移到该站点附近。我最开始用的是高德自带的setAnimation,但它有个明显问题:频繁调用时低端手机会掉帧。

let previousMarker = null function highlightStation(marker, { map, AMap }) { // 先把上一次的高亮复位 previousMarker?.setAnimation('AMAP_ANIMATION_NONE') // 改成图标替换 + 平移,避免动画 API 在低端机上的卡顿 marker.setIcon(new AMap.Icon({ size: new AMap.Size(24, 24), image: require('@/assets/station-active.png'), })) map.setCenter(marker.getPosition()) previousMarker = marker }

这个方案的好处是把“高亮”从动画依赖中解放出来。图标切换的瞬时响应比弹性动画更符合公交场景——用户只关心“我点的这个站在哪”,不需要额外 1 秒的视觉游戏。等地图切到线路详情时,这些站点 Marker 还可以继续复用,不必重复创建。

4. 实时公交数据与动态渲染:让车辆 Marker 在线上“移动”

地图能看、站点能点了,接下来是重头戏:让线路上的车“动”起来。实时公交的数据来源和高德的驾驶导航不一样,高德 JS API 并不直接提供“某辆车当前在哪”的公开能力,一般由后端对接公交集团或城市开放数据。前端要做的,是把数据变成地图上会动的 Marker,并处理好刷新频率和体验的关系。

4.1 数据接口与轮询周期:10 秒还是 60 秒,先看后端脸色

实时位置接口在演示项目里长这样:

GET /api/bus/realtime?id=B00025 { "line_id": "B00025", "vehicles": [ { "lng": 116.321, "lat": 39.902, "plate": "京A12345", "is_forward": true }, { "lng": 116.328, "lat": 39.906, "plate": "京A67890", "is_forward": false } ] }

轮询周期不要拍脑袋定。先问后端数据源本身的刷新间隔,常见公交开放接口是 15 到 30 秒更新一次位置,那么前端 10 秒拉一次只会造成无效请求。我一般先把后端接口的响应头Last-Modified或数据里的update_time打印出来看一眼,再决定轮询间隔。前端轮询频率比数据源快,是纯浪费;比数据源慢太多,用户会觉得车“一顿一顿”地跳。

4.2 轮询与生命周期:用 requestId 挡住过期响应

轮询必然伴随定时器,而定时器与 Vue 组件生命周期一旦脱钩,就会出现离开页面后还在疯狂请求的问题。更隐蔽的是请求乱序:用户先点开 A 线路,数据还没回来又切到 B 线路,A 的旧响应后到,地图上就出现“B 线路显示 A 线路的车”这种诡异画面。

let timer = null let requestId = 0 async function startPolling(lineId) { clearInterval(timer) timer = setInterval(async () => { const currentRequest = ++requestId const data = await fetchRealtime(lineId) // 如果期间用户切换了线路或页面,当前请求已经过期,直接丢弃 if (currentRequest !== requestId) return updateVehicleMarkers(data.vehicles) }, 15000) } function stopPolling() { clearInterval(timer) timer = null }

requestId是处理竞态最廉价的方案,比“请求后对比 activeLineId”更通用。它把所有“过期响应”统一挡在数据更新之前,不管是因为切换线路、组件卸载,还是因为网络慢导致上一次请求还没结束,都不会污染最新状态。

4.3 车辆位置更新:从跳动到平滑的补间动画

如果每 15 秒直接把 Marker 挪到新坐标,用户看到的是“瞬移”,视觉体验很糟糕。我通常会加一段简单的直线补间,让 Marker 在两次真实位置之间平滑过渡:

function animateMarker(marker, targetLngLat) { const start = marker.getPosition() const target = new AMap.LngLat(targetLngLat[0], targetLngLat[1]) let progress = 0 function step() { progress = Math.min(progress + 0.1, 1) const lng = start.lng + (target.lng - start.lng) * progress const lat = start.lat + (target.lat - start.lat) * progress marker.setPosition([lng, lat]) if (progress < 1) { requestAnimationFrame(step) } } requestAnimationFrame(step) }

补充说明一句:这是两点间的线性插值,没有考虑道路转弯,但公交位置轮询间隔短,两点之间本身距离有限,线性插值在视觉上完全够用。真正做车辆轨迹回放时才需要AMap.PathSimplifier或后端下发道路形状点做拟合,公交实时场景属于“够用即可”,不要过度设计。

4.4 线路描边与 Marker 层级规划

线路详情里要用Polyline画出整条公交路径:

const polyline = new AMap.Polyline({ path: linePath, // 上一章解析好的 LngLat 数组 strokeColor: '#2878FF', strokeOpacity: 0.8, strokeWeight: 6, // 比默认 4 更粗,配合白边做高辨识度 lineJoin: 'round', lineCap: 'round', zIndex: 10, }) polyline.setMap(map)

层级规划的顺序是:站点 Marker 在下、线路折线中间、车辆 Marker 永远最高。因为用户最关心的是车在哪,而不是站在哪。车辆 Marker 的zIndex我固定设为 100 以上:

const vehicleMarker = new AMap.Marker({ position: [item.lng, item.lat], content: '<div class="bus-icon">BUS</div>', zIndex: 100, })

如果线路很长、站点很多,还要注意 Marker 总量。站点数超过 50 个时,建议缩放级别小于 13 就只显示聚合,不要一次性把全部 Marker 铺上去。

5. 实时公交应用避坑清单:坐标偏移、定时器泄漏与请求乱序

这一章整理我在做公交应用过程中反复踩过的四个最典型问题,每条都是“现象 → 原因 → 解决”的结构,遇到类似表现可以直接对照排查。

5.1 坐标偏移:定位坐标和站点坐标不在一套坐标系里

现象:定位中心点落在马路上,但站点全部飘到马路对面几十米开外;点进详情又能看到正确的地址字段。

原因:浏览器原生 Geolocation 返回的是 WGS-84 坐标系,而高德地图内部使用 GCJ-02(俗称火星坐标系)。如果拿原生定位结果直接给高德用,偏移是必然的,且越靠近城市偏移越明显。

解决:不要自己写“加固定偏移量”这种土办法,误差在城市里可以差到上百米。直接使用AMap.Geolocation定位,它内部会做纠偏;如果定位来自其他 SDK 或后端下发,必须经过坐标转换接口再进入地图。前端能做的只有一件事:约定统一坐标系,高德地图相关数据一律使用 GCJ-02。

5.2 定时器泄漏:离开详情页后组件仍 15 秒一轮询

现象:用户从线路详情页返回列表页后,打开控制台 Network 面板,/api/bus/realtime还在每 15 秒发一次;页面切到后台 2 小时后回来,手机发烫明显。

原因:setInterval只被clearInterval能停掉。如果页面用了keep-alive缓存组件,onMounted只触发一次,定时器会跨页面存活;如果单纯在onBeforeUnmount里清理,keep-alive场景下又不会触发卸载。

解决:同时处理onActivated和onDeactivated:

onActivated(() => startPolling(activeLineId.value)) onDeactivated(() => stopPolling())

keep-alive缓存的组件切走时会触发deactivated,切回来再触发activated,把轮询的启动和停止挂在这两个钩子上,才能做到真正的不泄漏。

5.3 请求乱序:快速切换线路时旧响应覆盖新数据

现象:先点 A 线路,后点 B 线路,但 A 接口因为网络慢比 B 晚返回,页面上的站点和车辆全变成 A 的,而标题还显示“B 线路”。

原因:异步请求没有做有效期校验。响应返回后,组件没有确认“这个响应是否还属于当前激活的线路”,就把数据直接渲染了。

解决:在发起请求时用requestId自增,返回值回来先比对:

const currentRequest = ++requestId const data = await fetchRealtime(lineId) if (currentRequest !== requestId) return

这比简单比较activeLineId更可靠,因为即使切换线路后再切回来,旧响应依然会被拦截,不会出现“旧响应覆盖新状态”的问题。

5.4 Marker 数量膨胀:低端手机掉帧的隐藏凶手

现象:画完线路后一次性把所有站点和车辆 Marker 铺到地图,页面在手机端拖动明显掉帧;缩放地图时 Marker 标签乱飞。

原因:普通AMap.Marker是 DOM 实现,几十上百个 DOM 节点同时挂在视图层,再加上车辆补间动画,主线程直接被占满。

解决:分两层缓解。第一层,站点标注改用AMap.CircleMarker代替普通 Marker,它走 canvas 渲染,不创建 DOM,几百个也不卡:

const circle = new AMap.CircleMarker({ center: [lng, lat], radius: 8, strokeWeight: 1, fillColor: '#2878FF', strokeColor: '#fff', })

第二层,真正有交互需求的站点才用普通 Marker,比如当前选中站、换乘站。车辆 Marker 数量少,可以保持普通 Marker 以便显示车牌和动画,但不要给每个车辆都绑上复杂的 label 和事件。

6. 输入提示与换乘详情的集成:把搜索和路径规划串成一条链路

公交应用里,输入框和换乘结果是最后两步,也是最影响“这个产品好不好用”观感的环节。高德提供的AutoComplete和Transfer两个插件,能让前端在半小时内接好一套可用的搜索与换乘链路。

6.1 AutoComplete 接入:候选列表、防抖与落图

输入提示的接入点不是高德的插件本身,而是输入框的事件监听。下面我常用的一组写法:

const autoComplete = new AMap.AutoComplete({ city: '北京', // 生产环境用后端下发的默认城市,不要写死 pageSize: 7, // 下拉候选最多 7 条,避免列表过长 extensions: 'all', }) let inputTimer = null function onSearchInput(e) { const keyword = e.target.value.trim() if (!keyword) return clearTimeout(inputTimer) inputTimer = setTimeout(() => { autoComplete.search(keyword, (status, result) => { if (status === 'complete' && result.tips) { suggestionList.value = result.tips } }) }, 300) }

用户选中候选条后,用tip.location把地图中心移过去。如果location为空,说明选中的是城市名或行政区,这时要用tip.adcode做一次行政区边界定位,否则地图不会动。这个细节很容易被漏掉,一旦漏掉用户会以为“点了没反应”。

6.2 Transfer 换乘规划的关键字段映射

换乘详情用AMap.Transfer做公交路线规划,返回结果里有plans数组,每个plan是一套换乘方案:

const transfer = new AMap.Transfer({ city: '北京', policy: AMap.TransferPolicy.LEAST_TIME, // 按时间优先排序 nightflag: false, }) transfer.search(new AMap.LngLat(lng, lat), destination, (status, result) => { if (status !== 'complete' || !result.plans) return const plans = result.plans.map(extractPlanInfo) transferPlans.value = plans }) function extractPlanInfo(plan) { const walkSegments = plan.segments.filter(s => s.segment_type === 'walk') const busSegments = plan.segments.filter(s => s.segment_type === 'bus') return { totalTime: plan.duration, // 分钟 totalDistance: plan.distance, // 米 totalWalk: walkSegments.reduce((sum, s) => sum + s.distance, 0), busNames: busSegments.map(s => s.busline.name), firstStop: busSegments[0]?.departure_stop?.name, lastStop: busSegments[busSegments.length - 1]?.arrival_stop?.name, } }

换乘详情卡片里只放“坐几路、从哪上车、到哪下车、走多远”四类信息,就已经能覆盖 90% 的决策场景。把plans里所有的步行子段和公交子段展开逐一展示,反而会让用户在一屏里找不到重点。我习惯把第一套方案默认展开,其余方案折叠成一行概要。至今我还保留一个习惯:所有换乘请求也加一圈requestId防竞态,毕竟用户连续切换起终点比切线路还频繁。希望这套链路能帮你少走两个版本的重构弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询