feat: 面试题更新

This commit is contained in:
2026-06-25 15:58:23 +08:00
parent 98d1cebd20
commit 7e70c18f7e
3 changed files with 853 additions and 275 deletions

View File

@@ -39,6 +39,266 @@ useRouteTrack(mapInstance)
---
## 起始点路径规划 — 完整实现链路
整个"画起始点 → 规划路线"功能由 **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. 路径下采样 — 丝滑动画的秘密
@@ -163,6 +423,8 @@ enterSetMode('origin' | 'dest')
| **交互设计** | 模式切换 + 点击地图放标记 | ⭐⭐ |
| **容错设计** | 护理员缺失时自动创建 | ⭐ |
| **资源管理** | setInterval 清理 + 事件解绑 | ⭐⭐ |
| **级联清理** | 删除起点/终点时自动清除路线,避免僵尸数据 | ⭐⭐ |
| **状态双轨制** | 坐标层ref驱动逻辑 vs 标记层shallowRef驱动渲染 | ⭐⭐⭐ |
| **调试** | INVALID_USER_SCODE 问题排查 | ⭐⭐⭐⭐ |
## 加分项
@@ -198,3 +460,20 @@ enterSetMode('origin' | 'dest')
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`