Files
microapp-vue3-interview/docs/interview-question-my-location.md

470 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前端面试题:高德地图"我的位置"功能完整实现
## 题目描述
在一个 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。