1931 lines
90 KiB
Markdown
1931 lines
90 KiB
Markdown
# 高德地图面试题
|
||
|
||
---
|
||
|
||
## Q1:如何在 Vue 3 中集成高德地图(AMap)?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
请你设计一个 Vue 3 组合式函数(Composable)来集成高德地图 JSAPI,要求:
|
||
|
||
1. 支持在多个页面中独立使用,但 SDK 脚本只下载一次
|
||
2. 安全密钥在脚本加载之前正确设置
|
||
3. 地图容器在 flex 布局中能正确渲染
|
||
|
||
请描述关键实现步骤和设计决策。
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. 配置文件(`src/config/amap.ts`)
|
||
|
||
集中管理 Key、安全密钥、版本号和插件列表:
|
||
|
||
| 配置项 | 说明 |
|
||
| -------------------- | ---------------------------------------------------------- |
|
||
| `AMAP_JSAPI_KEY` | 前端地图 Key(`VITE_AMAP_JSAPI_KEY` 环境变量) |
|
||
| `AMAP_SECURITY_CODE` | 安全密钥,2021/12/02 后申请的 Key 必须配合使用 |
|
||
| `AMAP_VERSION` | JSAPI 版本,如 `'2.0'` |
|
||
| `AMAP_PLUGINS` | 需要加载的插件数组,如 `Geocoder`、`Driving`、`ToolBar` 等 |
|
||
|
||
### 2. `loadAMap()` — SDK 全局单例加载
|
||
|
||
用两个模块级变量实现双重锁:
|
||
|
||
```
|
||
let amapPromise = null // 正在进行的加载 Promise(防并发重复加载)
|
||
let AMapGlobal = null // 已完成的加载结果(后续调用 O(1) 秒返)
|
||
|
||
loadAMap():
|
||
① if (AMapGlobal) → 直接返回缓存 // 最快路径
|
||
② if (!amapPromise) → 创建加载 Promise // 首次 / 上次失败后
|
||
├─ 先设置 window._AMapSecurityConfig // ⚠️ 必须在 load 之前
|
||
├─ 调用 AMapLoader.load({ key, version, plugins }) ├─ 成功 → 存入 AMapGlobal └─ 失败 → amapPromise = null(允许重试)
|
||
③ return amapPromise // 并发调用复用同一 Promise```
|
||
|
||
关键设计:
|
||
- **Promise 去重**:多个组件同时调用,只发一次网络请求
|
||
- **失败可重试**:`catch` 中重置 `amapPromise`,不污染 `AMapGlobal`
|
||
|
||
### 3. `useAmap()` — 地图实例管理
|
||
|
||
```ts
|
||
// 返回值
|
||
containerRef // ref → 模板绑定到 <div ref="containerRef" />mapInstance // shallowRef → 地图实例(为什么用 shallowRef 见 Q2)
|
||
loading/error // ref → 加载/错误状态
|
||
initMap() // 异步初始化:loadAMap() → new AMap.Map(container, options)
|
||
destroyMap() // 销毁实例 + onUnmounted 自动调用
|
||
```
|
||
|
||
### 4. 页面中使用
|
||
|
||
```vue
|
||
<template>
|
||
<div ref="containerRef" class="map-container" /></template>
|
||
|
||
<script setup>
|
||
import { onMounted, nextTick } from 'vue'
|
||
import { useAmap } from '@/composables/useAmap'
|
||
|
||
const { containerRef, mapInstance, initMap } = useAmap({ zoom: 15 })
|
||
|
||
onMounted(async () => {
|
||
const map = await initMap() if (map) nextTick(() => map.resize()) // 修正 flex 布局下的初始尺寸
|
||
})
|
||
</script>
|
||
```
|
||
|
||
### 5. 关键细节
|
||
|
||
- **安全密钥时序**:`_AMapSecurityConfig` 必须在 `AMapLoader.load()` 之前设置,否则 Driving / Geocoder 等服务报 `INVALID_USER_SCODE`
|
||
- **flex 布局修正**:地图创建后调用 `map.resize()`,因为 flex 分配的尺寸可能尚未生效
|
||
- **`%` 而非 `vh`**:容器用 `height: 100%` 逐级继承,适配微前端宿主不一定是全视口的场景
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **配置文件集中管理 Key/插件 → `loadAMap()` 全局单例确保 SDK 只下载一次 → `useAmap()` 封装实例生命周期,页面只需绑定 `containerRef` 并调用 `initMap()`。**
|
||
|
||
---
|
||
|
||
## Q2:多个地图页面复用时有哪些性能陷阱?如何解决?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
你的应用中有 3 个页面各自包含高德地图,用户在不同页面间切换。请分析:
|
||
|
||
1. **JSAPI 脚本会重复下载吗?如何保证只下载一次?**
|
||
2. **`mapInstance` 为什么用 `shallowRef` 而不是 `ref`?从性能和副作用两个角度说明。**
|
||
3. **页面切换时旧地图实例如何清理?画出关键生命周期。**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. JSAPI 脚本只下载一次
|
||
|
||
`loadAMap()` 通过 `AMapGlobal` 缓存已加载结果。页面 A 首次调用时发起 `AMapLoader.load()`,页面 B/C 调用时直接命中缓存返回,不产生额外网络请求。
|
||
|
||
**进阶:`AMap` 与 `mapInstance` 的本质区别**
|
||
|
||
| 对象 | 本质 | 作用 | 数量 |
|
||
| ----------------- | ------------------- | ---------------------------------------------- | ------------ |
|
||
| **`AMap`** | 全局命名空间 (SDK) | 提供构造函数(如 `new AMap.Marker`)和工具方法 | 全局唯一 |
|
||
| **`mapInstance`** | 地图实例 (Instance) | 具体的地图对象,负责渲染、缩放、事件监听 | 每个容器一个 |
|
||
|
||
> **面试官提问:既然已经有了 `mapInstance`,为什么在点击事件处理函数中还要重新调用 `await loadAMap()`?**
|
||
>
|
||
> **回答:** 因为 `mapInstance` 只是一个渲染好的地图“窗口”,它不包含创建新零件(如 Marker 或 Polyline)的“工厂工具”。为了在点击位置创建新的 Marker,必须通过 `loadAMap()` 获取 `AMap` 这个构造函数库。由于 `loadAMap` 做了单例缓存,这种重复调用是 O(1) 级别的,既保证了代码的健壮性(确保 SDK 已加载),又不会产生额外的网络开销。
|
||
|
||
页面切换时的关键流程:
|
||
|
||
```
|
||
页面 A 活跃 → 页面 B 挂载
|
||
│ ├─ A.onUnmounted → destroyMap()
|
||
│ └─ mapInstance.value.destroy() // 释放 WebGL 上下文、事件监听
|
||
│ └─ B.onMounted → initMap() ├─ loadAMap() → 命中 AMapGlobal 缓存(不下载)
|
||
└─ new AMap.Map(containerB, options) // 全新实例挂到新 DOM
|
||
```
|
||
|
||
**核心结论**:SDK 全局常驻,地图实例按页面创建/销毁,互不干扰。
|
||
|
||
### 2. 为什么 `mapInstance` 必须用 `shallowRef`?
|
||
|
||
| 维度 | `shallowRef` ✅ | `ref` ❌ |
|
||
| ---------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
||
| **性能** | 仅追踪 `.value` 整体替换,初始化为 O(1) | 递归代理整个 AMap.Map 对象树(数百个属性/WebGL 上下文),阻塞主线程 |
|
||
| **副作用** | 不劫持属性访问,AMap 内部 `this` 指向不变 | Proxy 改变 `this` 指向,可能破坏地图渲染循环、事件系统 |
|
||
| **语义** | 代码中只需要 `mapInstance.value = new Map()` / `.destroy()` / `= null`,不关心内部属性变化 | 完全用不到深度追踪能力 |
|
||
|
||
**一句话**:`shallowRef` 用于"只关心对象整体是否被替换,不关心内部怎么变"的第三方复杂实例。
|
||
|
||
### 3. 页面切换时的完整生命周期
|
||
|
||
| 步骤 | 时机 | 动作 |
|
||
| -------------- | ------------------------------ | -------------------------------------------------- |
|
||
| ① 旧地图销毁 | `onUnmounted` → `destroyMap()` | `map.destroy()` 释放画布、事件、DOM |
|
||
| ② 响应式清理 | Vue 组件卸载 | `mapInstance`、`containerRef` 随组件 GC |
|
||
| ③ 新地图创建 | `onMounted` → `initMap()` | `containerRef` 绑定新 DOM → `new AMap.Map(新容器)` |
|
||
| ④ SDK 缓存命中 | `initMap()` 内部 `loadAMap()` | `AMapGlobal` 非空,O(1) 返回 |
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **SDK 全局单例只下载一次,地图实例按页面生灭,`shallowRef` 避免深度代理第三方对象 —— 三者配合才能让多页面地图既快又稳。**
|
||
|
||
---
|
||
|
||
## Q3:如何在高德地图上添加和管理 Marker?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
在一个电子围栏应用中,你需要在地图上添加多种标记:老人位置(👴,可拖拽)、护理员位置(👩⚕️,程序控制移动)、起点/终点(📍/🏁,静态)、围栏顶点(序号圆点,绘制完成后清除)。请回答:
|
||
|
||
1. **Marker 的基本创建流程是什么?`content` 和 `offset` 的作用?**
|
||
2. **不同类型的 Marker 在"创建/更新/销毁"策略上有什么不同?为什么?**
|
||
3. **为什么 Marker 的 CSS 样式不能写在 Vue 的 `<style scoped>` 中?**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. Marker 的基本创建
|
||
|
||
```ts
|
||
const marker = new AMap.Marker({
|
||
position: [lng, lat], // 经纬度
|
||
content: '<div class="my-marker">👴</div>', // 自定义 DOM 内容
|
||
offset: new AMap.Pixel(-17, -17), // 锚点偏移(让图标中心对准坐标)
|
||
zIndex: 200, // 层级
|
||
draggable: true, // 是否可拖拽
|
||
})
|
||
map.add(marker) // 添加到地图
|
||
```
|
||
|
||
- **`content`**:任意 HTML 字符串,AMap 将其渲染为 Marker 的 DOM 节点。比默认的图标更灵活(可以用 emoji + CSS)。
|
||
- **`offset`**:Marker 默认以左上角对齐坐标点,`offset` 向左上偏移一半宽高,使图标中心对准坐标。
|
||
|
||
### 2. 不同 Marker 的创建/更新/销毁策略
|
||
|
||
| 类型 | 创建策略 | 更新策略 | 销毁策略 |
|
||
| --------------------- | ------------------------------------------ | -------------------------------------------- | ------------------------------------------------- |
|
||
| **老人 👴** | `addPerson` 时 `new Marker` + `map.add` | 拖拽 `dragend` → `setPosition` + 边界检测 | `removePerson` 时 `map.remove` |
|
||
| **护理员 👩⚕️** | `addCaregiver` 时 `new Marker` + `map.add` | 动画循环中 `setPosition`(每 40ms) | `removeCaregiver` 时 `map.remove` |
|
||
| **起点 📍 / 终点 🏁** | 首次点击时 `new Marker`,**保存引用** | **复用**:再次点击只调 `setPosition`,不重建 | `removeOrigin/Dest` 时 `map.remove` |
|
||
| **围栏顶点** | 每次点击都 `new Marker` + `map.add` | 无需更新(绘制过程中只增不减) | `finishDraw`/`cancelDraw` 时**批量 `map.remove`** |
|
||
|
||
**为什么起点/终点用"复用 setPosition"而非"删了重建"?** 避免闪烁,性能更好,且语义上"同一个标记在移动"比"消失再出现"更自然。
|
||
|
||
**为什么围栏顶点要批量清理?** 顶点是临时绘制辅助标记,围栏完成后由 Polygon 接管显示,顶点 Marker 必须全部 `map.remove()`,否则会残留在画布上。
|
||
|
||
### 3. 为什么样式不能 scoped?
|
||
|
||
Vue 的 `<style scoped>` 会给选择器加 `data-v-xxx` 属性选择器。但 Marker 的 DOM 是由 AMap **直接注入地图容器**的,不在 Vue 模板编译范围内,DOM 节点上没有 `data-v-xxx` 属性 → scoped 样式完全匹配不到。
|
||
|
||
**正确做法**:在 `.vue` 文件末尾加一个非 scoped 的 `<style>` 块:
|
||
|
||
```vue
|
||
<style>
|
||
/* 全局生效,按 className 精确匹配,避免污染 */.geofence-person-marker { width: 34px; height: 34px; ... }
|
||
.geofence-caregiver-marker { width: 34px; height: 34px; ... }
|
||
</style>
|
||
```
|
||
|
||
通过 **业务前缀命名**(如 `geofence-`)来隔离,而非依赖 Vue 的 scoped 机制。
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **Marker 通过 `content` + `offset` 实现自定义 UI;静态标记复用 `setPosition`,临时标记批量清理,样式用全局 `<style>` + 业务前缀隔离。**
|
||
|
||
---
|
||
|
||
## Q4:如何实现"标记起点 → 终点 → 规划路线 → 动画移动"的完整交互?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
护理员巡护场景:点击按钮 → 点击地图放置 📍 起点 → 同样放置 🏁 终点 → 添加 👩⚕️ 护理员 → 规划驾车路线(蓝色折线)→ 护理员沿路线动画到达终点。请回答:
|
||
|
||
1. **"点击地图放标记"的交互如何设计?为什么不用全局 `map.on('click')`?**
|
||
2. **路线规划的核心流程?`AMap.Driving` 有哪些注意点?**
|
||
3. **动画如何实现?为什么需要对路径点降采样?**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. "标记模式"交互:用完即解绑
|
||
|
||
采用 **进入模式 → 点击地图 → 退出模式** 三段式,核心是一个 `settingMode` 状态变量(`'origin'` | `'dest'` | `null`):
|
||
|
||
| 阶段 | 动作 |
|
||
| -------- | ----------------------------------------------------------------------- |
|
||
| **进入** | `settingMode = mode`,光标设为 `crosshair`,`map.on('click', handler)` |
|
||
| **点击** | `eventLngLat(e)` 提取坐标 → `applyOrigin(pt)` 或 `applyDestination(pt)` |
|
||
| **退出** | `settingMode = null`,光标还原,`map.off('click', handler)` |
|
||
|
||
**为什么不用全局 `map.on('click')`?** 一直开着无法区分"现在该放起点还是终点",需要额外的 `if/else` 分支;模式开关职责单一,每个模式用完即解绑,防止事件堆积。
|
||
|
||
**Marker 复用策略**:起点/终点如果已存在,只调 `setPosition()` 而非删了重建 —— 避免闪烁。
|
||
|
||
### 2. 路线规划:`AMap.Driving` 五步走
|
||
|
||
前提:`config/amap.ts` 的 `AMAP_PLUGINS` 已包含 `'AMap.Driving'`。
|
||
|
||
```
|
||
① new AMap.Driving({ map, policy: 0 }) → 必须传 map 实例
|
||
② driving.search(起点LngLat, 终点LngLat, callback) → 回调风格,需手动包装 Promise③ 从 result.routes[0].steps[i].path 逐段拼出完整坐标数组
|
||
④ new AMap.Polyline({ path, strokeColor: '#4A90D9' }) + map.add()⑤ map.setFitView([polyline], false, [60,60,60,60]) → 自动缩放让整条路线可见
|
||
```
|
||
|
||
| 注意点 | 说明 |
|
||
| ------------------- | -------------------------------------------------------------- |
|
||
| **插件预加载** | 未在 `AMAP_PLUGINS` 中声明则 `AMap.Driving` 为 `undefined` |
|
||
| **`policy` 策略** | `0`=速度优先, `1`=费用优先, `2`=距离优先, `3`=不走快速路 |
|
||
| **回调 ≠ Promise** | `search` 是回调风格,用 `new Promise` 包裹以配合 `async/await` |
|
||
| **`steps[i].path`** | 每段道路包含该段所有拐点,需遍历拼接 |
|
||
|
||
### 3. 动画 & 降采样
|
||
|
||
**为什么降采样?** Driving 返回的原始路径有 **数千个点**(每几米一个),直接逐点动画:
|
||
|
||
> 5000 点 × 40ms = **200 秒**,太慢且大部分点位间距不到 1 像素。
|
||
|
||
降采样到 **250 点**:等距抽取,`250 × 40ms ≈ 10 秒`,流畅且速度合理。
|
||
|
||
```ts
|
||
// 降采样:从 N 个点中等距取 target 个
|
||
function downsamplePath(path, target = 250) {
|
||
const step = (path.length - 1) / (target - 1) return Array.from({ length: target }, (_, i) => path[Math.round(i * step)])}
|
||
```
|
||
|
||
**动画循环**:`setInterval` 每 40ms 调一次 `marker.setPosition(animPath[i])`,配合 `animProgress`(0→1)驱动进度条 UI。用 `setInterval` 而非 `rAF` 是因为地图不是逐帧画布,固定间隔配合降采样点数能精确控制总耗时。
|
||
|
||
### 完整流程(状态机视角)
|
||
|
||
```
|
||
settingMode='origin' → click → applyOrigin(Marker 复用)
|
||
↓settingMode='dest' → click → applyDestination(Marker 复用)
|
||
↓addCaregiver() → new Marker(👩⚕️) at 起点
|
||
↓planRoute() → Driving.search → 拼 path → Polyline(蓝) → downsample→250
|
||
↓startAnimation() → setInterval 40ms → setPosition → 到达
|
||
```
|
||
|
||
> 全程通过 `isPlanning` / `isAnimating` / `canPlanRoute`(computed) 控制按钮互斥与禁用状态。
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **模式开关管理点选(用完即解绑)→ `AMap.Driving` + 回调转 Promise 规划路线 → 降采样到 250 点后 `setInterval` + `setPosition` 驱动动画,状态变量全程控制 UI 互斥。**
|
||
|
||
---
|
||
|
||
## Q6:如何在高德地图上实现电子围栏的交互绘制与越界报警?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
在一个养老看护应用中,你需要实现以下功能:
|
||
|
||
1. **多边形围栏绘制**:用户在地图上逐点点击添加顶点,支持预览虚线、首顶点高亮提示闭合、右键取消
|
||
2. **圆形围栏绘制**:第一次点击定圆心,鼠标移动实时预览圆形,第二次点击定半径
|
||
3. **老人越界报警**:老人标记(👴)可通过方向键或拖拽移动,每次移动自动检测是否在围栏内,越界时触发声光报警并记录历史
|
||
|
||
请描述关键实现思路,重点关注**绘制过程的交互设计**和**越界检测算法**。
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 一、多边形围栏绘制
|
||
|
||
#### 1. 整体状态机
|
||
|
||
绘制过程由 `isDrawing` + `fenceType` 两个状态变量驱动,按钮禁用/启用、提示条显示、光标样式全部由它们控制:
|
||
|
||
```
|
||
空闲 ──[点击「绘制围栏」]──▶ 绘制中(polygon)
|
||
│ ├─ click → 添加顶点(编号圆点 + 虚线连接)
|
||
│ ├─ click 首顶点(30m内) → 闭环完成
|
||
│ └─ 右键 → 取消
|
||
◀──────────────────────────────┘
|
||
```
|
||
|
||
#### 2. 关键设计决策
|
||
|
||
| 决策 | 做法 | 原因 |
|
||
| ------------ | --------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||
| **事件绑定** | `map.on('click', handler)` 临时绑定,绘制完成/取消时 `off` | 用完即解绑,避免全局监听干扰其他功能 |
|
||
| **双击冲突** | 绘制开始时 `map.setStatus({ doubleClickZoom: false })` | 双击 = click+click+dblclick,不关闭则 dblclick 被 AMap 消费导致触发意外缩放 |
|
||
| **光标提示** | `map.setDefaultCursor('crosshair')` | 用户明确知道"正在绘制中" |
|
||
| **右键取消** | `map.on('rightclick', handler)` + `e.domEvent.preventDefault()` | 阻止浏览器右键菜单弹出 |
|
||
|
||
#### 3. 闭环判断:Haversine 距离
|
||
|
||
当已有 ≥3 个顶点时,每次点击计算点击位置与首顶点的地理距离。距离 < 30 米 → 自动闭环:
|
||
|
||
```ts
|
||
// Haversine 公式 — 球面两点最短距离
|
||
function haversineDistance(a, b):
|
||
R = 6371000 // 地球半径(米)
|
||
dLat, dLng 转为弧度
|
||
a = sin²(dLat/2) + cos(lat1) * cos(lat2) * sin²(dLng/2) return R * 2 * atan2(√a, √(1-a))
|
||
```
|
||
|
||
**为什么不用像素距离?** 缩放级别变化时像素距离不等价于地理距离——zoom=10 时 30 米 = 2px,zoom=18 时 30 米 = 80px。用地理距离保证所有缩放级别行为一致。
|
||
|
||
#### 4. 首顶点高亮渐变
|
||
|
||
≥3 个顶点后,第一个顶点标记从普通绿色圆点(`geofence-vertex-dot`)替换为红色脉动大圆(`geofence-first-vertex`),CSS 动画 `geofence-vertex-pulse` 缩放+阴影闪烁——给用户强烈暗示"点击这里闭合"。
|
||
|
||
### 二、圆形围栏绘制
|
||
|
||
#### 两阶段 Click 设计
|
||
|
||
```
|
||
阶段 1:第一次 click ├─ 记录圆心坐标
|
||
├─ map.off('click', onClick) ← 解绑第一阶段
|
||
├─ 添加红色圆心标记
|
||
├─ map.on('mousemove', onMouseMove) ← 动态预览圆形
|
||
├─ map.on('click', onSecondClick) ← 第二阶段
|
||
└─ map.on('rightclick', onCancel)
|
||
onMouseMove:
|
||
└─ 计算 Haversine(圆心, 鼠标) → 更新预览 Circle 的半径
|
||
|
||
阶段 2:第二次 click ├─ 计算最终半径
|
||
├─ 清理预览 Circle + 圆心标记
|
||
├─ 创建正式 Circle 覆盖物
|
||
└─ 恢复 doubleClickZoom + 默认光标
|
||
```
|
||
|
||
**关键**:两个阶段通过解绑/重新绑定不同的事件处理器实现,而非在同一个 handler 中用 `if/else` 分支——职责更清晰。
|
||
|
||
### 三、越界检测
|
||
|
||
#### 1. 检测时机
|
||
|
||
每次老人位置变化都触发 `checkBoundary()`:
|
||
|
||
- **方向键移动** (`movePerson`):`setPosition` → `checkBoundary()`
|
||
- **拖拽标记** (`dragend` 事件):更新 `personPosition` → `checkBoundary()`
|
||
- **首次添加** (`addPerson`):创建后立即检测一次
|
||
|
||
#### 2. 判断点是否在围栏内
|
||
|
||
| 围栏类型 | 方法 | 说明 |
|
||
| ---------- | ----------------------------------- | ---------------------------------------- |
|
||
| **圆形** | `AMap.Circle.contains(point)` | AMap 内置方法,直接判断 |
|
||
| **多边形** | `AMap.GeometryUtil.isPointInRing()` | AMap 工具方法,优先使用 |
|
||
| 多边形兜底 | 自实现射线法 `isPointInPolygon()` | try-catch 兜底,防止 GeometryUtil 不可用 |
|
||
|
||
**射线法原理**:从待测点向右发射水平射线,统计与多边形各边的交点数——奇数=在内,偶数=在外。
|
||
|
||
#### 3. 状态转换
|
||
|
||
```ts
|
||
checkBoundary():
|
||
计算 inside(当前是否在围栏内)
|
||
if (!inside && wasInside): // 刚刚越界
|
||
→ triggerAlarm(position) if (inside && !wasInside): // 刚刚回栏
|
||
→ resetAlarm()
|
||
```
|
||
|
||
**核心**:用 `wasInside`(上一次检测结果)与当前 `inside` 比较,只做**状态转换检测**,不做持续检测。越界后只要不回围栏,不会重复触发报警。
|
||
|
||
### 四、报警表现(多通道)
|
||
|
||
报警通过三个通道同时表现,每个通道独立控制:
|
||
|
||
| 通道 | 实现 | 说明 |
|
||
| ------------- | --------------------------------------------------------------------------- | -------------------------------- |
|
||
| **视觉-浮层** | `alarm-overlay` 绝对定位覆盖地图,box-shake + bg-pulse 动画,点击任意处关闭 | `alarmActive` 控制 `v-if` |
|
||
| **视觉-标签** | `fence-status` 位置标签:红底"老人已越界" / 绿底"安全区域内" | 由 `alarmActive` 驱动 class 切换 |
|
||
| **视觉-围栏** | `updateFenceStyle(true)` → 填充红色半透明 + 红色虚线描边 + 加粗 | `setOptions` 动态切换样式 |
|
||
| **听觉** | Web Audio API:880Hz 方波,每 800ms 响 300ms,增益从 0.2 指数衰减到 0 | `OscillatorNode` + `setInterval` |
|
||
| **记录** | `alarmHistory` 追加 `{ time, position }`,右侧面板显示 | 累计 `alarmCount` 在浮层中展示 |
|
||
|
||
### 五、架构分工
|
||
|
||
```
|
||
MapView.vue (胶水层)
|
||
│ ├─ useAmap() → mapInstance(地图实例,注入下游)
|
||
│ ├─ useGeofence(mapInstance) │ ├─ 围栏绘制(Polygon/Circle)
|
||
│ ├─ 老人标记(添加/移动/拖拽)
|
||
│ └─ 越界检测 + 报警
|
||
│ └─ useRouteTrack(mapInstance) └─ 路线规划 + 护理员动画(共享 mapInstance,互不干扰)
|
||
|
||
生命周期:onUnmounted → destroyGeofence() + destroyRouteTrack()
|
||
```
|
||
|
||
两个 Composable 共享同一个 `mapInstance`,通过各自的状态变量(`isDrawing`、`isPlanning`、`isAnimating`)在模板层实现按钮互斥——绘制围栏时禁用路线操作,规划路线时禁用围栏操作。
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **多边形用"点击地图→Haversine 30m 首顶点闭合 + 手动按钮兜底",圆形用"两阶段 Click + mousemove 实时预览",越界检测用 GeometryUtil/射线法 + 状态转换去重,报警通过视觉(浮层/标签/围栏变色)+ 听觉(Web Audio 蜂鸣)+ 记录(历史面板)三通道实现。**
|
||
|
||
---
|
||
|
||
## Q5:大卡车每10s发一个GPS点,连续3天(~25920点),前端如何绘制轨迹?会卡顿吗?如何处理?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
一辆大卡车每 10 秒上报一个 GPS 位置,连续跑了 3 天,累计约 **25920 个轨迹点**。前端需要在地图上展示完整轨迹。请回答:
|
||
|
||
1. **直接渲染 25920 个点会不会卡顿?为什么?**
|
||
2. **有哪些优化方案?请从数据层、渲染层、交互层三个维度分别说明。**
|
||
3. **落地的推荐组合方案是什么?**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. 会不会卡顿?为什么?
|
||
|
||
**一定会严重卡顿,甚至可能浏览器崩溃(OOM)**。原因:
|
||
|
||
| 环节 | 问题 |
|
||
| ------------ | ----------------------------------------------------------------------------------- |
|
||
| **数据规模** | 25920 个点 = 25919 条线段,远超 AMap.Polyline 流畅阈值(通常 < 2000 段) |
|
||
| **渲染机制** | `AMap.Polyline` 基于 DOM / 普通 Canvas 逐段绘制,缩放平移时全量重绘,直接榨干 CPU |
|
||
| **内存风险** | 大规模坐标数组 + AMap 内部渲染缓冲持续驻留,极端情况 OOM 崩溃 |
|
||
| **视觉浪费** | 屏幕宽度 ~1920px,缩小看整条轨迹只需几百像素,25920 个点中 95% 挤在同一像素毫无贡献 |
|
||
|
||
**一句话**:DOM/Canvas 全量渲染 2.6 万段折线 → 主线程阻塞 + 内存激增 + 每帧重绘 → 必定卡顿。
|
||
|
||
---
|
||
|
||
### 2. 优化方案(数据层 → 渲染层 → 交互层)
|
||
|
||
#### ▎数据层优化:减少点数(脱水)
|
||
|
||
##### 方案一:Douglas-Peucker 抽稀(核心方案)
|
||
|
||
道格拉斯-普克算法——保留轨迹形状特征,丢弃共线/冗余点:
|
||
|
||
> **原理**:连首尾 → 找离连线最远的中间点 → 若距离 > epsilon 则保留并递归 → 否则丢弃中间所有点。
|
||
|
||
- epsilon = 10 米时,25920 点 → **约 500~800 点**,地图上肉眼完全无法分辨差异
|
||
- O(n log n),浏览器计算 < 50ms,前端即可完成
|
||
- 现成库:`simplify-js`、`@turf/simplify`,一行调用
|
||
|
||
**效果**:数据量精简 70%~90%,弯道保留密集点,直道只留两端点。
|
||
|
||
##### 方案二:按缩放级别动态抽稀
|
||
|
||
同一份数据,不同 zoom 用不同 epsilon——远看粗、近看细:
|
||
|
||
| Zoom | 含义 | Epsilon | 预期点数 |
|
||
| ----- | --------- | ------------- | --------- |
|
||
| 3~8 | 省/国级别 | 500m | < 100 |
|
||
| 9~12 | 城市级别 | 100m | 200~500 |
|
||
| 13~15 | 区/街道 | 20m | 500~1500 |
|
||
| 16+ | 建筑级 | 5m 或原始数据 | 1000~5000 |
|
||
|
||
**实现**:预计算 3~4 个精度版本的 path,监听 `map.on('zoomend')` 切换 `polyline.setPath()`,避免实时计算。
|
||
|
||
##### 方案三:后端预聚合(前端最省心)
|
||
|
||
后端存储时提前产出多精度版本:
|
||
|
||
```
|
||
GET /api/track/123?precision=low → ~300 点(zoom < 10)
|
||
GET /api/track/123?precision=medium → ~2000 点(zoom 10~14)
|
||
GET /api/track/123?precision=high → 原始数据(zoom > 14)
|
||
```
|
||
|
||
**优点**:前端无需计算,首次请求即最优。 **缺点**:需后端配合,存储多份。
|
||
|
||
##### 方案四:时间维度聚合
|
||
|
||
最近数据精细、历史粗化:
|
||
|
||
| 时间段 | 展示策略 | 点数 |
|
||
| ------------ | ------------------ | ---- |
|
||
| 最近 2 小时 | 原始精度(10s/点) | 720 |
|
||
| 2~12 小时前 | 1 分钟/点 | 600 |
|
||
| 12~72 小时前 | 10 分钟/点 | 360 |
|
||
|
||
**适用场景**:实时监控页,用户更关注"现在在哪",而非历史每一秒。
|
||
|
||
---
|
||
|
||
#### ▎渲染层优化:换引擎(重构)
|
||
|
||
##### 方案五:AMap.Loca — 高德官方 WebGL 大数据引擎(推荐)
|
||
|
||
高德专门针对大屏和海量数据开发的 WebGL 可视化引擎。Loca 中的 **`LineLayer`(折线层)** 或 **`LinkLayer`(飞线层)** 专为万级、十万级轨迹线设计:
|
||
|
||
- WebGL 渲染,GPU 并行处理,不受 DOM/Canvas 瓶颈限制
|
||
- 平移缩放只更新投影矩阵,不重建几何
|
||
- 2.6 万条线段也能维持 60fps
|
||
|
||
**这是高德官方推荐的海量线条方案,也是 Gemini 回答的核心推荐。**
|
||
|
||
##### 方案六:AMap.LabelsLayer + LabelMarker(海量标记场景)
|
||
|
||
如果需要在地图上显示 2.6 万个停靠点或关键标记,传统 `Marker` 超过几百个就卡。`LabelsLayer` 基于 Canvas/WebGL,渲染几万个点极其流畅。配合 `AMap.MassMarks` 可做到百万级散点。
|
||
|
||
##### 方案七:AMap.CustomLayer / 自定义 WebGL 图层(终极可控)
|
||
|
||
使用 `AMap.CustomLayer` 叠加 Three.js 或原生 WebGL:
|
||
|
||
- 坐标一次性上传 GPU 缓冲
|
||
- 完全控制渲染管线(着色器、线宽、颜色渐变)
|
||
- 百万点也能 60fps
|
||
|
||
**缺点**:开发成本高,25920 点场景不需要。
|
||
|
||
---
|
||
|
||
#### ▎交互层优化:按需加载(减负)
|
||
|
||
##### 方案八:视口裁剪(Bounds Filter)
|
||
|
||
监听 `mapmove` / `zoomchange`,获取 `map.getBounds()`,只渲染当前可视区域内的轨迹段:
|
||
|
||
- 视口内的段 → `polyline.setMap(map)` 显示
|
||
- 视口外的段 → `polyline.setMap(null)` 隐藏
|
||
- 用户放大后视口变小,实际渲染点数自然大幅下降
|
||
|
||
**配合 zoom 自适应使用效果最佳**:缩小看粗略趋势(点数少),放大看局部细节(视口小 → 点数也少)。
|
||
|
||
##### 方案九:防抖(Debounce)
|
||
|
||
地图拖拽和缩放时频繁触发重绘,必须加防抖:
|
||
|
||
- 对 `mapmove` / `zoomchange` 加 **200ms 防抖**
|
||
- 等用户操作停止后再重新计算和渲染轨迹
|
||
- 避免拖拽过程中每秒触发数十次无意义的重绘
|
||
|
||
##### 方案十:分片计算(rAF 分批)
|
||
|
||
如果 2.6 万个点必须全量处理(如客户端抽稀计算),不要用 `forEach` 一次性跑完导致浏览器假死:
|
||
|
||
- 用 `requestAnimationFrame` 将计算分批,每帧处理 500~1000 个点
|
||
- 计算期间保持 UI 响应,避免"页面卡死"的用户体验
|
||
|
||
---
|
||
|
||
### 3. 推荐组合方案
|
||
|
||
按量级分档推荐:
|
||
|
||
| 点量 | 组合 |
|
||
| -------------------- | ---------------------------------------------------- |
|
||
| **< 1 万** | DP 抽稀 + zoom 自适应 + 防抖 |
|
||
| **1~5 万**(本场景) | DP 抽稀 + zoom 自适应 + 视口裁剪 + 防抖 |
|
||
| **5~20 万** | DP 抽稀 + zoom 自适应 + **Loca LineLayer** + 防抖 |
|
||
| **20 万+** | 后端预聚合 + Loca LineLayer / CustomLayer + 视口裁剪 |
|
||
|
||
**对于本场景(25920 点)的具体落地步骤**:
|
||
|
||
1. 收到原始 25920 点 → 前端 `simplify-js` DP 抽稀,epsilon 10m → ~800 点
|
||
2. 预计算 3 个精度版本(epsilon = 5m / 50m / 200m),对应 zoom >= 14 / 10~13 / < 10
|
||
3. 创建 `AMap.Polyline`,根据当前 zoom 选择对应精度
|
||
4. 监听 `zoomend` → `setPath(对应精度)` 无缝切换
|
||
5. 对 `mapmove` 加 200ms 防抖,避免拖拽时频繁重绘
|
||
6. 如果后端支持分段查询,叠加视口裁剪只加载可见段
|
||
|
||
> **够用原则**:2.6 万点不需要上 Loca 或 CustomLayer。DP 抽稀 + zoom 自适应 + 防抖三板斧足够丝滑。点数上 5 万再考虑 Loca。
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **数据层 DP 抽稀去冗余(脱水)→ 渲染层 Loca/CustomLayer 换引擎(重构,量大时)→ 交互层 zoom 自适应 + 视口裁剪 + 防抖(减负),三层组合按量级分档选配,2.6 万点 DP + zoom + 防抖足矣。**
|
||
|
||
---
|
||
|
||
## Q7:如何在高德地图上叠加自定义图片(ImageLayer)?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
你有一张本地图片(如建筑平面图、园区示意图、地质图层),需要将其叠加到高德地图的指定地理范围内。请回答:
|
||
|
||
1. **高德地图 JSAPI v2 中用什么类实现图片叠加?`GroundOverlay`(v1) 和 `ImageLayer`(v2) 有什么区别?**
|
||
2. **如何确定图片的地理范围(Bounds)?**
|
||
3. **如何管理图片图层的生命周期(加载/显示/隐藏/销毁)?**
|
||
4. **Vite 项目中 `import xxx from '@/assets/xxx.png'` 拿到的到底是什么?**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. ImageLayer vs GroundOverlay
|
||
|
||
| 维度 | `GroundOverlay` (v1.x) | `ImageLayer` (v2.0) |
|
||
| ------------ | ------------------------------------------------ | ---------------------------------------------------------- |
|
||
| **所属版本** | JSAPI v1.x | JSAPI v2.0 |
|
||
| **构造函数** | `new AMap.GroundOverlay(bounds, { image: url })` | `new AMap.ImageLayer({ url, bounds })` |
|
||
| **添加方式** | `map.add(groundOverlay)` | `imageLayer.setMap(map)` |
|
||
| **所需插件** | 无(v1 核心) | 无(v2 核心,继承自 `BuildLayer`) |
|
||
| **扩展性** | 基本属性(透明度、层级) | 更丰富的图层管理 API(setZooms, setOpacity, setzIndex 等) |
|
||
|
||
本项目使用 JSAPI v2.0,所以用 `AMap.ImageLayer`。
|
||
|
||
### 2. Bounds — 地理边界
|
||
|
||
`AMap.Bounds` 用**西南角 + 东北角**两个经纬度坐标定义一个矩形区域:
|
||
|
||
```ts
|
||
const bounds = new AMap.Bounds(
|
||
[116.385, 39.902], // 西南角 (southWest)
|
||
[116.410, 39.917], // 东北角 (northEast))
|
||
|
||
const imageLayer = new AMap.ImageLayer({
|
||
url: '/path/to/image.png',
|
||
bounds: bounds, // 也可以是 [lng1, lat1, lng2, lat2] 四元组
|
||
zooms: [3, 20], // 缩放级别范围(小于 3 或大于 20 时自动隐藏)
|
||
opacity: 0.8, // 透明度 0-1
|
||
})
|
||
imageLayer.setMap(map)
|
||
```
|
||
|
||
**如何确定 bounds?** 有三种常见方式:
|
||
|
||
| 方式 | 说明 | 适用场景 |
|
||
| ------------------ | --------------------------------------------------- | ------------------------------ |
|
||
| **手动指定** | 已知图片覆盖的实际地理位置范围 | 有明确坐标的建筑平面图、园区图 |
|
||
| **从地图视口取** | `map.getBounds()` 获取当前可视区域,直接用于 bounds | "把图片铺满当前屏幕"的快速开发 |
|
||
| **从图片元数据取** | 图片自带地理参考信息(如 GeoTIFF) | 专业 GIS 图层 |
|
||
|
||
### 3. 生命周期管理
|
||
|
||
图片图层的完整生命周期分为 5 个阶段:
|
||
|
||
```
|
||
① 创建 → ② 加载 → ③ 显示 → ④ 更新 → ⑤ 销毁
|
||
```
|
||
|
||
```ts
|
||
// ① 创建
|
||
const layer = new AMap.ImageLayer({ url, bounds, zooms, opacity })
|
||
|
||
// ② 加载(异步,监听 complete 事件)
|
||
layer.on('complete', () => console.log('图片加载完成'))
|
||
layer.setMap(map) // 此时开始网络请求
|
||
|
||
// ③ 显示/隐藏
|
||
layer.show() // 显示(默认)
|
||
layer.hide() // 隐藏,不销毁
|
||
|
||
// ④ 更新属性
|
||
layer.setOpacity(0.5)
|
||
layer.setBounds(newBounds)
|
||
layer.setZooms([10, 18])
|
||
|
||
// ⑤ 销毁
|
||
layer.setMap(null) // 从地图移除
|
||
layer.destroy() // 释放资源
|
||
```
|
||
|
||
**关键细节**:
|
||
|
||
- `show()`/`hide()` 只控制可见性,不触发网络请求(图片已缓存),适合频繁切换场景
|
||
- `setMap(null)` 从地图移除但不释放内存,`destroy()` 彻底释放
|
||
- `zooms` 参数让图片在特定缩放级别自动显隐 —— 比手动监听 `zoomchange` 更优雅
|
||
- 组件卸载时必须调用 `destroy()`,否则图片资源泄漏
|
||
|
||
### 4. Vite 中的图片导入
|
||
|
||
```ts
|
||
import desktopPng from '@/assets/desktop.png'
|
||
```
|
||
|
||
这里 `desktopPng` 是 **字符串(URL 地址)**,不是文件对象或 base64。
|
||
|
||
Vite 对小于 `assetsInlineLimit`(默认 4KB)的图片自动内联为 base64,大于阈值的(如这张 desktop.png 是 1.6MB)则复制到 `dist/assets/` 并返回带 hash 的路径,如 `/assets/desktop-D4jkcXaU.png`。
|
||
|
||
这意味着 `AMap.ImageLayer` 接收的 `url` 就是一个普通的 HTTP URL,AMap 内部通过 `<img>` 标签加载,完全兼容 Vite 的构建产物。
|
||
|
||
### 实际落地示例
|
||
|
||
```vue
|
||
<template>
|
||
<div ref="containerRef" class="map-container" /> <button @click="toggleLayer">切换图层</button>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { useAmap } from '@/composables/useAmap'
|
||
import desktopPng from '@/assets/desktop.png'
|
||
|
||
const { containerRef, mapInstance, initMap } = useAmap({ zoom: 14 })
|
||
const layer = shallowRef<AMap.ImageLayer | null>(null)
|
||
|
||
onMounted(async () => {
|
||
const map = await initMap() if (!map) return
|
||
layer.value = new AMap.ImageLayer({ url: desktopPng, // Vite 导入的 URL bounds: new AMap.Bounds([116.38,39.90], [116.41,39.92]), zooms: [3, 20], }) layer.value.setMap(map)})
|
||
|
||
function toggleLayer() {
|
||
layer.value?.[layerVisible ? 'hide' : 'show']()}
|
||
|
||
onUnmounted(() => layer.value?.destroy())
|
||
</script>
|
||
```
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **JSAPI v2 用 `AMap.ImageLayer({ url, bounds, zooms })` 替代 v1 的 `GroundOverlay`;Bounds 由西南/东北两个经纬度定义;生命周期通过 `setMap`/`show`/`hide`/`destroy` 管理;Vite 的图片 import 返回构建后的 URL 字符串,直接传给 `url` 即可。**
|
||
|
||
---
|
||
|
||
## Q8:如何在 ImageLayer 上叠加 Marker?遇到"地图文字盖住图片"怎么办?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
你通过 `AMap.ImageLayer` 在地图上叠加了一张园区平面图。现在需要在图片上放置若干标记点(如设备位置、巡检点),但发现两个问题:
|
||
|
||
1. **Marker 放在图片上能被看到吗?会不会被图片挡住?**
|
||
2. **地图自带的 POI 标注和路名盖在了图片上面,怎么处理?**
|
||
|
||
请回答这两个问题的原因和解决方案。
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. Marker 与 ImageLayer 的层级关系
|
||
|
||
高德地图的渲染从下到上分为多个图层:
|
||
|
||
```
|
||
┌───────────────────────┐
|
||
│ ⑥ 地图文字标注 │ ← 独立渲染层,永远最上层!
|
||
│ ⑤ Marker 标记 │ ← 默认 zIndex 100+
|
||
│ ④ Polygon / Polyline │
|
||
│ ③ ImageLayer │ ← 默认 zIndex 6
|
||
│ ② 道路 / 建筑 │
|
||
│ ① 底图瓦片 │
|
||
└───────────────────────┘
|
||
```
|
||
|
||
**默认情况下 Marker 就在 ImageLayer 之上**,不需要特殊处理就能看到。但建议显式设置 zIndex 确保万无一失:
|
||
|
||
```ts
|
||
// ImageLayer: zIndex 200
|
||
const layer = new AMap.ImageLayer({ url, bounds, zIndex: 200 })
|
||
|
||
// Marker: zIndex 500(明确比 ImageLayer 高)
|
||
const marker = new AMap.Marker({ position, zIndex: 500 })
|
||
```
|
||
|
||
如果 Marker 数量多(几百个),建议用同一个 zIndex 即可,AMap 内部按添加顺序渲染同层 Marker。
|
||
|
||
### 2. 为什么地图文字会盖住图片?
|
||
|
||
地图的 **POI 标注和路名** 渲染在 AMap 引擎的**独立文字图层**上,这个图层位于所有用户叠加层(ImageLayer、Marker、Polygon 等)之上。所以无论 ImageLayer 的 zIndex 设多高,文字始终在上面。
|
||
|
||
这**不是 bug**——高德的设计假设用户叠加图片后仍需要看到地名参考。但图片叠加场景通常不需要。
|
||
|
||
**解决方案**:通过 `map.setFeatures()` 控制底图要素的显示,去掉 `'point'`(POI 标注):
|
||
|
||
```ts
|
||
// 隐藏 POI 标注和路名
|
||
map.setFeatures(['bg', 'road', 'building']) // 去掉 'point'
|
||
// 恢复
|
||
map.setFeatures(['bg', 'road', 'building', 'point'])
|
||
```
|
||
|
||
| 参数 | 含义 |
|
||
| ------------- | ------------------------------------------- |
|
||
| `'bg'` | 背景地图 |
|
||
| `'road'` | 道路线 |
|
||
| `'building'` | 建筑 3D |
|
||
| **`'point'`** | **POI 标注 + 路名(就是盖在图片上的文字)** |
|
||
|
||
**注意事项**:
|
||
|
||
- **页面切换时需恢复**:在 `onUnmounted` 中将 features 恢复为全量 `['bg', 'road', 'building', 'point']`,否则离开页面后其他地图页面也看不到标注
|
||
- 如果只需要隐藏路名但保留 POI,可以用 `map.setShowLabel(false)`(不推荐的旧 API),或者接受"要么全留要么全去"
|
||
- `setFeatures` 是全局操作,影响整个地图实例。如果同一地图上有多个 ImageLayer 需要不同策略,需要自己管理状态
|
||
|
||
### 3. 完整示例:图片 + Marker + 无文字标注
|
||
|
||
```ts
|
||
// ① 地图就绪后先叠加图片
|
||
const imageLayer = new AMap.ImageLayer({
|
||
url: desktopPng, bounds: new AMap.Bounds([116.38, 39.90], [116.41, 39.92]), zIndex: 200,})
|
||
imageLayer.setMap(map)
|
||
|
||
// ② 隐藏 POI 标注(可选:提供按钮让用户自行切换)
|
||
map.setFeatures(['bg', 'road', 'building'])
|
||
|
||
// ③ 在图片上放置 Markerfunction placeMarker(lng: number, lat: number) {
|
||
const marker = new AMap.Marker({ position: [lng, lat], content: '<div class="my-marker">📍</div>',
|
||
offset: new AMap.Pixel(-13, -13), zIndex: 500, // 明确高于 ImageLayer draggable: true, }) marker.setMap(map) return marker}
|
||
|
||
// ④ 离开页面时清理
|
||
onUnmounted(() => {
|
||
imageLayer.destroy() map.setFeatures(['bg', 'road', 'building', 'point']) // 恢复文字层
|
||
})
|
||
```
|
||
|
||
### 4. Marker 交互设计:模式开关
|
||
|
||
在图片上放置 Marker 需要"点击地图"交互。推荐使用**模式开关**而非全局 `map.on('click')`:
|
||
|
||
```
|
||
用户点击「📍 添加标记」→ map.on('click', handler) + 光标变 crosshair用户点击地图 → 创建 Marker + 可能自动退出模式
|
||
用户再次点击按钮 → map.off('click', handler) + 光标还原
|
||
```
|
||
|
||
**为什么不用始终开启的全局 click?** 全局 click 无法区分用户的意图——是在拖动地图还是在放置标记。模式开关让职责单一,用完即解绑,事件不堆积。
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **Marker 默认层级就在 ImageLayer 之上,设 zIndex:500 保底;地图文字标注通过 `map.setFeatures(['bg','road','building'])` 去掉 `'point'` 来隐藏,离开页面时恢复;放置 Marker 用模式开关管理 map click 事件,用完即解绑。**
|
||
|
||
---
|
||
|
||
## Q9:ImageLayer 上的 Marker 为什么移除图片后还在?它们是什么关系?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
你在 `ImageLayer` 上放了一些 Marker,视觉上 Marker 在图片上面。但当你调用 `removeImageLayer()` 移除图片后,Marker 依然留在地图上。为什么?
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
**因为它们不是父子关系,而是平级兄弟。**
|
||
|
||
ImageLayer 和 Marker 都直接挂在地图实例上,是**两个独立的覆盖物**:
|
||
|
||
```
|
||
mapInstance
|
||
/
|
||
\ ImageLayer
|
||
Marker
|
||
①, Marker
|
||
②, Marker
|
||
③...
|
||
```
|
||
|
||
zIndex 只决定视觉上的前后遮挡,不产生任何数据上的从属关系。移除 ImageLayer 只销毁自己,Marker 不受影响。
|
||
|
||
**类比**:桌上铺一块布(ImageLayer),上面放几颗棋子(Marker)。抽走布,棋子还在桌上——它们从没粘在布上。
|
||
|
||
**如果想让 Marker 跟着图片走**,可以:
|
||
|
||
- 图片隐藏时调 `marker.hide()`,显示时调 `marker.show()`
|
||
- 或者更彻底:移除图片时 `clearMarkers()`,重新叠加时再重建 Marker
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **高德地图中所有覆盖物(ImageLayer、Marker、Polygon 等)都是 `mapInstance` 的平级子节点,视觉上的"谁在谁上面"由 zIndex 控制,数据上互不隶属。**
|
||
|
||
---
|
||
|
||
## Q10:SPA 中如何管理地图实例生命周期以防止内存泄漏?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
在单页应用中,你实现了高德地图的异步加载和按需引入。当用户频繁切换地图页面时,请回答:
|
||
|
||
1. **如何实现 JSAPI 的异步加载,避免阻塞首屏渲染?**
|
||
2. **页面切换时,旧地图实例如果不销毁会有什么后果?**
|
||
3. **`map.destroy()` 到底销毁了什么?只把 DOM 清空就够了吗?**
|
||
|
||
> 注:本题侧重**内存泄漏防止**,关于 SDK 单例加载的细节已覆盖在 Q1/Q2。
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. 异步加载机制
|
||
|
||
JSAPI 不放在 `index.html` 中用 `<script>` 引入,而是通过 `@amap/amap-jsapi-loader` 的 `AMapLoader.load()` 动态加载。原因:
|
||
|
||
| 方式 | 阻塞首屏 | 加载时机 | 多页面复用 |
|
||
| ------------------------ | -------- | ------------------------------ | ------------ |
|
||
| `<script>` 同步 | ❌ 阻塞 | 页面初始化(即使不访问地图页) | 全局一次 |
|
||
| `<script>` defer/async | ✅ 不阻塞 | 但依然提前下载 | 全局一次 |
|
||
| `AMapLoader.load()` 按需 | ✅ 不阻塞 | **真正需要时才发起请求** | 配合单例缓存 |
|
||
|
||
加载逻辑的核心原则:**Promise 去重 + 结果缓存**,Q1 的 `loadAMap()` 已详细说明。
|
||
|
||
### 2. 不销毁旧地图实例的后果
|
||
|
||
页面切换时如果只让组件卸载而不调用 `map.destroy()`:
|
||
|
||
| 资源 | 后果 |
|
||
| ---------------- | --------------------------------------------------------------------------------------------- |
|
||
| **WebGL 上下文** | 浏览器对单个标签页的 WebGL 上下文数量有限(通常 8~16 个),多次切换后超出上限,新地图创建失败 |
|
||
| **DOM 节点** | 地图容器虽然随 Vue 组件移除,但 AMap 内部可能保留了 detached DOM 引用,阻止 GC |
|
||
| **事件监听器** | 地图绑定的 `click`、`moveend`、`zoomend` 等事件仍挂在已卸载的 DOM 上,形成闭包引用链 |
|
||
| **定时器** | 地图内部的动画帧、瓦片加载定时器未被清除,持续消耗 CPU |
|
||
| **内存** | 单次泄漏可能只有 5~20MB,但用户切换 20 次页面后内存暴涨到 400MB+,移动端直接卡死或白屏 |
|
||
|
||
> 这就是"温水煮青蛙"式的内存泄漏——单次无害,累积致命。
|
||
|
||
### 3. `map.destroy()` 做了什么
|
||
|
||
`destroy()` 并非简单清空 DOM,而是分五步清理:
|
||
|
||
```
|
||
① 停止所有内部定时器(瓦片加载、动画帧、位置更新)
|
||
② 移除所有事件监听器(内置 + 用户自定义)
|
||
③ 销毁 WebGL 渲染上下文(释放 GPU 资源)
|
||
④ 移除所有覆盖物(Marker、Polygon、InfoWindow 等)← 注意!
|
||
⑤ 清空地图容器 DOM 的内容
|
||
```
|
||
|
||
**一个常被忽略的点**:`destroy()` 会销毁地图上的**所有覆盖物**,但如果你把 Marker 引用存入全局变量或 Vue reactive state,这些引用不会自动清空——它们变成了"僵尸引用"(指向已销毁的底层对象)。正确的做法:
|
||
|
||
```ts
|
||
// ✅ 正确:先清空引用再销毁
|
||
function destroyMap() {
|
||
markers.forEach(m => m.setMap(null)) // 逐个移除
|
||
markers.length = 0 // 清空引用数组
|
||
mapInstance.value?.destroy() mapInstance.value = null}
|
||
```
|
||
|
||
### 在 `useAmap()` 中的生命周期自动化
|
||
|
||
Q1 中 `useAmap()` 的 `destroyMap()` 配合 `onUnmounted` 自动调用,确保组件卸载时地图一定被销毁。这是防御性编程——即使开发者忘记手动调用,框架也会兜底。
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **`map.destroy()` 不止清 DOM——它释放 WebGL 上下文、解绑事件、停定时器、销毁覆盖物;不调用的后果是"切页 20 次,内存 400MB+"。生命周期自动化(`onUnmounted` 兜底)是防止人为遗忘的最后防线。**
|
||
|
||
---
|
||
|
||
## Q11:如何处理 GPS 原始坐标与地图坐标系的偏差?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
后端返回了一批通过硬件设备采集的 GPS 原始经纬度坐标,直接在高德地图上渲染出现了百米级偏差。请回答:
|
||
|
||
1. **为什么会偏差?WGS-84、GCJ-02、BD-09 分别代表什么?**
|
||
2. **前端有哪些方式进行坐标转换?各有什么优劣?**
|
||
3. **如果后端数据要同时给高德、腾讯、百度三个地图使用,架构上怎么设计?**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. 三大坐标系
|
||
|
||
| 坐标系 | 别名 | 使用方 | 说明 |
|
||
| ---------- | ------------------------- | ----------------------- | -------------------------------------------------------- |
|
||
| **WGS-84** | 地球坐标系 / GPS 原始坐标 | GPS 硬件、国际标准 | 最原始的经纬度,无任何偏移 |
|
||
| **GCJ-02** | 火星坐标系 / 国测局坐标 | 高德、腾讯、Google 中国 | 在 WGS-84 基础上加偏移算法(非线性),偏移量几十到几百米 |
|
||
| **BD-09** | 百度坐标系 | 百度地图 | 在 GCJ-02 基础上再次加密偏移 |
|
||
|
||
**为什么会有偏移?** 中国法律规定地图服务商必须使用 GCJ-02 加密坐标系,不得直接展示 WGS-84 原始坐标。偏移算法是非线性的——并非简单的"加几米"。
|
||
|
||
**关键结论**:GPS 硬件采集 → WGS-84;高德/腾讯地图 → GCJ-02;百度地图 → BD-09。
|
||
|
||
### 2. 前端坐标转换方案
|
||
|
||
| 方案 | 原理 | 优点 | 缺点 |
|
||
| ------------------------ | ------------------------------ | ---------------------------------------- | ----------------------------------------------------- |
|
||
| **`AMap.convertFrom()`** | 高德官方 API,上传坐标批量转换 | 官方保证精度,转换结果可直接用于高德地图 | 依赖网络请求(异步),有 QPS 限制,仅返回 GCJ-02 |
|
||
| **`gcoord` 库** | 纯前端 JS 算法,本地计算 | 离线可用,无网络依赖,支持所有坐标系互转 | 精度略低于官方 API(算法是逆向推算的),约 1~5 米误差 |
|
||
| **`coordtransform` 库** | 同上,纯 JS 算法 | 同上,体积更小 | 同上,维护不如 gcoord 活跃 |
|
||
|
||
**前端本地转换示例**(以 `gcoord` 为例):
|
||
|
||
```ts
|
||
import gcoord from 'gcoord'
|
||
|
||
// GPS 原始坐标 → 高德/腾讯可用坐标
|
||
const [gcjLng, gcjLat] = gcoord.transform(
|
||
[wgs84Lng, wgs84Lat], // 输入 WGS-84 gcoord.WGS84, // 从
|
||
gcoord.GCJ02 // 到
|
||
)
|
||
|
||
// GCJ-02 → 百度
|
||
const [bdLng, bdLat] = gcoord.transform(
|
||
[gcjLng, gcjLat], gcoord.GCJ02, gcoord.BD09)
|
||
```
|
||
|
||
### 3. 多地图架构设计
|
||
|
||
如果同一个后端服务要给高德、腾讯、百度三个前端地图提供数据,**推荐做法是在后端存储 WGS-84 原始坐标,各端按需自行转换**:
|
||
|
||
```
|
||
GPS 硬件 → 后端存 WGS-84(原始值)
|
||
│ ┌──────────┼──────────┐ ▼ ▼ ▼高德前端 腾讯前端 百度前端
|
||
WGS→GCJ-02 WGS→GCJ-02 WGS→GCJ-02→BD-09
|
||
(gcoord) (gcoord) (gcoord)
|
||
```
|
||
|
||
**为什么不存 GCJ-02?** GCJ-02 的偏移算法属于"黑盒"——虽然 `gcoord` 可以逆向推算回 WGS-84,但会有二次精度损失。存原始值,各端一次性转换,精度最高。
|
||
|
||
**为什么不通过后端提前转换?** 会引入不必要的耦合——后端需要感知前端使用哪个地图服务。保持后端返回原始 GPS 数据,各前端在自己这一层处理坐标转换,职责清晰。
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **GPS 设备出 WGS-84,高德/腾讯吃 GCJ-02,百度吃 BD-09;前端用 `gcoord` 离线转换或调 `AMap.convertFrom` 在线转换;后端始终存 WGS-84 原始值,各端按需自转,避免二次精度损失。**
|
||
|
||
---
|
||
|
||
## Q12:地图上展示 10000+ 个 Marker,如何防止卡顿?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
你的项目中需要在一个地图视野内展示 10000+ 个门店/共享单车的位置。如果直接 `new AMap.Marker` 一万次,页面会严重卡顿甚至崩溃。请回答:
|
||
|
||
1. **为什么普通 Marker 在万级数量下会卡顿?瓶颈在哪?**
|
||
2. **有哪些优化方案?各自的适用场景和上限是什么?**
|
||
3. **如果这 1 万个 Marker 需要不同的自定义样式(颜色、图标、大小),方案如何调整?**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. 普通 Marker 为什么卡
|
||
|
||
每个 `AMap.Marker` 都是独立的 DOM 节点。一万个 Marker = 一万个 `<div>` + 事件监听器 + 样式计算:
|
||
|
||
| 瓶颈 | 说明 |
|
||
| -------------- | ------------------------------------------------------------------------------------ |
|
||
| **DOM 节点数** | 一万个 DOM 节点,浏览器的布局、重绘、合成开销呈超线性增长 |
|
||
| **内存** | 每个 Marker 大约 2~5KB(DOM + 事件 + 内部对象),一万个 ≈ 20~50MB |
|
||
| **事件** | 每个 Marker 绑定 click/hover 等事件,事件委托做不到(AMap 内部实现限制) |
|
||
| **平移/缩放** | 每次地图移动,一万个 Marker 全部重新计算屏幕坐标并更新 DOM position → 帧率掉到个位数 |
|
||
|
||
### 2. 三种优化方案
|
||
|
||
#### 方案 A:点聚合(MarkerClusterer)
|
||
|
||
**原理**:低缩放级别时把邻近 Marker 合并为一个数字气泡(如"🔵 237"),放大到一定级别才展开为独立 Marker。
|
||
|
||
**适用**:数据有自然聚集特性(如城市门店分布),用户需要看清单个 Marker 的细节,有层级缩放交互需求。
|
||
|
||
**上限**:实际性能取决于"展开后可见 Marker 数",聚合状态本身很轻量。展开后是普通 DOM Marker,所以上限仍然是 2000~3000 个可见 Marker。
|
||
|
||
**加载方式**:
|
||
|
||
```ts
|
||
// 前提:AMAP_PLUGINS 中包含 'AMap.MarkerClusterer'const cluster = new AMap.MarkerClusterer(map, markers, {
|
||
gridSize: 80, // 聚合网格大小(像素)
|
||
maxZoom: 18, // 超过此级别不再聚合
|
||
averageCenter: true, // 聚合点取质心而非第一个点
|
||
})
|
||
```
|
||
|
||
#### 方案 B:海量点类(AMap.MassMarks)
|
||
|
||
**原理**:放弃 DOM,改用 **HTML5 Canvas** 在单一图层上绘制所有点。一个 Canvas 画布画十万个圆点 ≈ 0 个 DOM 节点。
|
||
|
||
**适用**:不需要复杂 HTML 自定义样式,只需展示点位/图标;用户不需要单独操作每个点(拖拽、右键菜单等)。
|
||
|
||
**上限**:轻松支持 10 万级,百万级开始吃力(Canvas 重绘耗时)。
|
||
|
||
```ts
|
||
const massMarks = new AMap.MassMarks(data, {
|
||
url: '/assets/marker-icon.png', // 统一图标
|
||
size: [20, 20], // 图标大小
|
||
anchor: new AMap.Pixel(10, 10), // 锚点
|
||
zIndex: 200,})
|
||
massMarks.setMap(map)
|
||
```
|
||
|
||
**MassMarks vs 普通 Marker 的核心差异**:
|
||
|
||
| 维度 | 普通 Marker | MassMarks |
|
||
| ----------- | ------------- | --------------------------------------- |
|
||
| 渲染方式 | 独立 DOM 节点 | Canvas 统一绘制 |
|
||
| 自定义 HTML | ✅ 支持 | ❌ 只支持图片 URL |
|
||
| 独立事件 | ✅ 每个可绑定 | ⚠️ 有统一 click 事件,通过 `e.data` 区分 |
|
||
| 拖拽 | ✅ | ❌ |
|
||
| 10 万点性能 | 崩溃 | 流畅 30fps+ |
|
||
|
||
#### 方案 C:Loca 数据可视化(WebGL)
|
||
|
||
**原理**:基于 Three.js 的 WebGL 渲染引擎,全部点位数据一次性上传到 GPU 缓冲区,利用 GPU 并行计算渲染。
|
||
|
||
**适用**:百万级数据、3D 可视化、动态特效(热力图、飞线、柱状图)。
|
||
|
||
**上限**:百万级流畅,千万级需要分片。
|
||
|
||
**缺点**:开发成本较高,需要额外引入 Loca 库,API 与核心 Marker 不兼容。
|
||
|
||
### 3. 需要自定义样式怎么办
|
||
|
||
如果一万个 Marker 需要**不同的颜色/图标/大小**,普通 Marker 扛不住,MassMarks 又不支持自定义 HTML。策略:
|
||
|
||
| 场景 | 方案 |
|
||
| ------------------------------------ | ------------------------------------------------------------------------------ |
|
||
| 几种固定样式(如红/黄/绿 3 种状态) | 使用 **多个 MassMarks 图层**,每种样式一个图层,按数据分类分别渲染 |
|
||
| 样式完全动态(如渐变颜色、旋转角度) | 升级到 **Loca** 的 `ScatterLayer` 或 `IconLayer`,支持按数据字段驱动颜色、大小 |
|
||
| 必须用 HTML 自定义但数量可控 | 先用聚类/MassMarks 展示全局,点击/搜索时按需创建少量 DOM Marker |
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **千级用普通 Marker + 聚合,万级用 MassMarks(Canvas),十万级用 Loca(WebGL)。关键是选对渲染引擎——DOM → Canvas → WebGL 性能逐级提升,但灵活性逐级下降。**
|
||
|
||
---
|
||
|
||
## Q13:如何做到"地图拖拽/缩放时只加载当前视野内的数据"?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
后端数据库有百万条地理数据,前端不可能一次性加载完。请设计一个方案,实现"用户拖拽或缩放地图时,只请求当前屏幕视野内的数据"。
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. 核心流程
|
||
|
||
```
|
||
地图视野变化
|
||
│ ├─ 'moveend' / 'zoomend' 事件
|
||
│ ├─ map.getBounds() → 获取当前视窗四至
|
||
│ ├─ NorthEast: [lng_max, lat_max] │ └─ SouthWest: [lng_min, lat_min] │ ├─ 防抖 300ms(避免拖拽过程中频繁请求)
|
||
│ ├─ GET /api/data?sw_lat=39.90&sw_lng=116.28&ne_lat=39.98&ne_lng=116.45 │ └─ 后端 WHERE lat BETWEEN sw_lat AND ne_lat AND lng BETWEEN sw_lng AND ne_lng```
|
||
|
||
### 2. 关键实现细节
|
||
|
||
#### 获取 BBox
|
||
|
||
```ts
|
||
map.on('moveend', () => {
|
||
const bounds = map.getBounds() const ne = bounds.getNorthEast() // { lng, lat } const sw = bounds.getSouthWest() // { lng, lat } // 传给后端
|
||
})
|
||
```
|
||
|
||
**注意**:`moveend` 在平移结束时触发,`zoomend` 在缩放结束时触发。两者是独立事件,需要同时监听。`zoomchange` 在缩放手势过程中触发(很频繁),不适合发请求。
|
||
|
||
#### 防抖(Debounce)
|
||
|
||
核心:用户连续拖拽时,每次 `moveend` 都触发但请求只发最后一次:
|
||
|
||
```ts
|
||
let debounceTimer: ReturnType<typeof setTimeout>
|
||
|
||
map.on('moveend', () => {
|
||
clearTimeout(debounceTimer) debounceTimer = setTimeout(() => fetchData(), 300)})
|
||
```
|
||
|
||
300ms 是一个平衡值——太短(100ms)依然频繁请求,太长(500ms+)用户感觉"数据加载延迟明显"。
|
||
|
||
#### 后端 SQL 优化
|
||
|
||
```sql
|
||
SELECT * FROM locations
|
||
WHERE lat BETWEEN #{sw_lat} AND #{ne_lat}
|
||
AND lng BETWEEN #{sw_lng} AND #{ne_lng}```
|
||
|
||
**必须给 `lat` 和 `lng` 建联合索引**,否则百万级表全表扫描必超时。
|
||
|
||
### 3. 进阶优化
|
||
|
||
#### 缩放自适应(与 Q6 同理)
|
||
|
||
缩放级别低(看全国)时视口范围大但不需要返回全量数据——用户不需要看几万个点:
|
||
|
||
| zoom | 策略 |
|
||
|------|------|
|
||
| < 8 | 返回聚合后的城市级汇总数据(如每省 Top 10) |
|
||
| 8~12 | 返回展平但抽稀后的数据(最多 2000 条) |
|
||
| > 12 | 按视口精确查询,返回全量 |
|
||
|
||
**做法**:前端把 `zoom` 也传给后端,后端在 SQL 中加 `LIMIT` 或调用预聚合表。
|
||
|
||
#### 视口裁剪(前端侧)
|
||
|
||
即使后端返回了当前视口的所有数据,地图上如果画了 Polyline 等覆盖物,也应只渲染视口内的部分:
|
||
|
||
```ts
|
||
const bounds = map.getBounds()
|
||
polylines.forEach(poly => {
|
||
// 判断 polyline 的 path 是否与 bounds 相交
|
||
const visible = poly.getPath().some(p => bounds.contains(p)) poly.setMap(visible ? map : null)})
|
||
```
|
||
|
||
这叫"前端视口裁剪",Q6 的方案八已详述。
|
||
|
||
#### 请求去重
|
||
|
||
如果防抖期间的旧请求还没返回,用户又拖到了新位置,应取消旧请求:
|
||
|
||
```ts
|
||
let abortController: AbortController | null = null
|
||
|
||
async function fetchData(bounds) {
|
||
abortController?.abort() // 取消旧请求
|
||
abortController = new AbortController() const res = await fetch(url, { signal: abortController.signal }) // ...}
|
||
```
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **`moveend` + `getBounds()` 取四至 → 防抖 300ms → 带 zoom 参数传给后端 → 后端联合索引范围查询 → 前端视口裁剪只渲染可见覆盖物。三层按需(请求按需、返回按需、渲染按需)缺一不可。**
|
||
|
||
---
|
||
|
||
## Q14:如何实现"骑手/车辆沿道路平滑移动"?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
后端每隔 5 秒推送一次骑手的最新经纬度。如果直接 `marker.setPosition(newPos)`,小车会在地图上闪烁跳跃。请回答:
|
||
|
||
1. **为什么直接 `setPosition` 会闪烁?**
|
||
2. **如何使用高德的 `moveTo` / `moveAlong` 实现平滑移动?**
|
||
3. **车头方向如何跟随路径自动旋转?**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. 直接 `setPosition` 的问题
|
||
|
||
两个 GPS 点之间间隔 5 秒,假设车速 40km/h,两点距离 ≈ 55 米。直接跳过去:
|
||
|
||
- 视觉上小车"瞬移"——上一帧在东,下一帧出现在 55 米外
|
||
- 地图的平滑惯性拖拽会放大这种跳跃感
|
||
- 没有"运动轨迹",用户感觉不真实
|
||
|
||
### 2. `moveTo` vs `moveAlong`
|
||
|
||
高德 Marker 提供了两个动画方法:
|
||
|
||
| 方法 | 行为 | 适用场景 |
|
||
| ------------------------------- | ---------------------------------------------- | ---------------------------------- |
|
||
| `marker.moveTo(lnglat, speed)` | 从当前位置**直线移动到目标点**,自动插值中间帧 | 单次位置更新(如 GPS 推送新坐标) |
|
||
| `marker.moveAlong(path, speed)` | 沿**折线路径**逐段移动,路径拐弯时自动跟随 | 已知完整路径(如路线规划后的动画) |
|
||
|
||
**参数说明**:
|
||
|
||
- `lnglat`:`[lng, lat]` 或 `AMap.LngLat` 目标位置
|
||
- `path`:坐标数组,如 `[[lng1,lat1], [lng2,lat2], ...]`
|
||
- `speed`:移动速度,单位 **公里/小时**(注意不是米/秒)
|
||
|
||
### 3. 实时推送场景的最佳实践
|
||
|
||
后端 5 秒推送一次,用 `marker.moveTo()` 实现平滑过渡:
|
||
|
||
```ts
|
||
// 收到 GPS 推送
|
||
function onGpsUpdate(newPos: [number, number]) {
|
||
// 计算合理的 speed:distance(m) / 5000(ms) = m/ms → km/h
|
||
const distance = AMap.GeometryUtil.distance(lastPos, newPos) // 米
|
||
const speed = (distance / 1000) / (5 / 3600) // km/h
|
||
marker.moveTo(newPos, speed)
|
||
// 更新角度(如果需要手动控制旋转)
|
||
const angle = AMap.GeometryUtil.getAngle(lastPos, newPos) marker.setAngle(angle) // 如果没设 autoRotation
|
||
lastPos = newPos}
|
||
```
|
||
|
||
**为什么 `moveTo` 比 `setPosition` 更好?** `moveTo` 内部使用 `requestAnimationFrame` 循环,在两个坐标之间做线性插值,视觉上小车是"滑过去"而不是"跳过去"。如果后端推送间隔是 5 秒,`moveTo` 的动画时长正好也是 5 秒,下一次推送到达时小车刚好滑到终点,视觉上连续无缝。
|
||
|
||
### 4. 车头方向旋转
|
||
|
||
| 方式 | 说明 |
|
||
| --------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||
| `autoRotation: true`(Marker 构造参数) | 配合 `moveAlong()` 使用,自动根据路径向量计算旋转角度。**只对 `moveAlong` 生效**,对 `moveTo` 无效 |
|
||
| `marker.setAngle(angle)` | 手动设置旋转角度。`AMap.GeometryUtil.getAngle(p1, p2)` 计算两点相对于正北方向的夹角 |
|
||
|
||
```ts
|
||
// 手动计算角度
|
||
const angle = AMap.GeometryUtil.getAngle(
|
||
new AMap.LngLat(prevLng, prevLat), new AMap.LngLat(currLng, currLat))
|
||
marker.setAngle(angle)
|
||
```
|
||
|
||
**注意**:`angle` 是相对于正北方向的顺时针角度(0°=北, 90°=东, 180°=南, 270°=西),这与常规数学坐标系不同。
|
||
|
||
### 5. 扩展:Q4 中的"路线规划 + 动画"
|
||
|
||
本场景(实时推送)与 Q4 的"路线规划后沿固定路径动画"不同:
|
||
|
||
| 维度 | Q4 场景 | 本场景 |
|
||
| -------- | ------------------------------------------- | ------------------------- |
|
||
| 数据来源 | 路线规划返回的完整路径 | 后端实时推送的离散 GPS 点 |
|
||
| 移动方法 | `setInterval` + `setPosition`(降采样路径) | `marker.moveTo()` |
|
||
| 路径已知 | 全部已知 | 只有当前目标点 |
|
||
| 方向控制 | 降采样点之间角度一致,无需额外计算 | 每次推送后需重新计算角度 |
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **实时推送用 `marker.moveTo(newPos, speed)` 替代 `setPosition`,内部 rAF 线性插值实现平滑滑动;车头方向用 `AMap.GeometryUtil.getAngle` 计算正北夹角 + `setAngle`,或对固定路径用 `autoRotation` + `moveAlong`。**
|
||
|
||
---
|
||
|
||
## Q15:如何实现电子围栏(GeoFence)与越界判断?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
共享单车禁停区、配送运营范围等业务需要在地图上绘制多边形电子围栏,并判断用户/车辆当前位置是否在围栏内。请回答:
|
||
|
||
1. **如何绘制和编辑多边形围栏?**
|
||
2. **如何判断一个点是否在多边形内部?**
|
||
3. **围栏数据如何与后端同步?格式怎么设计?**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. 绘制与编辑多边形
|
||
|
||
#### 绘制围栏
|
||
|
||
使用 `AMap.Polygon` + `AMap.MouseTool`(鼠标工具插件):
|
||
|
||
```ts
|
||
// 前提:AMAP_PLUGINS 中包含 'AMap.MouseTool'const mouseTool = new AMap.MouseTool(map)
|
||
|
||
// 进入多边形绘制模式
|
||
mouseTool.polygon({
|
||
strokeColor: '#FF33FF', strokeWeight: 2, fillColor: '#FF33FF', fillOpacity: 0.2,})
|
||
|
||
mouseTool.on('draw', (e) => {
|
||
const polygon = e.obj // AMap.Polygon 实例
|
||
const path = polygon.getPath() // 顶点坐标数组
|
||
// 保存 path 到后端
|
||
})
|
||
```
|
||
|
||
#### 编辑围栏
|
||
|
||
使用 `AMap.PolygonEditor` 插件,让运营人员可以拖拽修改围栏:
|
||
|
||
```ts
|
||
// 前提:AMAP_PLUGINS 中包含 'AMap.PolygonEditor'const editor = new AMap.PolygonEditor(map, polygon)
|
||
|
||
editor.open() // 进入编辑模式 → 顶点可拖拽、边可添加新顶点
|
||
editor.close() // 退出编辑模式
|
||
editor.on('adjust', () => {
|
||
const newPath = polygon.getPath() // 编辑后的新坐标
|
||
})
|
||
```
|
||
|
||
**交互设计要点**:编辑和查看是两个模式。查看模式下 polygon 不可编辑(避免误拖),点击"编辑围栏"按钮后才 `editor.open()`。
|
||
|
||
### 2. 点在多边形内判断
|
||
|
||
高德提供了 **`AMap.GeometryUtil`** 空间计算插件(需在 `AMAP_PLUGINS` 中声明 `'AMap.GeometryUtil'`):
|
||
|
||
```ts
|
||
const isInside = AMap.GeometryUtil.isPointInRing(
|
||
new AMap.LngLat(userLng, userLat), // 待判断的点
|
||
polygon.getPath() // 多边形顶点数组(环形)
|
||
)
|
||
// true → 在围栏内
|
||
// false → 在围栏外
|
||
```
|
||
|
||
**`isPointInRing` vs `isPointInPolygon`**:
|
||
|
||
- `isPointInRing(ring)`:判断是否在一个环内
|
||
- `isPointInPolygon(path)`:判断是否在一个多边形内(可能包含多个环/孔洞)
|
||
|
||
对于简单围栏,两者等价。对于有孔洞的复杂围栏(如"区域内但排除某个湖"),需要用 `isPointInPolygon`。
|
||
|
||
**性能**:GeometryUtil 的方法是纯 JS 计算(射线法),单次判断耗时 < 0.1ms。但如果要同时判断 **1 万个点在 500 个围栏中**(如"所有共享单车是否在禁停区"),需要优化为空间索引(R-Tree)。
|
||
|
||
### 3. 围栏数据的存储格式
|
||
|
||
多边形围栏本质上是一个**有序的坐标数组**:
|
||
|
||
```json
|
||
{
|
||
"id": "geofence-001", "name": "配送范围 - 朝阳区",
|
||
"type": "delivery_zone", "path": [ [116.443, 39.921], [116.451, 39.925], [116.455, 39.918], [116.447, 39.914] ], "updatedAt": "2025-01-15T10:30:00Z"}
|
||
```
|
||
|
||
**关键约束**:
|
||
|
||
- 顶点顺序必须是**顺时针或逆时针**(不能乱序)
|
||
- 首尾不需要闭合(AMap 会自动闭合)
|
||
- 坐标使用 **GCJ-02**(高德坐标系),如果后端存的是 WGS-84,前端加载后要先转换(见 Q11)
|
||
- 经纬度精度保留 **6 位小数**足够(约 0.1 米精度)
|
||
|
||
### 4. 越界告警的实时检测
|
||
|
||
场景:配送员骑行中,每 3 秒上报一次位置,需检测是否离开配送范围。
|
||
|
||
```
|
||
GPS 推送 → 坐标转换(WGS→GCJ) → isPointInRing → 结果
|
||
├─ true → 正常
|
||
└─ false → 弹窗告警 + 语音提示
|
||
```
|
||
|
||
**性能优化**:如果围栏数量多(如全国几千个运营区),不要遍历所有围栏判断。先用 BBox 粗略过滤——围栏的 BoundingBox 都不包含该点 → 直接跳过。
|
||
|
||
```ts
|
||
function isInAnyGeoFence(point, geoFences) {
|
||
return geoFences.some(fence => { // 粗筛:BBox 不包含 → 跳过
|
||
if (!fence.bbox.contains(point)) return false // 精判:射线法
|
||
return AMap.GeometryUtil.isPointInRing(point, fence.path) })}
|
||
```
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **`AMap.MouseTool` 绘制 + `PolygonEditor` 编辑 → 围栏数据存为有序坐标数组 → `GeometryUtil.isPointInRing()` 判断越界 → 先用 BBox 粗筛、再射线法精判,多围栏场景避免全量遍历。**
|
||
|
||
---
|
||
|
||
## Q16:物流场景下多途经点路线如何规划?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
司机从 A 点出发,途中去 B、C、D 等 10 个地方送货,最后到达 E 点。请回答:
|
||
|
||
1. **如何使用高德规划多途经点路线?**
|
||
2. **高德途经点有数量上限吗?超出上限怎么处理?**
|
||
3. **如何优化途经点的顺序?(旅行商问题 TSP)**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. `AMap.Driving` 多途经点规划
|
||
|
||
`Driving.search()` 支持 waypoints 参数,可以传入途经点数组。但要注意**途经点有顺序**——高德不会自动帮你排序(不会解决 TSP 问题),它按照你传入的顺序依次经过。
|
||
|
||
```ts
|
||
const driving = new AMap.Driving({
|
||
map: map, policy: 0, // 0=速度优先, 1=费用优先, 2=距离优先
|
||
autoFitView: true, // 自动调整视野
|
||
})
|
||
|
||
driving.search(
|
||
start, // 起点 LngLat
|
||
end, // 终点 LngLat
|
||
{ waypoints: [B, C, D], // 途经点数组(按此顺序经过)
|
||
extensions: 'all', // 返回详细信息
|
||
}, (status, result) => {
|
||
if (status === 'complete') { // result.routes[0] → 完整路线
|
||
}
|
||
})
|
||
```
|
||
|
||
**注意**:Q4 中的 `Driving.search` 用于两点路线,本场景是**多途经点**版本,使用方式一样但多了 `waypoints` 参数。基础流程(Promise 包装、路径拼接、折线绘制)完全一致。
|
||
|
||
### 2. 途经点上限与超限处理
|
||
|
||
高德原生 **最多支持 16 个途经点**(含起终点总计 18 个点)。如果业务有 20+ 个途经点,需要分段:
|
||
|
||
```
|
||
方案一:服务端分段
|
||
后端对 20 个点做路径拓扑排序 → 分段为 A→F→K、K→P→E
|
||
前端分两次调用 driving.search(),用两条 Polyline 绘制
|
||
|
||
方案二:前端分段
|
||
将途经点按地理位置分组(如按城市/区域)
|
||
每组内调用一次 driving.search() 组之间的连接用直线(或调用一次无途经点的短程规划)
|
||
```
|
||
|
||
**服务端分段更优**:因为后端可以一次性跑完 TSP 排序并分段,前端只需按顺序请求。前端分段的"组间连接"精度差且实现复杂。
|
||
|
||
### 3. TSP 排序(旅行商问题)
|
||
|
||
高德 **不会** 帮你优化途经点的访问顺序。如果用户只是随意添加了 B、C、D 三个点,按现有顺序可能会绕远路。但 **TSP 是一个 NP-hard 问题**,精确求解在途经点 > 10 时耗时过长。
|
||
|
||
实际做法:
|
||
|
||
| 途经点数 | 策略 |
|
||
| -------- | ------------------------------------------------------------ |
|
||
| ≤ 8 | 暴力枚举全排列,取总距离最短者(8! = 40320,前端可承受) |
|
||
| 9~20 | 使用**最近邻贪心算法**或**遗传算法**逼近最优解,放在后端计算 |
|
||
| 20+ | 配合分段策略,每组内再贪心排序 |
|
||
|
||
```ts
|
||
// 最近邻贪心(前端轻量实现)
|
||
function greedyTSP(points: LngLat[]): LngLat[] {
|
||
const result = [points[0]] // 从第一个点开始
|
||
const remaining = points.slice(1)
|
||
while (remaining.length > 0) { const last = result[result.length - 1] let nearestIdx = 0 let nearestDist = Infinity
|
||
remaining.forEach((p, i) => { const d = last.distance(p) // AMap LngLat 自带 distance 方法
|
||
if (d < nearestDist) { nearestDist = d nearestIdx = i } })
|
||
result.push(remaining[nearestIdx]) remaining.splice(nearestIdx, 1) }
|
||
return result}
|
||
```
|
||
|
||
**提醒**:TSP 排序的结果可能不是用户期望的顺序(如必须先送药品、后送普通货物)。如果业务有优先级约束,不要完全依赖算法排序——把"必须按某顺序"的点前置固定,只对"无所谓顺序"的点做优化。
|
||
|
||
### 4. 完整流程
|
||
|
||
```
|
||
后端返回 15 个配送点
|
||
│ ├─ 后端 TSP 排序(贪心)→ 优化路径顺序
|
||
│ ├─ 前端拿到排序后的 15 个点
|
||
│ ├─ 调用 driving.search(start, end, { waypoints: 15个点 })
|
||
│ → 未超 16 上限,一次搞定
|
||
│ ├─ 从 result.routes[0].steps 提取完整 path
|
||
│ └─ new AMap.Polyline({ path }) + map.add()
|
||
```
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **`Driving.search` + `waypoints` 参数支持多途经点,上限 16 个;超出则服务端分 2 段;途经点顺序需自行做 TSP 排序(≤8 全排列,>8 贪心),高德不做自动排序。**
|
||
|
||
---
|
||
|
||
## Q17:如何自定义地图样式?内网环境能离线用高德吗?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
公司大屏项目需要暗黑科技风格的地图,原生样式不符合需求。另外,系统可能部署在完全隔离内网的政府/军队环境。请回答:
|
||
|
||
1. **如何自定义高德地图的样式?**
|
||
2. **高德 JSAPI 支持离线部署吗?如果不支持,替代方案是什么?**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. 自定义地图样式
|
||
|
||
#### 方式一:自定义地图平台(推荐)
|
||
|
||
通过高德官方"自定义地图"平台(`https://lbs.amap.com/dev/mapstyle/`)可视化配置:
|
||
|
||
1. 在线调整地图元素样式(底色、道路颜色、建筑颜色、文字颜色等)
|
||
2. 保存后生成一个 `style_id`,形如 `amap://styles/darkblue`
|
||
3. 在地图初始化时传入 `mapStyle` 参数
|
||
|
||
```ts
|
||
const map = new AMap.Map(container, {
|
||
mapStyle: 'amap://styles/darkblue', // 高德预设暗色风格
|
||
// 或使用自定义 style_id // mapStyle: 'amap://styles/2a9e5f8c3b7d4e6f1a0b2c3d4e5f6a7b',})
|
||
```
|
||
|
||
**高德内置了几个预设风格**:
|
||
|
||
| style_id | 效果 |
|
||
| -------------------------- | ---------- |
|
||
| `amap://styles/normal` | 默认标准 |
|
||
| `amap://styles/dark` | 暗黑风格 |
|
||
| `amap://styles/light` | 浅色风格 |
|
||
| `amap://styles/whitesmoke` | 白烟风格 |
|
||
| `amap://styles/fresh` | 清新风格 |
|
||
| `amap://styles/grey` | 灰色风格 |
|
||
| `amap://styles/graffiti` | 涂鸦风格 |
|
||
| `amap://styles/macaron` | 马卡龙风格 |
|
||
| `amap://styles/darkblue` | 暗夜蓝风格 |
|
||
| `amap://styles/blue` | 极夜蓝风格 |
|
||
|
||
#### 方式二:自定义 Mapbox 风格规范
|
||
|
||
高德 JSAPI v2.0 支持 Mapbox 风格规范的 JSON。你可以在自定义地图平台导出 JSON 配置,直接传入:
|
||
|
||
```ts
|
||
const map = new AMap.Map(container, {
|
||
mapStyle: 'amap://styles/darkblue', features: ['bg', 'road', 'building'], // 控制图层显示(见 Q8)
|
||
showLabel: true, // 是否显示标注
|
||
showBuildingBlock: true, // 是否显示 3D 建筑
|
||
})
|
||
```
|
||
|
||
**结合 Q8 的知识**:在自定义样式的地图上叠加 ImageLayer 时,可以用 `setFeatures` 进一步去掉 POI 文字标注,实现完全自定义的视觉效果。
|
||
|
||
### 2. 离线部署问题
|
||
|
||
#### 高德官方不支持完全离线
|
||
|
||
**结论:高德 JSAPI 不支持完全离线部署。** 原因:
|
||
|
||
| 依赖项 | 说明 |
|
||
| -------------- | ----------------------------------------------------------------------- |
|
||
| **地图瓦片** | 地图图片切片托管在高德 CDN 上,`https://wprd0{1-4}.is.autonavi.com/...` |
|
||
| **字体文件** | POI 标注的字体文件动态加载 |
|
||
| **插件脚本** | `AMap.Driving`、`AMap.Geolocation` 等插件的 JS 文件从 CDN 按需加载 |
|
||
| **服务端 API** | 路线规划、地理编码、逆地理编码等依赖高德服务端接口 |
|
||
| **用户协议** | 高德的使用条款禁止离线缓存瓦片 |
|
||
|
||
#### 替代方案:OpenLayers / Leaflet + GeoServer
|
||
|
||
对于**完全断网的内网环境**,技术栈需要完全替换:
|
||
|
||
```
|
||
公网方案: 内网方案:
|
||
高德 JSAPI OpenLayers / Leaflet高德瓦片 CDN → 本地 GeoServer + 预下载瓦片
|
||
高德服务端 API → 自行实现或使用开源替代(如 OSRM 路线规划)
|
||
高德定位服务 → 纯前端 IP 定位 或 不上定位功能
|
||
```
|
||
|
||
**三步落地**:
|
||
|
||
1. **搭建瓦片服务器**:部署 GeoServer 或直接 Nginx 静态文件服务
|
||
2. **下载地图瓦片**:使用开源工具(如 `tile-dl`、QGIS)在联网环境预先下载目标区域的地图瓦片切片(通常下载到 zoom 3-18 级别)
|
||
3. **替换前端渲染框架**:用 OpenLayers 或 Leaflet 替代高德 JSAPI,配置 `tileLayer` 指向内网的瓦片服务器
|
||
|
||
```ts
|
||
// Leaflet 离线示例
|
||
import L from 'leaflet'
|
||
|
||
const map = L.map('map').setView([39.9, 116.4], 13)
|
||
|
||
L.tileLayer('http://内网IP:8080/tiles/{z}/{x}/{y}.png', {
|
||
maxZoom: 18,}).addTo(map)
|
||
```
|
||
|
||
**注意**:
|
||
|
||
- 离线瓦片占用空间大(一个城市的 3-18 级瓦片约 10~50GB)
|
||
- 需要定期更新瓦片(地图数据会变化——新道路、新建筑)
|
||
- OpenLayers/Leaflet 只负责渲染,没有路线规划、地理编码等高级功能——这些需要额外搭建 OSRM、Nominatim 等开源服务
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **自定义样式用高德地图平台生成 `style_id` + 9 种内置预设(如 `darkblue`);离线部署高德官方不支持,内网场景需换 OpenLayers/Leaflet + 自建 GeoServer + 预下载瓦片,且高级功能(路线规划等)需自行搭建开源替代。**
|
||
|
||
---
|
||
|
||
## Q18:地图上有数百个 Marker 时,如何避免 Vue/React 响应式系统导致的性能问题?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
地图上有数百个 Marker。点击一个 Marker 需要高亮它、弹出 InfoWindow 并同步更新左侧列表的选中状态。如果直接将地图相关数据绑定到框架的 State/Ref 中,往往引发高频重绘导致卡顿。请回答:
|
||
|
||
1. **为什么把地图实例或 Marker 放入响应式状态会卡?**
|
||
2. **InfoWindow 应该如何管理才能避免频繁创建销毁?**
|
||
3. **Marker 事件 → 框架状态 → UI 更新的"双向桥"如何设计?**
|
||
|
||
> 注:Q2 已详细说明 `shallowRef` vs `ref`,本题侧重**交互层**的状态流设计。
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. 地图对象与响应式的冲突
|
||
|
||
Vue 的 `ref()` / `reactive()` 和 React 的 `useState()` 都会对存入的数据做深度代理/追踪。当地图实例(`AMap.Map` 是一个包含数百个属性、WebGL 上下文、事件循环的巨型对象)被放入响应式系统:
|
||
|
||
| 后果 | 说明 |
|
||
| ------------------- | -------------------------------------------------------------------------------------------------- |
|
||
| **初始化阻塞** | Vue 的 `ref()` 递归代理整个 Map 对象树(数百个嵌套属性),主线程卡顿 100~500ms |
|
||
| **运行时抖动** | 地图内部的动画帧(每秒 60 帧)持续修改内部属性,触发 Proxy setter → 框架每帧都尝试重新渲染 |
|
||
| **`this` 指向错乱** | 部分 AMap 方法内部依赖 `this` 指向原始对象,Proxy 包装后 `this` 指向 Proxy,导致方法调用失败或异常 |
|
||
|
||
**解决方案**(Q2 已展开):`mapInstance` 用 `shallowRef`,`Marker` 数组用普通 JS 变量或 `shallowRef`,不让响应式系统深度干预第三方对象。
|
||
|
||
```ts
|
||
const mapInstance = shallowRef<AMap.Map | null>(null) // ✅ 只追踪整体替换
|
||
const markers: AMap.Marker[] = [] // ✅ 普通数组,不响应式
|
||
const selectedMarkerId = ref<string | null>(null) // ✅ 只追踪选中 ID
|
||
```
|
||
|
||
### 2. InfoWindow 复用策略
|
||
|
||
**核心思想**:InfoWindow 只有一个实例,点击不同 Marker 时只改内容和位置,不销毁重建。
|
||
|
||
```ts
|
||
// ❌ 错误:每次点击都创建新的 InfoWindowfunction
|
||
onMarkerClick(marker, data) {
|
||
new AMap.InfoWindow({ content: buildInfoHtml(data) }).open(map, marker.getPosition()) // 上一个 InfoWindow 没销毁 → 地图上越堆越多
|
||
}
|
||
|
||
// ✅ 正确:复用同一个 InfoWindowlet sharedInfoWindow: AMap.InfoWindow | null = null
|
||
|
||
function onMarkerClick(marker, data) {
|
||
if (!sharedInfoWindow) {
|
||
sharedInfoWindow = new AMap.InfoWindow({ offset: new AMap.Pixel(0, -30) }) } sharedInfoWindow.setContent(buildInfoHtml(data))
|
||
sharedInfoWindow.open(map, marker.getPosition())
|
||
}
|
||
```
|
||
|
||
**复用的好处**:
|
||
|
||
- 避免 DOM 创建/销毁开销
|
||
- InfoWindow 内部的 DOM 事件监听器不会泄漏
|
||
- 视觉上更流畅(同一个气泡移动 vs 旧消失新出现)
|
||
|
||
### 3. 事件 → 状态 → UI 的三层桥接
|
||
|
||
```
|
||
高德 Marker 点击事件(原生 DOM 事件)
|
||
│ ▼更新 light-weight 响应式状态(只存 ID 或数据索引)
|
||
│ selectedMarkerId.value = data.id │ ▼框架自动更新 UI ├─ 左侧列表高亮选中项
|
||
├─ 右侧详情面板刷新数据
|
||
└─ 地图上更新 Marker 外观(高亮/缩小)
|
||
```
|
||
|
||
**关键原则**:**单向数据流桥接**。Marker 点击 → 只改一个 `selectedId`。高亮逻辑由"该 Marker 的 id 是否等于 selectedId"计算得出,而不是在点击事件里操作 DOM。
|
||
|
||
```ts
|
||
// 点击事件
|
||
marker.on('click', (e) => {
|
||
selectedId.value = markerData.id // 只改一个轻量状态
|
||
updateInfoWindow(markerData) // 复用 InfoWindow})
|
||
|
||
// 高亮逻辑:在 computed 或 watch 中处理
|
||
watch(selectedId, (newId, oldId) => {
|
||
// 取消旧高亮
|
||
if (oldId) markerMap.get(oldId)?.setContent(buildNormalContent(oldId)) // 设置新高亮
|
||
if (newId) markerMap.get(newId)?.setContent(buildHighlightContent(newId))})
|
||
```
|
||
|
||
**为什么不直接在 click 事件里改 Marker DOM?** 如果有多处 UI(列表、详情面板、地图 Marker)需要响应这次点击,直接在 DOM 里分散操作会导致状态不一致——Marker 高亮了但列表没选中,或反过来。通过一个响应式 `selectedId` 作为唯一真相源,所有 UI 层都从它派生。
|
||
|
||
### 4. 事件委托优化
|
||
|
||
如果有 500 个 Marker,每个绑定独立 click 事件 = 500 个事件监听器 = 内存浪费:
|
||
|
||
```ts
|
||
// ❌ 500 个监听器
|
||
markers.forEach(m => m.on('click', handler))
|
||
|
||
// ✅ 一个监听器(如果用的是 MassMarks)
|
||
massMarks.on('click', (e) => {
|
||
const data = e.data // 通过 data 字段区分点击的是哪个点
|
||
selectedId.value = data.id})
|
||
```
|
||
|
||
对于普通 Marker,高德没有原生的事件委托机制。但可以通过**在地图层面监听 click 事件 + 手动判断**来模拟(不推荐,实现复杂且精度差)。务实做法:普通 Marker 在 500 个以内各自绑定事件是可接受的;超过 500 个应切换到 MassMarks + 统一事件。
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **地图对象不入响应式(`shallowRef` + 普通变量)→ InfoWindow 只创一次、复用 `setContent` + `open` → 交互走单向数据流桥:Marker 事件只改 `selectedId`,所有 UI 层(列表、面板、地图高亮)从该状态派生,避免多源状态不一致。**
|
||
|
||
---
|
||
|
||
## Q19:H5 移动端定位失败怎么排查和兜底?
|
||
|
||
---
|
||
|
||
## 题目
|
||
|
||
在 H5 移动端网页中使用高德 `AMap.Geolocation` 插件,经常出现定位失败,提示"Get geolocation time out"或"Permission denied"。请回答:
|
||
|
||
1. **从哪些维度去排查定位失败的原因?**
|
||
2. **有哪些兜底策略可以保证用户体验不中断?**
|
||
3. **如果 H5 嵌套在 App/小程序中,如何利用原生定位能力?**
|
||
|
||
---
|
||
|
||
## 参考答案
|
||
|
||
### 1. 失败原因排查清单
|
||
|
||
按排查优先级排列:
|
||
|
||
| 优先级 | 原因 | 现象 | 解决 |
|
||
| ------ | ----------------------------- | ----------------------------------------------- | ------------------------------------------------------ |
|
||
| **P0** | HTTP 协议 | 浏览器直接拒绝 Geolocation API 调用 | 部署到 **HTTPS**(iOS 10+ / Android 7+ 强制要求) |
|
||
| **P0** | 用户拒绝权限 | `Permission denied` | 引导用户去系统设置中开启定位权限(无法代码绕过) |
|
||
| **P1** | 微信/浏览器内核限制 | 微信内置浏览器、UC 浏览器等可能拦截 Geolocation | 引导用户用系统自带浏览器打开,或走 JSBridge |
|
||
| **P1** | 室内 / 地下室 | GPS 信号无法穿透建筑 | 降级到 IP 定位或 WiFi 定位 |
|
||
| **P2** | `AMap.Geolocation` 插件未加载 | `AMap.Geolocation is not a constructor` | 确保 `AMAP_PLUGINS` 中包含 `'AMap.Geolocation'` |
|
||
| **P2** | 安全密钥配置错误 | `INVALID_USER_SCODE` | `_AMapSecurityConfig` 必须在 JSAPI 加载前设置(见 Q1) |
|
||
| **P3** | HTTPS 证书问题 | 自签名/过期证书也可能被浏览器拒绝定位 | 使用正规 CA 签发的证书 |
|
||
|
||
**为什么 HTTP 必失败?** Chrome 从 50 版本、Safari 从 iOS 10 开始,`navigator.geolocation.getCurrentPosition()` 只能在"安全上下文"(HTTPS 或 localhost)下调用。这是浏览器层面的强制限制,与高德无关。
|
||
|
||
### 2. 兜底策略:多层降级
|
||
|
||
```
|
||
① 尝试 GPS 高精度定位
|
||
├─ 成功 → 使用精确定位结果
|
||
└─ 失败 ↓
|
||
② 降级到 IP 定位(AMap.CitySearch)
|
||
├─ 成功 → 定位到城市/区级(精度数公里)
|
||
└─ 失败 ↓
|
||
③ 降级到默认中心点
|
||
└─ 显示地图默认位置(如天安门/上海外滩),用户手动搜索
|
||
```
|
||
|
||
```ts
|
||
async function locate() {
|
||
try { // 第 ① 层:GPS 高精度定位
|
||
const position = await getGpsPosition() return { lng: position.lng, lat: position.lat, precision: 'gps' }
|
||
} catch (err) { console.warn('GPS 定位失败:', err.message)
|
||
try { // 第 ② 层:IP 城市定位
|
||
const city = await getCityByIP() const center = await getCityCenter(city) // 地理编码获取城市中心坐标
|
||
return { lng: center.lng, lat: center.lat, precision: 'city' }
|
||
} catch (err2) { // 第 ③ 层:默认位置
|
||
console.warn('IP 定位也失败:', err2.message)
|
||
return { lng: 116.397, lat: 39.908, precision: 'default' }
|
||
} }}
|
||
```
|
||
|
||
**`AMap.Geolocation` 与 `AMap.CitySearch` 的区别**:
|
||
|
||
| 方法 | 原理 | 精度 | 耗时 | 依赖 |
|
||
| ------------------ | --------------------------------------------------- | ------------------- | ------- | ---------------- |
|
||
| `AMap.Geolocation` | 浏览器原生 `navigator.geolocation`(GPS/WiFi/基站) | 5~50 米 | 3~15 秒 | HTTPS + 用户授权 |
|
||
| `AMap.CitySearch` | IP 地址解析 | 数公里(城市/区级) | < 1 秒 | 无(纯网络请求) |
|
||
|
||
### 3. App/小程序环境的原生桥接
|
||
|
||
如果 H5 嵌套在自研 App 或微信小程序中,应**放弃浏览器定位,改用原生能力**:
|
||
|
||
#### 微信小程序 WebView
|
||
|
||
```ts
|
||
// 小程序侧 → 调 wx.getLocation → 通过 URL 参数或 postMessage 传给 H5wx.getLocation({
|
||
type: 'gcj02', // 返回 GCJ-02 坐标,高德直接可用
|
||
success: (res) => { // 通过 JSSDK 或 URL 参数将经纬度注入 H5 webView.postMessage({ lng: res.longitude, lat: res.latitude }) }})
|
||
```
|
||
|
||
#### 自研 App WebView(JSBridge)
|
||
|
||
```ts
|
||
// H5 侧调用 JSBridgeconst pos = await window.NativeBridge.call('getCurrentPosition')
|
||
// Native 侧:Android → LocationManager, iOS → CLLocationManager
|
||
// Native 拿到 WGS-84 坐标后,在传回 H5 前转为 GCJ-02(或由 H5 侧转)
|
||
```
|
||
|
||
**为什么原生定位比浏览器定位更可靠?**
|
||
|
||
- 原生 API 不受浏览器 HTTPS 限制
|
||
- 系统级定位权限弹窗的信任度更高,用户更可能通过
|
||
- 可以控制超时时间、精度模式等参数
|
||
- 可以结合基站/WiFi 辅助定位(A-GPS),室内场景可用性更好
|
||
|
||
### 4. 用户体验细节
|
||
|
||
| 场景 | 做法 |
|
||
| ---------------------- | --------------------------------------------------------------------------- |
|
||
| 定位中 | 显示加载动画 + "正在获取位置..." + 超时 15 秒自动降级 |
|
||
| 定位失败 | Toast 提示原因 + 提供"手动选择城市"入口 |
|
||
| 精度低(IP 定位) | 在地图角落标注"📍 粗略定位",让用户知道可能不准 |
|
||
| 用户拒绝授权后再次尝试 | 弹窗引导 → 不会自动弹系统权限框(浏览器不允许),需引导用户手动进入系统设置 |
|
||
|
||
---
|
||
|
||
## 总结一句话
|
||
|
||
> **定位失败先查 HTTPS + 权限 → 三层降级:GPS → IP 城市定位 → 默认中心点 → App/小程序环境走 JSBridge 原生定位(可靠度最高)。关键是每一层都有 fallback,保证用户体验不因定位失败而中断。**
|