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

18 KiB
Raw Blame History

前端面试题:地图路线规划 + 轨迹动画系统

题目描述

在 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) ← 注册一次性点击监听

关键代码:

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, -32zIndex215高于护理员和起点

阶段 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.inforesult.messagereject()
'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

期望回答:

  • rAF 60fps → 250 点只需 4 秒(太快)
  • rAF 帧率不稳定(后台标签页降到 1fps→ 动画时间不可预测
  • setInterval(40) 稳定 25fps后台自动暂停浏览器节流回来时从当前位置继续
  • 需要配合进度条时需要可知的总时长,setInterval 更可控

追问 3startAnimation 中为什么要 await addCaregiver()

期望回答: addCaregiver() 内部调用 loadAMap() 是异步的。如果护理员不存在,需要等标记创建完成再开始动画。await 确保 caregiverMarker.value 已被赋值,后续 setInterval 中才能正常调用 setPosition

追问 4如何让动画支持「可调速」

期望回答: 将 40ms 提取为变量 speed,暴露给外部。速度加倍 = interval 减半:

const speedMap = { '1x': 40, '2x': 20, '4x': 10 }
// 或动态计算,进度条相应调整

追问 5为什么 originPointreforiginMarkershallowRef

期望回答:

  • originPoint 是纯数据 [number, number],需要响应式以驱动 canPlanRoute 计算属性和 Driving API 调用
  • originMarker 是 AMap 实例对象,内部结构庞大且不应被 Vue 深度代理
  • shallowRef 只监听 .value 的替换(m = new AMap.Marker(...)m = null),不追踪内部属性变化
  • 标记的位置更新通过 marker.setPosition() 直接操作 AMap 原生 API不需要 Vue 参与

追问 6clearRoute() 里为什么要先 stopAnimation() 再删折线?

期望回答: stopAnimation 中会调用 caregiverMarker.value?.setPosition()。清理顺序必须是:

  1. stopAnimation() — 先停 setInterval,确保不再操作任何地图对象
  2. map.remove(routePolyline) — 再安全删除路线折线
  3. 重置所有 ref — 最后清理响应式状态

这个顺序保证了"先停用,再删除,最后置空",避免 setInterval 回调在对象删除后仍然触发 setPosition