480 lines
18 KiB
Markdown
480 lines
18 KiB
Markdown
# 前端面试题:地图路线规划 + 轨迹动画系统
|
||
|
||
## 题目描述
|
||
|
||
在 Vue 3 + 高德地图的养老/医疗微前端应用中,实现一个**护理员轨迹系统**:
|
||
|
||
```
|
||
操作流程:
|
||
[📍标记起点] → 点击地图放 📍
|
||
[🏁标记终点] → 点击地图放 🏁
|
||
[➕添加护理员] → 在起点创建 👩⚕️
|
||
[🚗规划路线] → AMap.Driving 计算驾车路线 → 蓝色折线
|
||
[▶出发] → 护理员沿路线动画移动
|
||
|
||
不管路径 500km 还是 2000km,动画始终 ~10 秒丝滑播完
|
||
```
|
||
|
||
**核心要求:**
|
||
|
||
1. 起点和终点通过**点击地图手动标记**(独立 `AMap.Marker`)
|
||
2. 驾车路线使用 `AMap.Driving` 规划(真实道路路径,非直线)
|
||
3. 护理员标记沿路线**动画移动**,无论路径多长都流畅无卡顿
|
||
4. 支持暂停、回到起点、全部清除
|
||
5. 护理员不存在时点击"出发"要**自动创建**(容错 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) ← 注册一次性点击监听
|
||
```
|
||
|
||
**关键代码:**
|
||
|
||
```ts
|
||
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()`
|
||
|
||
```ts
|
||
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()`
|
||
|
||
```ts
|
||
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
|
||
```
|
||
|
||
**路线数据提取(核心):**
|
||
|
||
```ts
|
||
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)` 而不是直接传数组?**
|
||
|
||
```ts
|
||
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` 块保证状态恢复**
|
||
|
||
```ts
|
||
try {
|
||
// ... Driving 路线规划 ...
|
||
} catch (err: any) {
|
||
alert(`路线规划失败: ${err.message}`)
|
||
clearRoute() // 失败时清理半成品
|
||
} finally {
|
||
isPlanning.value = false // ← 无论如何都要关掉 loading,否则按钮永久禁用
|
||
}
|
||
```
|
||
|
||
### 阶段 5:级联清理 — 删除起点/终点自动清除路线
|
||
|
||
```ts
|
||
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,动画抖动 | 均匀间隔,**视觉丝滑** |
|
||
|
||
**下采样算法:**
|
||
|
||
```ts
|
||
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. 帧动画引擎
|
||
|
||
```ts
|
||
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 的完整错误处理
|
||
|
||
```ts
|
||
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`?
|
||
|
||
**期望回答:**
|
||
- `rAF` 60fps → 250 点只需 4 秒(太快)
|
||
- `rAF` 帧率不稳定(后台标签页降到 1fps)→ 动画时间不可预测
|
||
- `setInterval(40)` 稳定 25fps,后台自动暂停(浏览器节流),回来时从当前位置继续
|
||
- 需要配合进度条时需要可知的总时长,`setInterval` 更可控
|
||
|
||
### 追问 3:`startAnimation` 中为什么要 `await addCaregiver()`?
|
||
|
||
**期望回答:** `addCaregiver()` 内部调用 `loadAMap()` 是异步的。如果护理员不存在,需要等标记创建完成再开始动画。`await` 确保 `caregiverMarker.value` 已被赋值,后续 `setInterval` 中才能正常调用 `setPosition`。
|
||
|
||
### 追问 4:如何让动画支持「可调速」?
|
||
|
||
**期望回答:** 将 40ms 提取为变量 `speed`,暴露给外部。速度加倍 = interval 减半:
|
||
```ts
|
||
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()`。清理顺序必须是:
|
||
1. `stopAnimation()` — 先停 `setInterval`,确保不再操作任何地图对象
|
||
2. `map.remove(routePolyline)` — 再安全删除路线折线
|
||
3. 重置所有 ref — 最后清理响应式状态
|
||
|
||
这个顺序保证了"先停用,再删除,最后置空",避免 `setInterval` 回调在对象删除后仍然触发 `setPosition`。
|