# 前端面试题：高德地图 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' }
```
