Files
microapp-vue3-interview/docs/interview-question-route-track.md
2026-06-25 15:58:23 +08:00

480 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前端面试题:地图路线规划 + 轨迹动画系统
## 题目描述
在 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`