高德地图添加轨迹规划和重放功能

This commit is contained in:
2026-06-25 10:30:45 +08:00
parent 0089e5c107
commit 91afc9d81c
7 changed files with 2740 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
# 前端面试题:高德地图 JSAPI 加载机制与安全密钥
## 题目描述
在 Vue 3 + Vite 项目中,使用 `@amap/amap-jsapi-loader` 动态加载高德地图 JSAPI 2.0。要求:
1. **全局只加载一次** — 多个组件同时调用不会重复下载 ~600KB 脚本
2. **加载失败可重试** — 不缓存失败的 Promise
3. **安全密钥按需配置** — 2021/12/02 后申请的 Key 必须配置 `securityJsCode`
4. **安全密钥必须在脚本加载之前设置** — 否则 Driving/Geocoder 等服务报 `INVALID_USER_SCODE`
---
## 完整源码与解析
### 1. 配置文件
```ts
// config/amap.ts
export const AMAP_JSAPI_KEY = import.meta.env.VITE_AMAP_JSAPI_KEY as string
export const AMAP_WEB_KEY = import.meta.env.VITE_AMAP_WEB_KEY as string
export const AMAP_SECURITY_CODE = import.meta.env.VITE_AMAP_SECURITY_CODE as string
export const AMAP_VERSION = '2.0'
export const AMAP_PLUGINS = [
'AMap.Geocoder', // 地理编码
'AMap.AutoComplete', // 输入提示
'AMap.PlaceSearch', // 搜索
'AMap.Geolocation', // 定位
'AMap.MarkerClusterer', // 点聚合
'AMap.Driving', // 驾车路线规划
] as const
```
### 2. 加载器 — 单例模式
```ts
// composables/useAmap.ts
import AMapLoader from '@amap/amap-jsapi-loader'
import { AMAP_JSAPI_KEY, AMAP_SECURITY_CODE, AMAP_VERSION, AMAP_PLUGINS } from '@/config/amap'
/** 全局 Promise 缓存 — 多次调用共享同一个下载 */
let amapPromise: Promise<typeof AMap> | null = null
/** 全局对象缓存 — 成功后直接返回,避免再次 await */
let AMapGlobal: typeof AMap | null = null
export async function loadAMap(): Promise<typeof AMap> {
// ① 已加载过 → 直接返回
if (AMapGlobal) return AMapGlobal
if (!amapPromise) {
// ② ⚠️ 必须在 JSAPI 脚本加载之前设置安全密钥
if (AMAP_SECURITY_CODE && !(window as any)._AMapSecurityConfig) {
;(window as any)._AMapSecurityConfig = {
securityJsCode: AMAP_SECURITY_CODE,
}
}
// ③ 发起加载
amapPromise = AMapLoader.load({
key: AMAP_JSAPI_KEY,
version: AMAP_VERSION,
plugins: [...AMAP_PLUGINS],
})
.then((amap) => {
AMapGlobal = amap // ④ 成功后缓存,后续调用瞬间返回
return amap
})
.catch((err) => {
amapPromise = null // ⑤ 失败后清空 → 允许重试
throw new Error(`高德地图 JSAPI 加载失败: ${err.message}`)
})
}
return amapPromise
}
```
---
## 核心知识点
### 知识点 ①:单例模式 — 避免重复加载
```
组件 A 组件 B 组件 C
│ │ │
├─ loadAMap() ├─ loadAMap() ├─ loadAMap()
│ amapPromise │ amapPromise │ amapPromise
│ = load() │ !== null │ !== null
│ (开始下载) │ (等待同一Promise)│ (等待同一Promise)
│ ↓ │ ↓ │ ↓
└─── await ──────┴─── await ──────┴─── await
AMapGlobal = amap (缓存)
后续任何调用if (AMapGlobal) return AMapGlobal → 瞬间返回
```
**两层缓存的作用:**
| 缓存层 | 类型 | 作用 |
|--------|------|------|
| `amapPromise` | `Promise<AMap> \| null` | 并发调用去重 — A、B、C 同时调用时,共享同一个下载 Promise |
| `AMapGlobal` | `typeof AMap \| null` | 跨时间缓存 — 下载完成后,任意时刻再调用直接返回对象 |
### 知识点 ②:失败重试
```ts
.catch((err) => {
amapPromise = null // ← 清空 Promise下次调用重新下载
throw new Error(...)
})
```
如果不置 `null`,失败的 Promise 会被缓存,后续调用拿到的是同一个 rejected Promise永远无法恢复。
### 知识点 ③:安全密钥的时序约束
```
✅ 正确顺序:
window._AMapSecurityConfig = { securityJsCode: 'xxx' }
<script src="https://webapi.amap.com/maps?v=2.0&key=..."></script>
AMap.Driving 等服务可用
❌ 错误顺序(写在 AMapLoader.load 之后):
<script src="..."></script> ← JSAPI 已加载,安全配置窗口关闭
window._AMapSecurityConfig = { ... } ← 无效Driving 等服务报 INVALID_USER_SCODE
```
**为什么必须在此之前?** 高德 JSAPI 脚本加载时读取 `_AMapSecurityConfig` 并完成内部初始化。脚本加载后该配置窗口关闭,之后再设置不会生效。
### 知识点 ④:`as const` 插件列表
```ts
export const AMAP_PLUGINS = [
'AMap.Geocoder',
// ...
] as const
```
`as const` 将数组类型收窄为 `readonly` 字面量元组,提供:
- 类型安全:`[...AMAP_PLUGINS]` 展开时保留精确字面量类型
- 防止意外修改:插件列表不应在运行时被 push/pop
- Tree-shaking 友好Vite 可以更好地优化
### 知识点 ⑤:环境变量前缀 `VITE_`
```
.env 文件中:
VITE_AMAP_JSAPI_KEY=xxx ← ✅ Vite 会暴露给客户端
AMAP_JSAPI_KEY=xxx ← ❌ 客户端 import.meta.env 读不到
```
Vite 只暴露 `VITE_` 前缀的环境变量,防止敏感信息泄漏。这是 Vite 的安全设计。
---
## INVALID_USER_SCODE 排查流程
```
[护理员] ❌ Driving 返回错误: 未知错误 INVALID_USER_SCODE
```
**排查清单:**
| 检查项 | 怎么做 |
|--------|--------|
| ① Key 申请时间 | 2021/12/02 之后申请的 Key 必须配置安全密钥 |
| ② `_AMapSecurityConfig` 时机 | 必须在 `AMapLoader.load()` **之前**设置 |
| ③ JSAPI Key vs 安全密钥 | 是两个不同的值,都在 AMap 控制台查看 |
| ④ 密钥拼写 | `securityJsCode`(注意大小写)不能有空格 |
| ⑤ 环境变量 | `.env``VITE_AMAP_SECURITY_CODE=xxx`,重启 dev server |
| ⑥ 条件加载 | 如果没有安全密钥,不设置 config老 Key 不需要) |
---
## 三种 AMap 密钥的区别
| | JSAPI Key | Web Service Key | 安全密钥 |
|---|---|---|---|
| 环境变量 | `VITE_AMAP_JSAPI_KEY` | `VITE_AMAP_WEB_KEY` | `VITE_AMAP_SECURITY_CODE` |
| 用途 | 地图展示、标记、折线 | 服务端 API地理编码等 | 前端安全验证 |
| 使用方式 | `AMapLoader.load({ key })` | 后端 fetch 请求 | `window._AMapSecurityConfig` |
| 暴露风险 | 前端可见(无法避免) | **应仅在后端使用** | 前端可见(建议代理) |
| 影响范围 | 无此 Key 地图无法加载 | 无此 Key 后端 API 不可用 | 2021/12 后新 Key 不配则 Driving/Geocoder 报错 |
---
## 考察点汇总
| 层级 | 考察点 | 难度 |
|------|--------|------|
| **设计模式** | 单例模式 + 两层缓存Promise + 对象) | ⭐⭐⭐ |
| **错误处理** | 失败清空 Promise 允许重试 | ⭐⭐ |
| **时序约束** | `_AMapSecurityConfig` 必须在脚本加载之前 | ⭐⭐⭐⭐ |
| **Vite** | `VITE_` 前缀的环境变量暴露机制 | ⭐⭐ |
| **TypeScript** | `as const` 字面量类型收窄 | ⭐⭐ |
| **调试** | INVALID_USER_SCODE 的系统化排查 | ⭐⭐⭐⭐ |
## 追问方向
### 追问 1为什么不直接在 `index.html` 中加 `<script>` 标签加载 AMap
**期望回答:**
- 按需加载:仅在访问地图页面时才下载 600KB 脚本,首页不需要
- 插件管理:通过 AMapLoader 统一管理插件版本
- 微前端兼容:子应用独立控制加载时机,避免主应用 `<script>` 影响
- 安全密钥时序:`AMapLoader.load()` 之前可以执行任意 JS 设置 `_AMapSecurityConfig`
### 追问 2如果多个子应用都需要 AMap`AMapGlobal` 会冲突吗?
**期望回答:** 如果多个子应用共用同一个域名,`AMapGlobal` 是模块级变量,在同一 JS 上下文中共享。如果子应用用 iframe 沙箱隔离(本项目的 `vue3-app` 配置了 `iframe: true`),则每个 iframe 有独立的 JS 上下文,各自加载自己的 AMap不会冲突。
### 追问 3`window._AMapSecurityConfig` 中还有一个 `serviceHost` 字段是干什么的?
**期望回答:** 用于代理模式。生产环境中不应把安全密钥写在前端代码里,可以通过 Nginx 反向代理转发:
```nginx
location /_AMapService/ {
set $args "$args&jscode=真实密钥";
proxy_pass https://restapi.amap.com/;
}
```
前端只需配置代理地址而不暴露密钥:
```ts
window._AMapSecurityConfig = { serviceHost: '/_AMapService' }
```

