# 前端面试题:地图路线规划 + 轨迹动画系统 ## 题目描述 在 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 { 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 实例,用于地图渲染;用 `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 { // 🔧 容错:护理员不存在 → 自动在起点创建 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`。