# 前端面试题：高德地图"我的位置"功能完整实现

## 题目描述

在一个 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。
