18 KiB
前端面试题:地图路线规划 + 轨迹动画系统
题目描述
在 Vue 3 + 高德地图的养老/医疗微前端应用中,实现一个护理员轨迹系统:
操作流程:
[📍标记起点] → 点击地图放 📍
[🏁标记终点] → 点击地图放 🏁
[➕添加护理员] → 在起点创建 👩⚕️
[🚗规划路线] → AMap.Driving 计算驾车路线 → 蓝色折线
[▶出发] → 护理员沿路线动画移动
不管路径 500km 还是 2000km,动画始终 ~10 秒丝滑播完
核心要求:
- 起点和终点通过点击地图手动标记(独立
AMap.Marker) - 驾车路线使用
AMap.Driving规划(真实道路路径,非直线) - 护理员标记沿路线动画移动,无论路径多长都流畅无卡顿
- 支持暂停、回到起点、全部清除
- 护理员不存在时点击"出发"要自动创建(容错 UX)
架构设计
useRouteTrack(mapInstance)
├─ enterSetMode('origin'|'dest') ← 点击地图放标记
├─ addCaregiver() ← 在起点创建护理员
├─ planRoute() ← AMap.Driving 路线规划
│ └─ downsamplePath(full, 250) ← ★ 路径下采样
├─ startAnimation() ← setInterval 帧动画
└─ clearAll() ← 完整清理
起始点路径规划 — 完整实现链路
整个"画起始点 → 规划路线"功能由 5 个阶段 串联而成,每个阶段由不同的函数负责,状态通过 Vue 响应式变量驱动 UI。
阶段 1:进入标记模式 — enterSetMode('origin' | 'dest')
用户点击 [📍标记起点]
→ enterSetMode('origin')
→ exitSetMode() ← 先清理上一次的状态(互斥)
→ settingMode.value = 'origin'
→ map.setDefaultCursor('crosshair')
→ map.on('click', onClick) ← 注册一次性点击监听
关键代码:
async function enterSetMode(mode: 'origin' | 'dest'): Promise<void> {
const map = mapInstance.value
if (!map) return
exitSetMode() // ① 互斥:同时只能设一种点
settingMode.value = mode
map.setDefaultCursor('crosshair') // ② 视觉反馈:十字光标
const AMap = await loadAMap()
function onClick(e: any): void {
const pt = eventLngLat(e) // ③ 安全提取经纬度
if (!pt) return
if (mode === 'origin') {
applyOrigin(pt, AMap, map!) // ④ 创建/更新起点标记
} else {
applyDestination(pt) // ⑤ 创建/更新终点标记
}
exitSetMode() // ⑥ 单击后自动退出
}
_clickHandler = onClick
;(map as any)._rtClick = onClick // ⑦ 保存引用,确保后续能正确解绑
map.on('click', onClick)
}
三个设计细节:
| 细节 | 代码位置 | 理由 |
|---|---|---|
exitSetMode() 前置调用 |
enterSetMode 第 3 行 |
如果用户先点了"标记起点",又点"标记终点",旧点击事件会被清理,不会同时触发两个 handler |
_rtClick 强引用保存 |
(map as any)._rtClick = onClick |
map.off('click', handler) 需要同一个函数引用才能解绑;闭包内的 onClick 每次调用都是新函数,_rtClick 保存它以便 exitSetMode 精确解绑 |
| 单击后自动退出 | exitSetMode() 在回调末尾 |
避免用户误放多个标记;每次设点都是"进入模式 → 点一次 → 退出"的原子操作 |
阶段 2:安全提取经纬度 — eventLngLat()
function eventLngLat(e: any): [number, number] | null {
const lng: number = e.lnglat?.lng ?? e.lnglat?.getLng?.() // 兼容两种 API 格式
const lat: number = e.lnglat?.lat ?? e.lnglat?.getLat?.()
if (isNaN(lng) || isNaN(lat)) return null // NaN 防护
return [lng, lat]
}
高德地图点击事件的 lnglat 对象在不同版本中可能是属性(e.lnglat.lng)也可能是方法(e.lnglat.getLng())。?? 链式回退 + isNaN 兜底确保不会因为 API 差异导致后续逻辑拿到 NaN 坐标。
阶段 3:创建/更新标记 — applyOrigin() & applyDestination()
function applyOrigin(point: [number, number], AMap: ..., map: ...): void {
originPoint.value = point // ① 反应式状态:驱动 canPlanRoute 计算属性
if (originMarker.value) {
originMarker.value.setPosition(point) // ② 已存在:复用 + 移动位置
} else {
const m = new AMap.Marker({ // ③ 不存在:创建新标记
position: point,
content: ORIGIN_HTML, // '📍' emoji 作为标记内容
offset: new AMap.Pixel(-10, -10), // 居中对齐
zIndex: 205, // 层级:起点(205) < 护理员(210) < 终点(215)
})
map.add(m)
originMarker.value = m
}
}
状态双轨制:
| 轨道 | 变量 | 类型 | 用途 |
|---|---|---|---|
| 坐标层 | originPoint / destPoint |
Ref<[number,number] | null> |
响应式数据,驱动 canPlanRoute、传给 Driving API |
| 标记层 | originMarker / destMarker |
ShallowRef<AMap.Marker | null> |
AMap 实例,用于地图渲染;用 shallowRef 避免深度响应式开销 |
applyDestination 逻辑完全对称,区别在于 content(🏁)、offset(-12, -32)和 zIndex(215,高于护理员和起点)。
阶段 4:路线规划 — planRoute()
这是整个功能的核心,流程如下:
planRoute()
├─ 前置检查:originPoint && destPoint && map 都存在
├─ clearRoute() ← 清除旧路线
├─ isPlanning = true ← 按钮 loading 状态
├─ loadAMap() ← 动态加载
├─ 检查 AMap.Driving 可用 ← 防御性编程
├─ new AMap.Driving({ map, policy: 0 })
│ policy: 0 = 速度优先(LEAST_TIME)
│ map: 必须传入!Driving 依赖 map 做投影计算
├─ driving.search(originLngLat, destLngLat, callback)
└─ 回调处理 3 种 status
路线数据提取(核心):
if (status === 'complete' && result.routes?.length > 0) {
const route = result.routes[0]
const fullPath: [number, number][] = []
// Driving 返回的路径是分层结构:Route → Step → Path
for (const step of route.steps) {
for (const pt of step.path) fullPath.push([pt.lng, pt.lat])
}
routePath.value = fullPath // 原始路径(5000+ 点)
animPath.value = downsamplePath(fullPath, 250) // 动画路径(250 点)
routeInfo.value = {
distance: route.distance, // 米
duration: route.time, // 秒
path: fullPath,
}
// 绘制蓝色折线
const polyline = new AMap.Polyline({
path: fullPath,
strokeColor: '#4A90D9', strokeWeight: 4, strokeOpacity: 0.75,
lineJoin: 'round', lineCap: 'round', zIndex: 85,
})
map.add(polyline)
routePolyline.value = polyline
// 自动缩放视野以包含整条路线
map.setFitView([polyline], false, [60, 60, 60, 60])
}
为什么要 new AMap.LngLat(lng, lat) 而不是直接传数组?
const originLngLat = new AMap.LngLat(orgLng, orgLat) // ✅ 推荐
const destLngLat = new AMap.LngLat(destLng, destLat)
// 某些版本的 AMap.Driving.search() 不接受 [lng, lat] 数组
三种 status 的完整处理:
| status | 含义 | 处理策略 |
|---|---|---|
'complete' |
规划成功 | 提取路径 → 下采样 → 绘折线 → resolve() |
'error' |
API 调用失败(密钥/权限/网络) | 提取 result.info 或 result.message → reject() |
'no_data' |
起终点间无可通行道路 | 提示用户缩短距离或换位置 → reject() |
| 其他 | 未知状态(不应发生) | 防御性兜底 → reject() |
关键:finally 块保证状态恢复
try {
// ... Driving 路线规划 ...
} catch (err: any) {
alert(`路线规划失败: ${err.message}`)
clearRoute() // 失败时清理半成品
} finally {
isPlanning.value = false // ← 无论如何都要关掉 loading,否则按钮永久禁用
}
阶段 5:级联清理 — 删除起点/终点自动清除路线
function removeOrigin(): void {
// ...移除 marker 和坐标...
clearRoute() // ← 级联:没了起点,路线也无意义
}
function removeDest(): void {
// ...移除 marker 和坐标...
clearRoute() // ← 同理
}
function clearRoute(): void {
stopAnimation() // 停动画
if (routePolyline.value) {
map.remove(routePolyline.value) // 移除折线
routePolyline.value = null
}
routePath.value = [] // 清空路径数据
animPath.value = []
routeInfo.value = null
animProgress.value = 0 // 重置进度
animStep.value = 0
}
级联关系图:
removeOrigin() / removeDest()
└─ clearRoute()
├─ stopAnimation() ← 先停动画,避免 setInterval 操作已删除的对象
├─ map.remove(polyline)
└─ 重置所有相关 ref
clearAll()
├─ exitSetMode() ← 退出标记模式
├─ stopAnimation()
├─ clearRoute()
├─ removeCaregiver()
├─ removeOrigin()
└─ removeDest()
清理顺序很重要:先停动画 → 再删路线 → 最后删标记。如果反过来,stopAnimation 中的 setPosition 可能操作已删除的 marker。
完整数据流图
用户操作 状态变化 地图效果
─────── ──────── ────────
点击 [📍标记起点]
→ enterSetMode('origin') settingMode='origin' 光标变十字
→ 点击地图 originPoint=[lng,lat] 📍 出现在点击位置
→ exitSetMode() settingMode=null 光标恢复默认
点击 [🏁标记终点]
→ enterSetMode('dest') settingMode='dest' 光标变十字
→ 点击地图 destPoint=[lng,lat] 🏁 出现在点击位置
→ exitSetMode() settingMode=null 光标恢复默认
canPlanRoute=true [🚗规划路线] 按钮可点击
点击 [🚗规划路线]
→ planRoute() isPlanning=true 按钮 loading
→ Driving.search()
→ 提取 path routePath=[5000+点]
→ downsamplePath(250) animPath=[250点]
→ new Polyline routePolyline=实例 蓝色折线出现
→ setFitView 视野自动缩放
isPlanning=false 按钮恢复
点击 [▶出发]
→ startAnimation() isAnimating=true
→ setInterval(40ms) animStep 0→249 👩⚕️ 沿路线移动
animProgress 0→1 进度条推进
→ stopAnimation() isAnimating=false 👩⚕️ 停在终点
核心技术点
1. 路径下采样 — 丝滑动画的秘密
AMap.Driving 返回原始路径:5000+ 个点(每几米一个)
↓ downsamplePath(fullPath, 250)
动画路径:250 个点(均匀间隔)
↓ setInterval(40ms) → 25fps
总时长:250 × 40ms = 10 秒(恒定)
为什么需要下采样?
| 不用下采样 | 用下采样 |
|---|---|
| 5000 点 × 40ms = 200 秒(太慢) | 250 点 × 40ms = 10 秒(刚好) |
| 短距离只有 200 点 = 8 秒(不一致) | 始终 250 点 = 恒定 10 秒 |
| 每个点间隔 ~1m,动画抖动 | 均匀间隔,视觉丝滑 |
下采样算法:
function downsamplePath(path: [number, number][], target: number): [number, number][] {
if (path.length <= target) return [...path]
const result: [number, number][] = []
const step = (path.length - 1) / (target - 1) // 5000/249 ≈ 20.08
for (let i = 0; i < target; i++) {
result.push(path[Math.min(Math.round(i * step), path.length - 1)])
}
return result
}
为什么选 250?
- 250 × 40ms = 10 秒,不短不长,刚好展示完整轨迹
- 25fps 是人眼感知流畅的最低帧率,再低会卡顿
- 250 个
setPosition调用对 DOM 压力极小
2. 帧动画引擎
async function startAnimation(): Promise<void> {
// 🔧 容错:护理员不存在 → 自动在起点创建
if (!caregiverMarker.value) {
if (!originPoint.value) { alert('请先标记起点'); return }
await addCaregiver()
}
const total = animPath.value.length - 1 // 249 步
animTimer = setInterval(() => {
if (animStep.value >= total) {
stopAnimation() // 到达终点自动停
animProgress.value = 1
return
}
animStep.value++
animProgress.value = animStep.value / total // 0→1 驱动进度条
caregiverMarker.value?.setPosition(animPath.value[animStep.value])
}, 40) // 25fps
}
| 参数 | 值 | 说明 |
|---|---|---|
| 帧间隔 | 40ms | 25fps,人眼流畅感知阈值 |
| 总步数 | 249 | 250 个路径点,首点即起始位置 |
| 总时长 | ~10s | 恒定,不受实际距离影响 |
| 进度计算 | step/total |
0→1,驱动 CSS 进度条宽度 |
3. 三种 status 的完整错误处理
driving.search(originLngLat, destLngLat, (status, result) => {
if (status === 'complete' && result.routes?.length > 0) {
// ✅ 成功:提取路径 → 下采样 → 绘制折线
} else if (status === 'error') {
// ❌ 参数/权限问题:INVALID_USER_SCODE(缺安全密钥)等
reject(new Error(`路线规划失败: ${result.info || result.message}`))
} else if (status === 'no_data') {
// ⚠️ 起终点之间确实无路(如海岛 → 内陆无桥梁)
reject(new Error('起终点之间无可用路线'))
} else {
// ⁉️ 未知状态,不应发生
reject(new Error(`路线规划失败 (${status})`))
}
})
4. 点击地图放标记 — 模式切换设计
enterSetMode('origin' | 'dest')
→ exitSetMode() // 先退出当前模式(互斥)
→ settingMode = mode // origin 或 dest
→ map.setDefaultCursor('crosshair')
→ map.on('click', handler)
→ 点击地图:
→ 获取经纬度
→ mode === 'origin' ? applyOrigin() : applyDestination()
→ exitSetMode() // 单次点击后自动退出
三个关键设计决策:
| 决策 | 理由 |
|---|---|
| 单次点击后自动退出 | 避免用户误放多个标记 |
| origin 和 dest 互斥 | 同一时间只能设一种点,防止状态混乱 |
| 退出时恢复光标 + 解绑事件 | exitSetMode() 统一清理,防止事件泄漏 |
考察点汇总
| 层级 | 考察点 | 难度 |
|---|---|---|
| 算法 | 路径下采样(均匀间隔重采样) | ⭐⭐⭐ |
| 动画 | setInterval 帧动画 + 进度归一化 | ⭐⭐ |
| API 集成 | AMap.Driving 三种 status 的完整处理 | ⭐⭐⭐ |
| 交互设计 | 模式切换 + 点击地图放标记 | ⭐⭐ |
| 容错设计 | 护理员缺失时自动创建 | ⭐ |
| 资源管理 | setInterval 清理 + 事件解绑 | ⭐⭐ |
| 级联清理 | 删除起点/终点时自动清除路线,避免僵尸数据 | ⭐⭐ |
| 状态双轨制 | 坐标层(ref)驱动逻辑 vs 标记层(shallowRef)驱动渲染 | ⭐⭐⭐ |
| 调试 | INVALID_USER_SCODE 问题排查 | ⭐⭐⭐⭐ |
加分项
- 能解释为什么 250 个点 — 250×40ms=10s 恒定,与距离无关
- 能提出变速方案 — 起步加速/到站减速(easeInOutCubic),用动态 interval 替代固定 40ms
- 能指出
new AMap.LngLat(lng, lat)的必要性 — 数组[lng, lat]在某些版本不被 Driving 接受 - 能说明
map: undefined会导致 Driving 失败 — Driving 依赖 map 做投影计算 - 能分析不同距离下的优化策略 — 短距离提高 target 点数(更细腻),长距离降低(防卡顿)
追问方向
追问 1:如果路线有 10 万个点,250 的下采样会丢失多少细节?
期望回答: 10 万 → 250,每 400 个点取 1 个,城市街道级别的拐弯会被抹平。但视觉上因为标记移动只显示一个点,250 个关键帧足以模拟沿途运动。如果需要更高精度,可以动态调整 target:短距离(<50km)用 500 点,长距离(>500km)用 150 点。
追问 2:为什么用 setInterval 而不用 requestAnimationFrame?
期望回答:
rAF60fps → 250 点只需 4 秒(太快)rAF帧率不稳定(后台标签页降到 1fps)→ 动画时间不可预测setInterval(40)稳定 25fps,后台自动暂停(浏览器节流),回来时从当前位置继续- 需要配合进度条时需要可知的总时长,
setInterval更可控
追问 3:startAnimation 中为什么要 await addCaregiver()?
期望回答: addCaregiver() 内部调用 loadAMap() 是异步的。如果护理员不存在,需要等标记创建完成再开始动画。await 确保 caregiverMarker.value 已被赋值,后续 setInterval 中才能正常调用 setPosition。
追问 4:如何让动画支持「可调速」?
期望回答: 将 40ms 提取为变量 speed,暴露给外部。速度加倍 = interval 减半:
const speedMap = { '1x': 40, '2x': 20, '4x': 10 }
// 或动态计算,进度条相应调整
追问 5:为什么 originPoint 用 ref 而 originMarker 用 shallowRef?
期望回答:
originPoint是纯数据[number, number],需要响应式以驱动canPlanRoute计算属性和 Driving API 调用originMarker是 AMap 实例对象,内部结构庞大且不应被 Vue 深度代理shallowRef只监听.value的替换(m = new AMap.Marker(...)或m = null),不追踪内部属性变化- 标记的位置更新通过
marker.setPosition()直接操作 AMap 原生 API,不需要 Vue 参与
追问 6:clearRoute() 里为什么要先 stopAnimation() 再删折线?
期望回答: stopAnimation 中会调用 caregiverMarker.value?.setPosition()。清理顺序必须是:
stopAnimation()— 先停setInterval,确保不再操作任何地图对象map.remove(routePolyline)— 再安全删除路线折线- 重置所有 ref — 最后清理响应式状态
这个顺序保证了"先停用,再删除,最后置空",避免 setInterval 回调在对象删除后仍然触发 setPosition。