View File

@@ -0,0 +1,396 @@
# 前端面试题:养老场景电子围栏 — 绘制 + 越界检测 + 声光报警
## 题目描述
在 Vue 3 + 高德地图的微前端子应用中,实现一个**养老院/社区电子围栏**系统:
```
┌──────────────────────────────────────────────┐
│ sub-app-header │
├──────────────────────────────────────────────┤
│ 🚨 电子围栏报警!(越界时全屏覆盖) │
│ ✅ 老人在安全区域内 (状态标签) │
│ │
│ 🗺️ 高德地图 │
│ [绿色多边形围栏] + [👴 可拖拽老人] │
│ │
├──────────────────────────────────────────────┤
│ 🗺️ 电子围栏 [🔷绘制围栏][⭕圆形围栏][🗑️清除] │
│ 🎯 绘制提示条(顶点计数 + 闭合提示) │
├──────────────────────────────────────────────┤
│ 👴 老人控制 [➕添加][↑←→↓方向键][🗑️移除] │
├──────────────────────────────────────────────┤
│ 📋 报警记录(右侧浮层) │
└──────────────────────────────────────────────┘
```
**核心要求:**
1. 支持**多边形**和**圆形**两种围栏绘制
2. 老人标记可拖拽 + 方向键移动,每次移动**即时检测**越界
3. 越界时触发**声光报警**:围栏变红、全屏浮层、蜂鸣音、报警记录
4. 老人回到围栏内**自动解除**报警
5. 组件卸载时**完整清理**所有地图覆盖物和事件监听
---
## 架构设计
### 文件分层
```
microapp-vue3/src/
├── composables/
│ ├── useAmap.ts ← 地图实例管理(单例加载 JSAPI、生命周期
│ └── useGeofence.ts ← 电子围栏核心逻辑(围栏、标记、检测、报警)
├── views/
│ └── MapView.vue ← 视图层(布局、按钮、报警 UI
├── config/amap.ts ← 密钥、版本、插件列表
└── vite-env.d.ts ← AMap 类型声明Polygon/Circle/Marker/GeometryUtil...
```
**关键设计决策:逻辑与视图分离。** `useGeofence` composable 封装所有地图操作,`MapView.vue` 只负责 UI 渲染和事件绑定。好处:
- 围栏逻辑可在其他页面复用(如多围栏管理页)
- 单元测试只需测 composable无需挂载 Vue 组件
- 视图层更换 UI 框架(如 Element Plus 面板)不影响核心逻辑
---
## 核心技术点拆解
### 1. 围栏绘制 — 交互设计与踩坑
#### 多边形绘制流程
```
点击「🔷 绘制围栏」
→ map.setStatus({ doubleClickZoom: false }) ← 关键!禁用双击缩放
→ 注册 map.on('click', onClick)
→ 光标变 crosshair
每次单击:
├─ 地理距离 < 30m 且 已有点 ≥ 3 → 闭环完成 ✅(点击首顶点)
├─ 否则 → 添加顶点
│ ├─ new AMap.Marker编号圆点
│ ├─ new AMap.Polyline虚线预览
│ └─ 点数 ≥ 3→ 销毁首顶点 → 重建为红色脉冲高亮标记
└─ 右键 → cancelDraw()
闭环(三种方式):
├─ 点击红色脉冲的首顶点Haversine < 30m
├─ 点击「✅ 闭合围栏N点」按钮
└─ 创建 AMap.Polygon → 清理绘制中间状态 → 恢复 doubleClickZoom
```
#### 圆形绘制流程
```
点击「⭕ 圆形围栏」
→ 第 1 次点击:确定圆心(红色标记)
→ map.on('mousemove')动态预览圆形Haversine 实时计算半径)
→ 第 2 次点击:确定半径
→ 清理预览 Circle → 创建正式 Circle 围栏
→ 右键:取消
```
#### ⚠️ 踩坑记录(面试中最有区分度)
| 踩坑 | 现象 | 根因 | 修复 |
|------|------|------|------|
| **双击闭合** | 双击后地图缩放 + 多了 2 个废顶点 | AMap 默认 `doubleClickZoom: true` 拦截双击;双击 = click+click+dblclick | `map.setStatus({ doubleClickZoom: false })` + 改用点击首顶点闭合 |
| **`lngLatToContainer({lng,lat})`** | 第 4 次点击报 `Pixel(NaN, NaN)` | AMap v2 不接受普通对象,只接受 `[lng,lat]``LngLat` 实例 | 用 **Haversine 地理距离**替换像素距离判断,不再调用此方法 |
| **`marker.setContent()`** | 更新标记内容后内部状态损坏 → NaN | AMap v2 的 `setContent` 触发重新布局,新旧内容尺寸不同时偏移计算异常 | 改为 **destroy + new Marker** 重建,确保内部状态干净 |
| **`e.pixel` 不可靠** | 偶发 NaN | 某些事件回调中 `e.pixel` 可能未被填充 | 一律使用 `e.lnglat` + Haversine不依赖像素坐标 |
| **scoped CSS 无效** | 自定义 Marker 样式不生效 | AMap 注入的 DOM 不带 Vue 的 `data-v-xxx` 属性 | 新增**非 scoped** 的 `<style>` 块,用全局 class 选择器 |
---
### 2. 老人标记管理
```ts
// 创建HTML content + draggable + dragend 自动检测
const marker = new AMap.Marker({
position,
content: '<div class="geofence-person-marker">👴</div>', // ← CSS class非 inline
offset: new AMap.Pixel(-17, -17), // 34×34 居中
draggable: true, // 允许拖拽
zIndex: 200, // 高于围栏(100)和顶点(120)
})
marker.on('dragend', () => {
// 每次拖拽结束 → 更新位置 → 立即检测越界
const pos = marker.getPosition()
personPosition.value = [pos.lng, pos.lat]
checkBoundary()
})
```
**两种移动方式:**
| 方式 | 实现 | 步长 | 适用场景 |
|------|------|------|----------|
| 拖拽 | `draggable: true` + `dragend` 事件 | 任意 | 手动测试、演示 |
| 方向键 | `movePerson(direction)` ± 0.0005° | ~30-50m | 精确步进测试 |
**为什么步长用 0.0005° 而非固定米数?** 经纬度步长简单可靠,不依赖地图投影。对于养老场景的社区围栏(通常 100-500m 范围),像素级精度足够。
---
### 3. 越界检测 — 双算法兜底
```ts
function checkBoundary(): void {
const pp = personPosition.value
if (!pp || !fenceOverlay.value) return
let inside: boolean
if (fenceType.value === 'circle') {
// 圆形AMap 内置 contains() 方法
inside = (fenceOverlay.value as AMap.Circle).contains(pp)
} else {
// 多边形:优先 AMap.GeometryUtil兜底射线法
try {
inside = AMap.GeometryUtil.isPointInRing(
{ lng: pp[0], lat: pp[1] },
fenceVertices.value
)
} catch {
inside = isPointInPolygon(pp, fenceVertices.value) // 纯 JS 兜底
}
}
// 状态变迁检测:只在"进入→出去"时报警,"出去→进入"时解除
const wasInside = isInside.value
isInside.value = inside
if (!inside && wasInside) triggerAlarm(pp) // 刚刚越界
if (inside && !wasInside) resetAlarm() // 刚刚回归
}
```
**为什么需要兜底?** `AMap.GeometryUtil` 在某些加载顺序下可能未就绪JSAPI 异步加载)。射线法作为纯数学算法始终可用:
```
射线法Ray Casting从目标点向右发射一条水平线
→ 统计与多边形各边的交点数
→ 奇数 = 内部,偶数 = 外部
```
**状态变迁检测的设计意图:** 不直接用 `if (!inside) alert()`,而是追踪 `wasInside → isInside` 的变化。这样:
- 持续在围栏外 → 不重复报警
- 出去再进来再出去 → 每次跨越边界都报警(累计次数 + 历史记录)
---
### 4. 报警系统 — 五通道并行的设计模式
越界时**同时触发 5 个通道**,每个通道独立可关闭:
```
triggerAlarm()
├─ 通道 1视觉-围栏
│ updateFenceStyle(true)
│ 绿色实线 → 红色虚线fillColor/strokeColor 切换)
├─ 通道 2视觉-全屏浮层
│ alarmActive = true → v-if 渲染
│ 全屏红色脉冲背景 + 震动弹窗 + 脉冲图标
│ 点击任意处 → dismissAlarm()
├─ 通道 3视觉-状态标签
│ fence-status--safe → fence-status--alarm
│ "✅ 安全" → "🚨 已越界!"
├─ 通道 4听觉-蜂鸣
│ startAlarmSound()
│ Web Audio API → OscillatorNode 方波 880Hz
│ 每 800ms 蜂鸣一次setInterval
│ 0.3 秒 duration + exponentialRampToValueAtTime 淡出
└─ 通道 5记录-历史
alarmHistory.push({ time, position })
右侧浮层实时展示
```
**`updateFenceStyle` 的兼容处理:**
```ts
// AMap 不同版本可能没有 setOptions兜底用 Object.assign
poly.setOptions?.(style) || Object.assign(poly as any, style)
```
---
### 5. 响应式设计 — `shallowRef` 的正确用法
```ts
// ✅ 正确shallowRef — 只在引用替换时触发更新
const fenceOverlay = shallowRef<AMap.Polygon | AMap.Circle | null>(null)
const personMarker = shallowRef<AMap.Marker | null>(null)
// ❌ 错误ref — 会深度追踪 AMap 实例的数千个内部属性
// const fenceOverlay = ref<AMap.Polygon | null>(null)
// → setCenter/setZoom 等操作触发深度 diff → 性能灾难
```
| 存储方式 | 适用场景 | 代价 |
|----------|----------|------|
| `shallowRef` | 地图实例、覆盖物(内部状态由 AMap 自行管理) | 无性能损耗 |
| `ref` | 简单值(坐标数组、布尔、计数) | 需要响应式追踪 |
| `computed` | 派生状态(`hasFence``hasPerson` | 自动缓存 |
---
### 6. 生命周期清理 — 避免内存泄漏的完整链条
```ts
onUnmounted(() => {
destroyGeofence()
stopAlarmSound() // 清理 setInterval + Oscillator
clearFence() // map.remove(polygon/circle) + 重置状态
removePerson() // map.remove(marker) + 停止报警
cancelDraw() // 清理预览线/顶点/事件监听 + 恢复 doubleClickZoom
})
```
**事件监听的清理机制:**
```ts
// 绑定:将 handler 引用存储到 map 实例的自定义属性上
;(map as any)._geofenceClick = onClick
map.on('click', onClick)
// 清理:通过引用精确 off避免误删其他监听器
const m = map as any
if (m._geofenceClick) {
map.off('click', m._geofenceClick)
delete m._geofenceClick
}
```
---
## 完整调用链路图
```
用户操作 → 电子围栏响应
═══════════════════════════════════════════
┌─ 绘制 ─────────────────────────────────────┐
│ [🔷绘制围栏] → startDrawPolygon() │
│ → map.setStatus({ doubleClickZoom:false }) │
│ → 逐一点击 → onClick(e) │
│ → 每点new Marker + new Polyline │
│ → ≥3点首顶点变红脉冲 │
│ → 点击首顶点 / [✅闭合] → finishDraw() │
│ → finishPolygonDraw() │
│ → new AMap.Polygon(path, FENCE_STYLE) │
│ → cleanupDrawing(map) │
│ → map.setStatus({ doubleClickZoom:true})│
└────────────────────────────────────────────┘
┌─ 老人 ─────────────────────────────────────┐
│ [➕添加老人] → addPerson() │
│ → new AMap.Marker({draggable:true}) │
│ → marker.on('dragend', checkBoundary) │
│ → checkBoundary() // 初始检测 │
│ │
│ [↑↓←→] / 拖拽 → movePerson() │
│ → marker.setPosition([lng, lat]) │
│ → checkBoundary() // 每次移动立即检测 │
└────────────────────────────────────────────┘
┌─ 检测 ─────────────────────────────────────┐
│ checkBoundary() │
│ ├─ 无围栏 → isInside = true (skip) │
│ ├─ 圆形 → Circle.contains(point) │
│ └─ 多边形 → GeometryUtil.isPointInRing() │
│ └─ 失败 → isPointInPolygon() │
│ └─ wasInside && !inside → triggerAlarm() │
│ └─ !wasInside && inside → resetAlarm() │
└────────────────────────────────────────────┘
┌─ 报警 ─────────────────────────────────────┐
│ triggerAlarm(position) │
│ ├─ alarmCount++ │
│ ├─ alarmHistory.push({time, position}) │
│ ├─ updateFenceStyle(true) // 围栏变红虚线 │
│ ├─ alarmActive = true // 浮层出现 │
│ └─ startAlarmSound() // 蜂鸣开始 │
│ │
│ dismissAlarm() / resetAlarm() │
│ ├─ alarmActive = false │
│ ├─ updateFenceStyle(false) // 恢复绿色 │
│ └─ stopAlarmSound() // 蜂鸣停止 │
└────────────────────────────────────────────┘
```
---
## 考察点汇总
| 层级 | 考察点 | 难度 |
|------|--------|------|
| **架构设计** | Composable 模式:逻辑/视图分离、单一职责 | ⭐⭐⭐ |
| **响应式** | `shallowRef` vs `ref` vs `computed` 的选择理由 | ⭐⭐ |
| **几何算法** | 射线法Ray Casting原理 + Haversine 公式 | ⭐⭐⭐⭐ |
| **地图 SDK 踩坑** | `doubleClickZoom``lngLatToContainer` 参数格式、`setContent` 副作用 | ⭐⭐⭐⭐⭐ |
| **交互设计** | 三种闭环方式(点击首顶点/按钮/右键取消)的 UX 考量 | ⭐⭐⭐ |
| **事件管理** | 动态事件绑定/解绑、闭包捕获、内存泄漏防范 | ⭐⭐⭐ |
| **Web Audio API** | OscillatorNode + GainNode + exponentialRampToValueAtTime | ⭐⭐⭐ |
| **生命周期** | `onUnmounted` 中 4 步清理链(音效→围栏→标记→绘制态) | ⭐⭐ |
| **CSS 隔离** | scoped vs 全局样式在 AMap 自定义标记中的应用 | ⭐⭐⭐ |
| **状态机** | wasInside/inside 变迁检测避免重复报警 | ⭐⭐ |
---
## 加分项
- **能解释为什么不用 `AMap.MouseTool` 而手动处理 click 事件** — 更细粒度的交互控制(首顶点高亮、实时顶点计数、三通道闭环)
- **能指出 `setContent` 的替代方案** — destroy + new Marker 重建,或使用 `setOptions`(如果 SDK 支持)
- **能说明 `lngLatToContainer` 的参数格式陷阱** — AMap v2 只接受 `[lng, lat]` 数组或 `LngLat` 实例,不接受 `{lng, lat}` 普通对象
- **能提出改进方向**
- 支持多个围栏 + 多个老人(一对多/多对多关系)
- 使用 `watchPosition` 替代 `getCurrentPosition` 实现实时 GPS 追踪
- 报警信息通过 WebSocket 推送到护工端
- 围栏数据持久化localStorage/后端)+ 编辑已有围栏的顶点
- 使用 AMap 的 `PolygonEditor` 插件支持围栏二次编辑
- **能分析 `movePerson` 的步长选择** — 0.0005° 约 30-50 米的经纬度换算
---
## 追问方向
### 追问 1如果围栏有 100 个顶点,`isPointInPolygon` 的性能如何?
**期望回答:** 射线法时间复杂度 O(n)n 为顶点数。100 个顶点约 0.01ms。但养老场景围栏通常 4-20 个顶点,性能完全不是瓶颈。如果真有数千顶点,可以先用 bounding box 做粗筛。
### 追问 2圆形围栏的 `contains()` 精度取决于什么?
**期望回答:** `AMap.Circle.contains()` 基于球面几何计算,精度受限于:
1. 地球半径常量AMap 使用 6378137m
2. 高纬度地区 Mercator 投影变形
3. 极小的圆(<1m可能受浮点精度影响
对于养老场景(通常 100-1000m 半径),精度完全够用。
### 追问 3`alarmActive` 为什么用 `ref` 而不用 `shallowRef`
**期望回答:** `alarmActive` 是布尔值,不是对象。`ref(true)``shallowRef(true)` 对原始值行为完全相同——Vue 对原始类型不会深度追踪。但用 `ref` 语义更清晰("这是一个需要响应式的值")。
### 追问 4如果老人恰好在围栏边界线上`isPointInRing` 返回什么?
**期望回答:** 取决于浮点精度和算法实现。射线法在边界线上行为不确定(可能 true 也可能 false。生产环境应该在边界 ±2m 加一个模糊区间hysteresis避免在边界反复触发报警/解除。
### 追问 5`startAlarmSound` 中为什么要 try-catch
**期望回答:**
1. 部分浏览器(如 iOS Safari要求 AudioContext 必须在用户手势中创建
2. 某些企业环境可能禁用 Web Audio API
3. setInterval 可能被浏览器节流(后台标签页降至 1s
4. 静默失败比页面崩溃更优雅——视觉报警仍然工作
### 追问 6为什么不在 `drag` 事件中检测,而是 `dragend`
**期望回答:** `drag` 事件每像素触发一次,拖拽 100px 就触发 100 次 `checkBoundary`,其中 99 次是浪费。`dragend` 只触发一次,刚好够。"边界穿越"在拖拽中段发生还是在终点发生,对报警结果无影响——报警只需要知道"最终位置是否在围栏外"。

View File

@@ -0,0 +1,242 @@
# 前端面试题全屏三区布局header + 地图 + 操作栏)且无滚动条
## 题目描述
你需要实现一个**微前端子应用**的地图页面,布局要求如下:
```
┌────────────────────────────────┐
│ sub-app-header │ ← 高 48px始终可见
│ 🟢 Vue3 子应用 [首页][关于][地图] │
├────────────────────────────────┤
│ │
│ │
│ 🗺️ 高德地图 │ ← 占满剩余空间
│ │
│ │
├────────────────────────────────┤
│ 🗺️ 高德地图 [📍回到默认] [🎯我的位置] │ ← 高 52px始终可见
└────────────────────────────────┘
```
**核心要求:**
1. 页面**不能出现滚动条**,三个区域必须同时完整显示在视口内
2. 地图区域需正确渲染(高德地图 JSAPI 要求容器有明确的像素尺寸)
3. 该应用既要能在**微前端环境**中嵌入运行,也要能**独立运行**
---
## 初始代码(有问题)
```vue
<!-- App.vue -->
<template>
<div id="microapp-vue3-container">
<header class="sub-app-header">
<h1>🟢 Vue3 子应用</h1>
<nav>
<router-link to="/">首页</router-link>
<router-link to="/about">关于</router-link>
<router-link to="/map">地图</router-link>
</nav>
</header>
<main class="sub-app-main">
<router-view />
</main>
</div>
</template>
<style>
body {
margin: 0;
}
#microapp-vue3-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: #fafafa;
}
.sub-app-header {
height: 48px;
flex-shrink: 0;
/* ...其他样式省略 */
}
.sub-app-main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
</style>
```
```vue
<!-- MapView.vue 地图页面 -->
<template>
<div class="map-page">
<div v-if="loading" class="map-status"> 地图加载中...</div>
<div v-else-if="error" class="map-status map-error"> {{ error }}</div>
<div ref="containerRef" class="map-container" />
<div class="map-controls">
<span>🗺 高德地图</span>
<div>
<button @click="resetView">📍 回到默认位置</button>
<button @click="getCurrentPosition">🎯 我的位置</button>
</div>
</div>
</div>
</template>
<style scoped>
.map-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.map-container {
flex: 1;
min-height: 0;
}
.map-controls {
flex-shrink: 0;
/* ... */
}
</style>
```
**问题:** 页面出现了垂直滚动条,地图也没有正确渲染。
---
## 考察知识点
| 层级 | 知识点 | 具体体现 |
|------|--------|----------|
| **CSS 盒模型** | `height: 100%` 的继承链 | 百分比高度必须逐级传递,任意一环缺失都会断裂 |
| **Flexbox** | `flex: 1``min-height: 0` | flex 子项默认 `min-height: auto`,可能导致溢出 |
| **溢出控制** | `overflow` 的层级管理 | 在哪一层阻止溢出、哪一层允许滚动需要精心设计 |
| **第三方库适配** | 地图 SDK 的容器约束 | AMap 要求容器有明确像素高度flex 分配的隐式高度可能不生效 |
| **微前端** | 子应用的挂载点与视口 | `100vh` vs `100%` 在不同环境下的差异 |
---
## 期望答案要点
### 1. 修复百分比高度继承链(关键)
```
初始html → body → #root无高度→ #container100vh ❌ 链断裂
修复html → body → #root → #container全部 100% ✅ 完整链路
```
```css
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden; /* 根级截断,杜绝页面级滚动条 */
}
#microapp-vue3-root {
height: 100%; /* 关键:挂载点也需撑满 */
}
#microapp-vue3-container {
height: 100%; /* 用 % 而非 vh适配微前端环境 */
overflow: hidden;
}
```
**为什么 `100vh` 不够好?**
- 移动端浏览器地址栏收起/展开会改变 `100vh` 的实际值
- 微前端环境中,子应用容器不一定等于视口高度,`100%` 跟随父容器更稳健
### 2. flex 布局的溢出收缩
```css
.sub-app-main {
flex: 1;
min-height: 0; /* 允许收缩到内容以下 */
display: flex;
flex-direction: column;
}
.map-page {
flex: 1;
min-height: 0; /* 同上 */
overflow: hidden; /* 阻止地图页面自身溢出 */
}
```
**关键原理:** flex item 默认 `min-height: auto`,不允许收缩到内容高度以下;设为 `0` 后才真正服从 flex 分配。
### 3. AMap 容器必须有明确的尺寸
```css
.map-container {
flex: 1;
width: 100%;
min-height: 0;
}
/* 避免display: none 导致容器尺寸为 0 */
.map-hidden {
visibility: hidden; /* ✅ 保留占位,地图可正常初始化 */
/* display: none; */ /* ❌ 尺寸归零,地图无法渲染 */
}
```
配合 JS 端延迟 `resize`
```js
onMounted(async () => {
const map = await initMap()
if (map) {
nextTick(() => map.resize()) // 修正 flex 布局下的初始尺寸
}
})
```
### 4. 不同页面的滚动策略分离
地图页不需要滚动,但首页/关于页可能需要:
```css
.sub-app-main {
overflow-y: auto; /* 允许滚动 */
}
.map-page {
overflow: hidden; /* 地图页自己阻止溢出 */
/* flex: 1 意味着它恰好填满父容器,不会触发父级的 overflow-y */
}
```
---
## 加分项
- 能说出 `display: none` vs `visibility: hidden` 对第三方库 DOM 测量的影响
- 能解释为什么在 `onMounted` 中调用 `map.resize()`,而不是 `onBeforeMount`
- 能分析 `resizeEnable: true` 的 AMap 配置与手动 `resize()` 的配合关系
- 能指出微前端场景下 `body { margin: 0 }` 不生效的边界情况(宿主页面的 body 不由子应用控制)
---
## 追问方向
1. **如果不用 flex还有什么方案** → grid、absolute + calc、vh 运算
2. **如何保证在其他页面(首页/关于页)的滚动正常?** → 仅在父级 `.sub-app-main``overflow-y: auto`,子页面不设 `overflow: hidden`
3. **`min-height: 0` 为什么是必须的?** → flex item 的隐含 `min-height: auto` 阻止收缩
4. **如果地图加载失败,当前布局会崩吗?** → 状态提示用绝对定位浮层,不占 flex 空间,不影响布局

View File

@@ -0,0 +1,469 @@
# 前端面试题:高德地图"我的位置"功能完整实现
## 题目描述
在一个 Vue 3 + 高德地图的微前端子应用中,实现**"我的位置"**功能:用户点击按钮后,地图自动定位到当前设备所在地理位置,并将缩放级别调到 15街道级
```
┌────────────────────────────────┐
│ sub-app-header │
│ 🟢 Vue3 子应用 [首页][关于][地图] │
├────────────────────────────────┤
│ │
│ 🗺️ 高德地图 │
│ (当前在北京天安门) │
│ │
├────────────────────────────────┤
│ 🗺️ 高德地图 [📍回到默认] [🎯 我的位置] │ ← 点击这个
└────────────────────────────────┘
点击 🎯 我的位置 →
① 检测浏览器是否支持定位
② 调用浏览器 Geolocation API
③ 提取经纬度
④ 移动地图中心 → 用户所在位置zoom = 15
```
**要求:**
1. 在地图实例创建完成后,按钮才可点击(未就绪时 `disabled`
2. 必须处理:浏览器不支持定位、用户拒绝授权、定位超时等异常
3. 地图实例通过 composable 管理,不能直接在组件中 `new AMap.Map()`
4. 地图需要支持微前端环境iframe 沙箱),注意权限策略差异
---
## 完整源码解读
### 1. 项目文件结构
```
microapp-vue3/src/
├── config/
│ └── amap.ts ← JSAPI 密钥、版本、插件列表
├── composables/
│ └── useAmap.ts ← 地图实例管理(单例加载、生命周期)
├── views/
│ └── MapView.vue ← 地图页面 + "我的位置" 功能
├── vite-env.d.ts ← AMap 全局类型声明
├── App.vue ← 子应用根组件header + router-view
├── main.ts ← 微前端生命周期mount/unmount
└── router/index.ts ← 路由定义
```
---
### 2. 配置文件 — 密钥与插件
```ts
// src/config/amap.ts
export const AMAP_JSAPI_KEY = import.meta.env.VITE_AMAP_JSAPI_KEY as string
export const AMAP_VERSION = '2.0'
export const AMAP_PLUGINS = [
'AMap.Geocoder', // 地理编码 / 逆地理编码
'AMap.AutoComplete', // 输入提示
'AMap.PlaceSearch', // 搜索服务
'AMap.Geolocation', // ← 定位插件(已加载但当前未使用)
'AMap.MarkerClusterer', // 点聚合
] as const
```
**关键细节:** `AMap.Geolocation` 是高德自己的定位插件(基于 IP + 基站),精度低于浏览器原生 GPS。**当前实现选择的是浏览器原生 `navigator.geolocation`,而非高德插件**——这是一个常见的架构决策点(见后文追问)。
---
### 3. 地图 Composable — 实例管理与单例加载
```ts
// src/composables/useAmap.ts
// ① 全局单例 — 避免多次加载 JSAPI 脚本(~600KB
let amapPromise: Promise<typeof AMap> | null = null
let AMapGlobal: typeof AMap | null = null
export async function loadAMap(): Promise<typeof AMap> {
if (AMapGlobal) return AMapGlobal // 缓存命中
if (!amapPromise) {
amapPromise = AMapLoader.load({
key: AMAP_JSAPI_KEY,
version: AMAP_VERSION,
plugins: [...AMAP_PLUGINS],
})
.then((amap) => {
AMapGlobal = amap
return amap
})
.catch((err) => {
amapPromise = null // ② 失败后可重试
throw new Error(`高德地图 JSAPI 加载失败: ${err.message}`)
})
}
return amapPromise
}
export function useAmap(options: AMap.MapOptions = {}) {
const containerRef = ref<HTMLDivElement | null>(null)
const mapInstance = shallowRef<AMap.Map | null>(null) // ③ 浅响应式
const loading = ref(false)
const error = ref<string | null>(null)
async function initMap(): Promise<AMap.Map | null> {
if (!containerRef.value) {
error.value = '地图容器不存在'
return null
}
loading.value = true
error.value = null
try {
const AMap = await loadAMap()
if (mapInstance.value) {
mapInstance.value.destroy() // ④ 先销毁旧实例
}
const defaultOptions: AMap.MapOptions = {
center: [116.397428, 39.90923], // 默认:北京天安门
zoom: 11,
viewMode: '3D',
resizeEnable: true, // ⑤ 窗口 resize 时自动重绘
}
mapInstance.value = new AMap.Map(containerRef.value, {
...defaultOptions,
...options, // ⑥ 调用方可覆盖默认值
})
return mapInstance.value
} catch (err: any) {
error.value = err.message || '地图初始化失败'
return null
} finally {
loading.value = false
}
}
function destroyMap(): void {
if (mapInstance.value) {
mapInstance.value.destroy()
mapInstance.value = null
}
}
onUnmounted(() => { destroyMap() }) // ⑦ 组件卸载自动清理
return { containerRef, mapInstance, loading, error, initMap, destroyMap }
}
```
**设计要点:**
| 编号 | 技术点 | 说明 |
|------|--------|------|
| ① | **模块级单例** | `loadAMap()` 的 Promise 缓存在模块作用域,多次调用 `useAmap()` 不会重复下载 JSAPI |
| ② | **失败重试** | catch 中将 `amapPromise``null`,下一次调用会重新加载 |
| ③ | **`shallowRef`** | 地图实例是复杂第三方对象,不需要深度响应式追踪,`shallowRef` 只在引用变化时触发更新,节省性能 |
| ④ | **销毁旧实例** | 如果多次调用 `initMap()`,先 `destroy()``new`,避免内存泄漏 |
| ⑤ | **`resizeEnable`** | 容器尺寸变化时自动调用 `resize()`,在 flex 布局中尤其重要 |
| ⑥ | **默认值合并** | 调用方传入的 options 覆盖默认值,支持自定义初始位置和缩放 |
| ⑦ | **生命周期绑定** | `onUnmounted` 确保组件销毁时释放地图资源 |
---
### 4. "我的位置" — 核心实现
```vue
<!-- src/views/MapView.vue -->
<template>
<div class="map-page">
<div ref="containerRef" class="map-container"
:class="{ 'map-hidden': loading || error }" />
<div class="map-controls">
<span class="map-title">🗺 高德地图</span>
<div class="map-actions">
<button @click="resetView" :disabled="!mapInstance">📍 回到默认位置</button>
<button @click="getCurrentPosition" :disabled="!mapInstance">🎯 我的位置</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, nextTick } from 'vue'
import { useAmap } from '@/composables/useAmap'
const { containerRef, mapInstance, loading, error, initMap } = useAmap({
center: [116.397428, 39.90923],
zoom: 12,
pitch: 30,
})
// ─────────────────────────────────────────
// 地图初始化
// ─────────────────────────────────────────
async function bootstrapMap() {
const map = await initMap()
if (map) {
nextTick(() => { map.resize() }) // flex 布局下修正地图尺寸
}
}
onMounted(() => { bootstrapMap() })
// ─────────────────────────────────────────
// 📍 回到默认视图
// ─────────────────────────────────────────
function resetView(): void {
const map = mapInstance.value
if (!map) return
map.setCenter([116.397428, 39.90923])
map.setZoom(12)
}
// ─────────────────────────────────────────
// 🎯 我的位置 — 核心功能
// ─────────────────────────────────────────
function getCurrentPosition(): void {
// ① 能力检测 — 浏览器是否支持 Geolocation API
if (navigator.geolocation) {
// ② 调用原生 API
navigator.geolocation.getCurrentPosition(
// ③ 成功回调 — 提取经纬度并移动地图
(pos) => {
const { longitude, latitude } = pos.coords
mapInstance.value?.setCenter([longitude, latitude])
mapInstance.value?.setZoom(15)
},
// ④ 失败回调 — 用户拒绝 / 超时 / 无法获取
(err) => {
alert(`定位失败: ${err.message}`)
}
)
} else {
// ⑤ 完全不支持 — HTTP 环境或老旧浏览器
alert('浏览器不支持定位')
}
}
</script>
```
---
## 核心知识点拆解
### 知识点 ①:`navigator.geolocation` vs `AMap.Geolocation`
| 维度 | `navigator.geolocation`(浏览器原生) | `AMap.Geolocation`(高德插件) |
|------|--------------------------------------|-------------------------------|
| **定位来源** | GPS + Wi-Fi + 基站 + IP | IP 定位 + 基站(无 GPS |
| **精度** | 高(室外可达 5-10 米) | 低(通常 100-500 米) |
| **授权弹窗** | 浏览器原生弹窗(信任度高) | 无弹窗(静默获取) |
| **HTTPS 要求** | **必须 HTTPS** 或 localhost | 无要求 |
| **加载方式** | 浏览器内置,无需加载 | 需加载高德 JSAPI + Geolocation 插件 |
| **使用场景** | "我的位置"精确导航 | 城市级粗略定位、IP 统计 |
**当前代码的架构决策:选择浏览器原生 API**。因为按钮文案是"我的位置",用户预期是精确的 GPS 定位,而非粗粒度的 IP 定位。
### 知识点 ②:`getCurrentPosition` 的参数与安全策略
```js
navigator.geolocation.getCurrentPosition(success, error, options)
```
**可选的第三个参数 `options`(当前未使用):**
```js
navigator.geolocation.getCurrentPosition(success, error, {
timeout: 10000, // 超时时间ms默认 Infinity
maximumAge: 60000, // 缓存有效期ms0 = 强制重新获取
enableHighAccuracy: true, // 高精度模式(启用 GPS默认 false
})
```
**如果面试者能指出缺失的 `options`,说明有实际落地经验:**
- 不设 `timeout` → 用户在室内可能永远等不到回调
- 不设 `enableHighAccuracy: true` → 浏览器可能只返回 IP 定位,精度很差
- 不设 `maximumAge` → 每次点击都重新定位,没有利用缓存
### 知识点 ③:微前端环境下的 Geolocation 权限
```
主应用 (microapp-main)
└─ <micro-app name="vue3-app" iframe>
└─ iframe → 子应用 (microapp-vue3)
```
当子应用运行在 **iframe 沙箱**中时(`subApps.ts``iframe: true``navigator.geolocation` 的行为:
- **Chrome / Edge**iframe 中的 `getCurrentPosition` 需要 iframe 的 `allow="geolocation"` 属性,否则直接触发错误回调
- **Firefox**:会向上询问用户授权,相对宽松
- **Safari**iframe 中基本无法获取位置
**主应用需要额外配置 iframe 的 Permissions Policy**
```html
<!-- 主应用中嵌入子应用的 iframe 需要声明 -->
<iframe allow="geolocation" src="..."></iframe>
<!-- 或通过 HTTP 响应头 -->
Permissions-Policy: geolocation=(self "http://localhost:3001")
```
**当前代码缺少这个配置**——这是一个真实的技术债,也是面试中考察微前端经验的好切入点。
### 知识点 ④:`shallowRef` 的地图实例存储
```ts
const mapInstance = shallowRef<AMap.Map | null>(null)
```
| 对比 | `ref()` | `shallowRef()` |
|------|---------|-----------------|
| 深度追踪 | ✅ 递归追踪内部属性 | ❌ 仅追踪 `.value` 的引用替换 |
| `map.setCenter()` 后 | 触发不必要的重渲染 | **不触发**(引用没变) |
| `map.setZoom()` 后 | 触发不必要的重渲染 | **不触发** |
| 性能 | 差(每次地图操作都 diff | 好(只有销毁/重建才触发) |
**关键原理:** 地图实例内部状态的变更(平移、缩放、标记点增删)都不应该触发 Vue 的响应式更新——地图自己管理自己的 DOM。`shallowRef` 正是为此场景设计。
### 知识点 ⑤:按钮的 `disabled` 守卫
```html
<button @click="getCurrentPosition" :disabled="!mapInstance">🎯 我的位置</button>
```
**防护链:**
```
mapInstance 为 null地图未就绪
→ button disabled → 无法点击 → getCurrentPosition 不会执行
→ 同时函数内部仍有 mapInstance.value?.setCenter() 的 ?. 守卫
```
这是**双重防护**
1. **UI 层守卫**`disabled` 阻止用户操作
2. **逻辑层守卫**`?.` 可选链防止异步竞态(例如在定位回调返回前地图被销毁)
### 知识点 ⑥:异步初始化窗口 — 竞态条件
```
用户操作时间线:
t=0 页面加载onMounted → initMap() 开始
t=50 initMap 还在加载 JSAPI...
t=100 用户疯狂点击 "我的位置" → 按钮 disabledmapInstance 为 null ✅ 被拦截
t=500 initMap 完成mapInstance 赋值
t=600 用户点击 "我的位置" → getCurrentPosition 执行 ✅ 正常工作
```
**如果没有 `:disabled="!mapInstance"`**
```
t=100 getCurrentPosition() 中的 mapInstance.value?.setCenter()
→ undefined?.setCenter() 不报错但什么也不做
→ 地图不动,用户困惑
```
---
## 完整调用链路图
```
用户点击 🎯 我的位置
├─ button :disabled="!mapInstance"
│ └─ mapInstance.value 是否为 null
│ ├─ null → 按钮灰色,事件不触发 【终止】
│ └─ 有值 → 继续
├─ getCurrentPosition()
│ │
│ ├─ ① navigator.geolocation 是否存在?
│ │ ├─ 不存在 → alert('浏览器不支持定位') 【终止】
│ │ └─ 存在 → 继续
│ │
│ ├─ ② navigator.geolocation.getCurrentPosition(success, error)
│ │ │
│ │ ├─ 浏览器弹出授权弹窗:"localhost 想要获取您的位置"
│ │ │ ├─ 用户拒绝 → error({ code: 1, message: "User denied" })
│ │ │ │ └─ alert('定位失败: User denied Geolocation') 【终止】
│ │ │ └─ 用户允许 → 浏览器开始获取位置
│ │ │ ├─ 超时/信号弱 → error({ code: 3, message: "Timeout" })
│ │ │ │ └─ alert('定位失败: Timeout expired') 【终止】
│ │ │ └─ 成功 → success(pos)
│ │ │ │
│ │ │ ├─ ③ const { longitude, latitude } = pos.coords
│ │ │ │ └─ pos.coords 还包含:
│ │ │ │ · accuracy精度
│ │ │ │ · altitude海拔
│ │ │ │ · heading方向角
│ │ │ │ · speed速度m/s
│ │ │ │
│ │ │ ├─ ④ mapInstance.value?.setCenter([lng, lat])
│ │ │ │ └─ 地图中心平移到用户位置
│ │ │ │
│ │ │ └─ ⑤ mapInstance.value?.setZoom(15)
│ │ │ └─ 缩放到街道级(默认 zoom=12 是城市级)
│ │ │
│ │ └─ 注意:当前实现没有传 options 参数!
│ │ 建议补充 { timeout, enableHighAccuracy, maximumAge }
```
---
## 考察点汇总
| 层级 | 考察点 | 难度 |
|------|--------|------|
| **API 选型** | `navigator.geolocation` vs `AMap.Geolocation` 的取舍 | ⭐⭐⭐ |
| **响应式** | 为何用 `shallowRef` 而非 `ref` 存储地图实例 | ⭐⭐ |
| **异步安全** | `disabled` + `?.` 双重守卫防止竞态 | ⭐⭐ |
| **单例模式** | JSAPI 加载的模块级缓存与失败重试 | ⭐⭐⭐ |
| **微前端** | iframe 沙箱中 geolocation 的 Permissions Policy | ⭐⭐⭐⭐ |
| **安全策略** | HTTPS 要求、授权弹窗、用户拒绝处理 | ⭐⭐ |
| **生命周期** | `onUnmounted` 销毁地图、`onMounted` 初始化 | ⭐ |
| **错误处理** | 三层 fallback浏览器不支持 → 授权拒绝 → 超时 | ⭐⭐ |
| **用户体验** | 缺少 loading 状态、缺少定位失败后的降级 UI | ⭐⭐⭐ |
---
## 加分项
- **能说出 `getCurrentPosition``options` 三个参数及其合理默认值**
- **能指出 iframe 沙箱下 geolocation 需要主应用配合配置 `Permissions-Policy`**
- **能分析为何 `AMap.Geolocation` 已加载却未使用** — 精度不够,不适合"我的位置"场景
- **能提出改进方案**
- 优先用 `navigator.geolocation`高精度fallback 到 `AMap.Geolocation`(低精度但可用)
- 定位过程中按钮显示 loading 态(如 spinner + "定位中..."
- 定位成功后在地图上添加用户位置标记点Marker
-`watchPosition` 替代 `getCurrentPosition` 实现实时追踪
- **能指出 `pos.coords.accuracy` 可用于在地图上绘制精度圈**
---
## 追问方向
### 追问 1如果定位失败如何优雅降级
**期望回答:**
```
navigator.geolocationGPS高精度
↓ 失败/不支持
AMap.GeolocationIP 定位,低精度,但一定能拿到城市级位置)
↓ 也失败
默认位置(北京天安门) + Toast 提示
```
### 追问 2为什么当前代码没用 `AMap.Geolocation` 却加载了它的插件?
**期望回答:** 这是预留能力。插件列表是静态配置,加载后该功能即可用。如果未来需要定位功能(如 POI 搜索中的"附近"功能),不需要改配置重新加载 JSAPI。代价是首屏多加载 ~15KB 的插件代码——一个有意为之的权衡。
### 追问 3如果在 Vue 3 的 `watchEffect` 中调用 `map.setCenter()`,会发生什么?
**期望回答:** 如果 `mapInstance``ref()` 存储,`setCenter` 会改变地图内部状态Vue 的响应式系统会追踪到这些变化并触发 `watchEffect` 重新执行,形成死循环。`shallowRef` 避免了这个问题。
### 追问 4微前端 iframe 沙箱中 `navigator.geolocation` 失效的根本原因?
**期望回答:** 浏览器的 Permissions Policy原 Feature Policy要求跨域 iframe 必须显式声明 `allow="geolocation"` 属性。`@micro-zoe/micro-app` 的 iframe 沙箱创建的是同源 iframe但浏览器的权限模型仍然将其视为独立上下文需要主应用在 `<micro-app>` 标签或框架配置中透传该 permission。

View File

@@ -0,0 +1,200 @@
# 前端面试题:地图路线规划 + 轨迹动画系统
## 题目描述
在 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() ← 完整清理
```
---
## 核心技术点
### 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 清理 + 事件解绑 | ⭐⭐ |
| **调试** | 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 }
// 或动态计算,进度条相应调整
```