高德地图添加轨迹规划和重放功能
This commit is contained in:
469
docs/interview-question-my-location.md
Normal file
469
docs/interview-question-my-location.md
Normal 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, // 缓存有效期(ms),0 = 强制重新获取
|
||||
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 用户疯狂点击 "我的位置" → 按钮 disabled(mapInstance 为 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.geolocation(GPS,高精度)
|
||||
↓ 失败/不支持
|
||||
AMap.Geolocation(IP 定位,低精度,但一定能拿到城市级位置)
|
||||
↓ 也失败
|
||||
默认位置(北京天安门) + 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。
|
||||
Reference in New Issue
Block a user