feat: 面试题更新

This commit is contained in:
2026-06-25 15:58:23 +08:00
parent 98d1cebd20
commit 7e70c18f7e
3 changed files with 853 additions and 275 deletions

117
REASONIX.md Normal file
View File

@@ -0,0 +1,117 @@
# REASONIX.md
## 项目概述
**microapp-vue3** — 基于 Vue 3 + TypeScript + Vite 的微前端子应用,使用 `@micro-zoe/micro-app` 框架集成到主应用中。同时集成了高德地图 JSAPI提供地图展示、地理围栏、路径轨迹等功能。
- **类型**: 前端微应用(子应用)
- **运行环境**: 浏览器(支持独立运行 & micro-app 微前端环境)
- **默认端口**: `3001`
---
## 技术栈
| 类别 | 技术 | 版本 |
|------|------|------|
| 框架 | Vue 3 (Composition API) | ^3.5 |
| 语言 | TypeScript | ~6.0 |
| 构建 | Vite | ^8.0 |
| 路由 | Vue Router | ^4.6 |
| 微前端 | @micro-zoe/micro-app | ^1.0.0-rc.31 |
| 地图 | @amap/amap-jsapi-loader | ^1.0.1 |
---
### 环境变量
`.env` 中配置高德地图 Key`VITE_` 为前缀暴露给客户端):
```
VITE_AMAP_JSAPI_KEY=<your-key>
VITE_AMAP_WEB_KEY=<your-web-key>
VITE_AMAP_SECURITY_CODE=<your-security-code>
```
---
## 架构设计
### 微前端生命周期
`src/main.ts` 中实现了 `mount()` / `unmount()` 两个生命周期函数:
- **微前端环境** (`window.__MICRO_APP_ENVIRONMENT__` 为 truthy):导出 `{ mount, unmount }``window['micro-app-<appName>']`,由 micro-app 框架调用
- **独立运行**:直接调用 `mount()` 挂载到 `#microapp-vue3-root`
### 路由
- 使用 `createWebHistory``baseroute` 动态适配:
- 微前端环境:使用 `window.__MICRO_APP_BASE_ROUTE__`
- 独立运行:使用 `/`
- 所有路由均使用懒加载 (`() => import(...)`)
- 挂载点使用独立 id `#microapp-vue3-root`(避免与主应用 `#app` 冲突)
### 跨应用通信
通过 micro-app 的 `globalData` 机制实现:
- **发送**: `window.microApp.setGlobalData({ key: value })`
- **接收**: `window.microApp.addGlobalDataListener(callback, autoTrigger)`
- 示例见 `src/views/Home.vue` 中的共享计数器
### 高德地图集成
- **单例加载**: `loadAMap()` 确保 JSAPI 全局只加载一次,所有地图实例共享
- **Composable 模式**: `useAmap()` 返回 `containerRef``mapInstance``loading``error``initMap``destroyMap`
- **配置集中管理**: 所有 AMap 配置Key、版本、插件列表`src/config/amap.ts` 中统一维护
- **MapPanel 组件**: 每个 MapPanel 有独立的 DOM 容器和地图实例,可多个同时存在
---
## 编码规范
### Vue 组件
- 使用 `<script setup lang="ts">` 语法
- 样式默认 `scoped`,避免样式泄漏到主应用
- 全局样式使用 `#microapp-vue3-container` 选择器前缀隔离
- 组件命名PascalCase 文件名(如 `MapPanel.vue`
### TypeScript
- 启用严格检查:`noUnusedLocals``noUnusedParameters``noFallthroughCasesInSwitch`
- 使用 `erasableSyntaxOnly`TS 6.0 特性)
- 路径别名:`@/` 映射到 `./src/`
- 环境变量类型通过 `vite-env.d.ts` 扩展
### Composable
- 文件命名:`use<Name>.ts`,放在 `src/composables/`
- 返回响应式状态 + 操作方法
- 使用 `onUnmounted` 清理资源(如销毁地图实例)
### 注释
- 使用中文注释,区块使用分隔线标记
- 重要配置项附带说明和官方文档链接
---
## 微前端注意事项
1. **挂载点命名**: 子应用挂载点不能使用 `#app`,必须使用独立命名的 id
2. **自定义元素**: 子应用中嵌套 `<micro-app>` 标签时,需要在 `vite.config.ts``vue.template.compilerOptions.isCustomElement` 中注册
3. **CORS**: 开发服务器需设置 `Access-Control-Allow-Origin: *` 允许主应用跨域请求
4. **base 路径**: 构建时需设置与主应用 `baseroute` 一致的 `base`(当前注释掉,独立运行时使用默认 `/`
5. **样式隔离**: 全局样式使用子应用容器选择器前缀,组件样式使用 `scoped`
## 重要
在回答了用户问题如果这个问题与项目相关就要整理成面试题在帮用户回答完问题之后整理成面试题写入到docs/AMap面试题.md 中去每个回答最好100行以内就能说明清楚。
尽量少出现源码,用文字说明清楚就可以了。
在原有的Q1Q2的基础上累加问题这个是用户最关心的内容非常重要请务必认真写。

View File

@@ -2,320 +2,502 @@
---
## Q1为什么 mapInstance 使用 shallowRef
> 对应源码:`src/composables/useAmap.ts` 第 70 行
## Q1如何在 Vue 3 中集成高德地图AMap
---
## 题目
在下面的 `useAmap` 组合式函数中,`mapInstance`(第 70 行)使用了 `shallowRef` 而非 `ref`。请回答以下问题
请你设计一个 Vue 3 组合式函数Composable来集成高德地图 JSAPI要求
1. 支持在多个页面中独立使用,但 SDK 脚本只下载一次
2. 安全密钥在脚本加载之前正确设置
3. 地图容器在 flex 布局中能正确渲染
1. **`shallowRef``ref` 的区别是什么?**
2. **为什么这里必须(或更适合)使用 `shallowRef`?请从性能、副作用、语义三个维度分析。**
3. **如果把 `shallowRef` 改成 `ref`,会出现什么问题?**
参考代码:
```typescript
export function useAmap(options: AMap.MapOptions = {}) {
const containerRef = ref<HTMLDivElement | null>(null) // ← DOM 引用用 ref
const mapInstance = shallowRef<AMap.Map | null>(null) // ← 地图实例用 shallowRef
const loading = ref(false)
const error = ref<string | null>(null)
// ...
}
```
请描述关键实现步骤和设计决策。
---
## 参考答案
### 1. `shallowRef` 和 `ref` 的区别
### 1. 配置文件(`src/config/amap.ts`
| 特性 | `ref` | `shallowRef` |
|------|-------|-------------|
| 深度响应式 | ✅ 递归地对 `.value` 的所有嵌套属性进行 Proxy 代理 | ❌ 仅对 `.value` 本身的替换做响应式追踪 |
| 触发更新的时机 | 任意深层属性的修改都会触发视图更新 | **仅当 `.value` 整体被重新赋值时**才会触发更新 |
| 性能开销 | 大对象初始化时有显著的递归代理开销 | 几乎零开销,仅监听顶层引用变化 |
| 适用场景 | 组件内部状态、表单数据等需要逐属性追踪的场景 | 第三方类实例、大型只读对象、不可变数据 |
集中管理 Key、安全密钥、版本号和插件列表
Vue 3 源码层面的本质区别:
- `ref` 内部会调用 `reactive`(或 `toReactive`)对 `.value` 做一次深度 `Proxy` 包装。
- `shallowRef``.value` **不会被 `reactive` 处理**,只依赖 `getter/setter` 中的 `triggerRef` 机制。
| 配置项 | 说明 |
|--------|------|
| `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. 为什么这里必须使用 `shallowRef`
### 2. `loadAMap()` — SDK 全局单例加载
#### 维度一:性能
`AMap.Map` 是高德地图的核心类,实例化后内部包含极其庞大的对象树:
用两个模块级变量实现双重锁:
```
AMap.Map 实例(示意结构
├── _layers: Layer[] → 图层管理器
├── _overlays: OverlayGroup[] → 叠加物组
├── _status: { ... } → 数十个状态字段
├── _canvas: HTMLCanvasElement → WebGL 渲染上下文
├── _events: Map<string, Handler[]> → 事件系统
└── ...数百个内部属性/方法
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
```
如果改用 `ref`Vue 会在 `mapInstance.value = new AMap.Map(...)` 这条赋值语句执行时,**递归遍历整个地图实例的每一层属性**,为它们全部创建 Proxy 代理。这个过程会
关键设计
- **Promise 去重**:多个组件同时调用,只发一次网络请求
- **失败可重试**`catch` 中重置 `amapPromise`,不污染 `AMapGlobal`
- **阻塞主线程**:大型对象深度代理可能耗时数十甚至上百毫秒。
- **消耗大量内存**:每个被代理的属性都会产生额外的 `ReactiveEffect` 和依赖追踪闭包。
- **完全无意义**:因为代码中**从不需要**追踪 `mapInstance.value.zoom``mapInstance.value.getCenter()` 的返回值变化。
### 3. `useAmap()` — 地图实例管理
#### 维度二:副作用(避免污染第三方库实例)
`ref` 的深度 Proxy 代理会**劫持对象的所有属性访问和修改**。对于 AMap 这样的第三方库,这会带来严重风险:
1. **`this` 绑定混乱**AMap 内部大量使用 `this.xxx` 访问自身属性。Proxy 会改变 `this` 的指向,可能导致内部方法执行出错。
2. **黑盒状态被破坏**地图引擎有自己的渲染循环和状态机Vue 的代理拦截可能触发非预期的重绘、事件重复触发甚至内存泄漏。
3. **第三方库不感知 Proxy**AMap 不是为 Vue 响应式系统设计的,它的内部逻辑假设 `this` 是一个普通的 JavaScript 对象,不是 Proxy。
#### 维度三:语义正确性
回顾代码中 `mapInstance` 的所有使用方式:
```typescript
// ① 赋值(直接替换整个实例)
mapInstance.value = new AMap.Map(containerRef.value, { ... })
// ② 调用实例方法
mapInstance.value.destroy()
// ③ 置空(销毁后)
mapInstance.value = null
```ts
// 返回值
containerRef // ref → 模板绑定到 <div ref="containerRef" />
mapInstance // shallowRef → 地图实例(为什么用 shallowRef 见 Q2
loading/error // ref → 加载/错误状态
initMap() // 异步初始化loadAMap() → new AMap.Map(container, options)
destroyMap() // 销毁实例 + onUnmounted 自动调用
```
**我们只关心"地图实例是哪个对象"**,而不关心它的内部属性如何变化。这与 `shallowRef` 的设计初衷完全匹配 —— 它就是一种"引用型"响应式,只追踪 `.value` 的替换。
模板/计算属性中若需要基于地图状态做响应,正确的做法是**手动同步**需要的属性到一个独立的 `ref`
```typescript
const currentZoom = ref(mapInstance.value?.getZoom() ?? 11)
// 通过 AMap 事件手动同步
mapInstance.value.on('zoomchange', () => {
currentZoom.value = mapInstance.value!.getZoom()
})
```
### 3. 如果改成 `ref` 会怎样?
改成 `ref` 后:
```typescript
const mapInstance = ref<AMap.Map | null>(null) // ❌ 错误
```
**可观测的问题:**
| 现象 | 原因 |
|------|------|
| `initMap()` 执行时出现明显的卡顿/掉帧 | 深度代理大对象阻塞 JS 主线程 |
| 控制台可能报 `TypeError: 'get' on proxy: property 'xxx' is a read-only...` 等奇诡错误 | Proxy 劫持与 AMap 内部 `Object.defineProperty` 冲突 |
| 地图交互(缩放、拖拽)偶发闪烁或功能异常 | AMap 内部状态变更被 Vue 代理拦截,触发非预期副作用 |
| 组件卸载后内存未释放(比正常情况高) | 深层代理产生了大量未被 GC 的响应式依赖 |
**调试技巧**:如果你怀疑某处误用了 `ref` 代理了大型第三方对象,可以在浏览器控制台打印:
```javascript
console.log(mapInstance.value)
// shallowRef → 输出原始 AMap.Map 对象
// ref → 输出 Proxy { ... }(注意前面的 "Proxy"
```
---
## 延伸思考:与 `containerRef` 的对比
注意到第 69 行:
```typescript
const containerRef = ref<HTMLDivElement | null>(null) // DOM ref 用了 ref
```
这里用的是 `ref` 而非 `shallowRef`。原因是:
- DOM 元素引用是**模板 ref 的约定**Vue 的模板编译器会自动将 `ref="containerRef"` 对应的值写入 `ref``.value`
- DOM 元素本身是一个相对"轻量"的对象(没有深层嵌套的自定义数据),`ref` 的深度代理成本可忽略不计。
- 但即使改成 `shallowRef` 用于 DOM 引用也不会出问题,因为模板 ref 只会整体替换 `.value`mount 时赋值unmount 时置 null
---
## 总结一句话
> **`shallowRef` 用于持有"你只关心它整体是不是变了,而不关心它内部怎么变"的第三方复杂实例** —— 既避免了深度代理的性能陷阱,也防止了响应式系统污染第三方库的内部状态。
---
## Q2loadAMap 的全局单例模式 & 多页面切换如何挂载地图?
> 对应源码:`src/composables/useAmap.ts` 第 9-48 行(`loadAMap` 函数)
---
## 题目
`useAmap.ts` 中,`loadAMap()` 使用了模块级变量 `amapPromise``AMapGlobal` 来实现全局单例。
```typescript
// 模块顶层 —— 全局单例变量
let amapPromise: Promise<typeof AMap> | null = null // ① JSAPI 加载 Promise防重复加载
let AMapGlobal: typeof AMap | null = null // ② JSAPI 加载结果缓存(后续调用秒返)
export async function loadAMap(): Promise<typeof AMap> {
if (AMapGlobal) return AMapGlobal // 已加载 → 直接返回缓存
if (!amapPromise) { // 未在加载中 → 发起加载
// ... 设置安全密钥、调用 AMapLoader.load()
}
return amapPromise // 加载中 → 返回同一个 Promise
}
```
请回答以下问题:
1. **这段代码是如何实现"全局单例"的?用到了哪些技巧?**
2. **如果我要做一个新页面(新的 `.vue` 组件),里面也放一个地图,我该怎么写才能让地图挂载到新的 DOM 容器上JSAPI 会重新下载吗?**
3. **如果用户从页面 A 切到页面 B两个页面都有地图旧地图会被销毁吗新地图是怎么创建出来的请画出完整的生命周期流程图。**
---
## 参考答案
### 1. 全局单例的实现原理
`loadAMap()` 用两个模块级变量实现了**双重锁**的单例模式:
```
┌─────────────────────────────────────────────────────┐
│ loadAMap() 调用 │
├─────────────────────────────────────────────────────┤
│ ① if (AMapGlobal) return AMapGlobal │
│ │ 已加载过 → 直接返回(最快路径,无异步开销) │
│ │ │
│ └─ 未加载 → 进入 ② │
│ │
│ ② if (!amapPromise) │
│ │ Promise 为空 → 首次加载,创建 Promise │
│ │ Promise 存在 → 其他调用正在加载,复用同一个 │
│ │ │
│ └─ 发起 AMapLoader.load() │
│ ├─ 成功 → 存入 AMapGlobal 缓存 │
│ └─ 失败 → 清空 amapPromise允许下次重试 │
└─────────────────────────────────────────────────────┘
```
**三个关键技巧:**
| 技巧 | 代码 | 解决的问题 |
|------|------|-----------|
| **结果缓存** | `AMapGlobal` | 加载完成后,后续调用直接返回,无需任何异步等待 |
| **Promise 去重** | `amapPromise` | 多个组件同时调用 `loadAMap()` 时,只发起一次网络请求,所有调用者等待同一个 Promise |
| **失败重试** | `catch``amapPromise = null` | 加载失败后重置状态,下次调用会重新尝试下载 |
**为什么不用 `new Promise` 而用 `AMapLoader.load()` 的返回值作为 Promise**
因为 `AMapLoader.load()` 本身就是异步的(返回 Promise直接保存它即可。如果自己在外面再包一层 `new Promise`,反而会破坏失败重试的语义。
---
### 2. 新页面如何写JSAPI 会重新下载吗?
**答案写法完全一致JSAPI 不会重新下载。**
新页面只需要像 `MapView.vue` 一样调用 `useAmap()`
### 4. 页面中使用
```vue
<!-- views/AnotherMapPage.vue -->
<template>
<div ref="containerRef" class="my-map" />
<div ref="containerRef" class="map-container" />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
<script setup>
import { onMounted, nextTick } from 'vue'
import { useAmap } from '@/composables/useAmap'
// ✅ 每个页面独立调用 useAmap获得自己的 containerRefmapInstance
const { containerRef, mapInstance, loading, error, initMap } = useAmap({
center: [121.473701, 31.230416], // 上海东方明珠
zoom: 12,
})
const { containerRef, mapInstance, initMap } = useAmap({ zoom: 15 })
onMounted(async () => {
await initMap() // 内部调用 loadAMap() → 命中 AMapGlobal 缓存,秒返
const map = await initMap()
if (map) nextTick(() => map.resize()) // 修正 flex 布局下的初始尺寸
})
</script>
```
**JSAPI 下载次数1 次。**
### 5. 关键细节
```
页面 A 初始化 页面 B 初始化
│ │
├─ loadAMap() ├─ loadAMap()
│ └─ AMapGlobal 为空 │ └─ AMapGlobal 已有 → 直接返回 ✅
│ └─ AMapLoader │ 不下载不请求O(1) 时间)
│ .load() ↓ │
│ (仅此一次网络请求) │
│ │
├─ new AMap.Map( ├─ new AMap.Map(
│ containerA, ...) │ containerB, ...) ← 不同 DOM 容器
│ │
```
**核心结论:`loadAMap()` 的单例只管 SDK 脚本加载,不管地图实例创建。** 每个页面用 `useAmap()` 拿到自己的 `containerRef`(不同的 DOM 元素),地图实例互不干扰。
---
### 3. 页面切换时的完整生命周期
假设有 **页面 A**Home 地图)和 **页面 B**About 地图),用户通过 `<router-link>` 从 A 导航到 B
```
时间线 ──────────────────────────────────────────────────────▶
┌── 页面 A 活跃 ──┤ 路由切换 ├── 页面 B 活跃 ──┤
│ │
A 组件: │ unmounted │ 💀 已销毁
mapInstance ────┤ destroy() │
containerRef ───┤ │
onUnmounted ────┘ │
B 组件: │ mounted │
containerRef ───┤ 赋值 │ ← 新的 DOM 元素
initMap() ──────┤ │
loadAMap() ───┤ 缓存命中 │ ← 不重新下载 JSAPI
new Map() ────┤ │ ← 挂载到 B 的 containerRef
mapInstance ────┤ │
JSAPI 全局: ─────┼────────────┼──────────────────
AMapGlobal │ 常驻内存 │
```
**每一步的具体机制:**
| 步骤 | 触发 | 发生了什么 |
|------|------|-----------|
| ① 旧地图销毁 | `onUnmounted()``destroyMap()` | `mapInstance.value.destroy()` 释放 WebGL 上下文、事件监听、DOM 节点 |
| ② 旧组件销毁 | Vue 响应式系统 | `mapInstance``containerRef` 等响应式变量随组件实例一起被 GC |
| ③ 新组件挂载 | `onMounted()``initMap()` | `containerRef.value` 已被 Vue 模板引擎绑定到新的 DOM 元素 |
| ④ loadAMap 缓存命中 | `initMap()` 内部调用 `loadAMap()` | `AMapGlobal` 非空 → 直接返回,不需要网络请求 |
| ⑤ 新地图创建 | `new AMap.Map(新容器, options)` | AMap 在新 DOM 元素内创建新的 WebGL 画布 |
**关键保证:**
-**JSAPI 只在首次访问任意地图页面时下载一次**,后续切换页面瞬间返回
-**每次切换页面都会销毁旧地图、创建新地图**,互不干扰
-**不会出现"地图挂到错误容器"的问题**,因为每个 `useAmap()` 有自己的 `containerRef` 闭包
- **安全密钥时序**`_AMapSecurityConfig` 必须在 `AMapLoader.load()` 之前设置,否则 Driving / Geocoder 等服务报 `INVALID_USER_SCODE`
- **flex 布局修正**:地图创建后调用 `map.resize()`,因为 flex 分配的尺寸可能尚未生效
- **`%` 而非 `vh`**:容器用 `height: 100%` 逐级继承,适配微前端宿主不一定是全视口的场景
---
## 总结一句话
> **`loadAMap()` 全局单例只缓存 JSAPI 脚本,不缓存地图实例 —— JSAPI 下载一次,地图实例按页面创建/销毁,各用各的 DOM 容器,切换页面自动清理旧实例。**
> **配置文件集中管理 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 互斥。**
---
## 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 + 防抖足矣。**

View File

@@ -39,6 +39,266 @@ useRouteTrack(mapInstance)
---
## 起始点路径规划 — 完整实现链路
整个"画起始点 → 规划路线"功能由 **5 个阶段** 串联而成,每个阶段由不同的函数负责,状态通过 Vue 响应式变量驱动 UI。
### 阶段 1进入标记模式 — `enterSetMode('origin' | 'dest')`
```
用户点击 [📍标记起点]
→ enterSetMode('origin')
→ exitSetMode() ← 先清理上一次的状态(互斥)
→ settingMode.value = 'origin'
→ map.setDefaultCursor('crosshair')
→ map.on('click', onClick) ← 注册一次性点击监听
```
**关键代码:**
```ts
async function enterSetMode(mode: 'origin' | 'dest'): Promise<void> {
const map = mapInstance.value
if (!map) return
exitSetMode() // ① 互斥:同时只能设一种点
settingMode.value = mode
map.setDefaultCursor('crosshair') // ② 视觉反馈:十字光标
const AMap = await loadAMap()
function onClick(e: any): void {
const pt = eventLngLat(e) // ③ 安全提取经纬度
if (!pt) return
if (mode === 'origin') {
applyOrigin(pt, AMap, map!) // ④ 创建/更新起点标记
} else {
applyDestination(pt) // ⑤ 创建/更新终点标记
}
exitSetMode() // ⑥ 单击后自动退出
}
_clickHandler = onClick
;(map as any)._rtClick = onClick // ⑦ 保存引用,确保后续能正确解绑
map.on('click', onClick)
}
```
**三个设计细节:**
| 细节 | 代码位置 | 理由 |
|------|----------|------|
| `exitSetMode()` 前置调用 | `enterSetMode` 第 3 行 | 如果用户先点了"标记起点",又点"标记终点",旧点击事件会被清理,不会同时触发两个 handler |
| `_rtClick` 强引用保存 | `(map as any)._rtClick = onClick` | `map.off('click', handler)` 需要同一个函数引用才能解绑;闭包内的 `onClick` 每次调用都是新函数,`_rtClick` 保存它以便 `exitSetMode` 精确解绑 |
| 单击后自动退出 | `exitSetMode()` 在回调末尾 | 避免用户误放多个标记;每次设点都是"进入模式 → 点一次 → 退出"的原子操作 |
### 阶段 2安全提取经纬度 — `eventLngLat()`
```ts
function eventLngLat(e: any): [number, number] | null {
const lng: number = e.lnglat?.lng ?? e.lnglat?.getLng?.() // 兼容两种 API 格式
const lat: number = e.lnglat?.lat ?? e.lnglat?.getLat?.()
if (isNaN(lng) || isNaN(lat)) return null // NaN 防护
return [lng, lat]
}
```
高德地图点击事件的 `lnglat` 对象在不同版本中可能是**属性**`e.lnglat.lng`)也可能是**方法**`e.lnglat.getLng()`)。`??` 链式回退 + `isNaN` 兜底确保不会因为 API 差异导致后续逻辑拿到 `NaN` 坐标。
### 阶段 3创建/更新标记 — `applyOrigin()` & `applyDestination()`
```ts
function applyOrigin(point: [number, number], AMap: ..., map: ...): void {
originPoint.value = point // ① 反应式状态:驱动 canPlanRoute 计算属性
if (originMarker.value) {
originMarker.value.setPosition(point) // ② 已存在:复用 + 移动位置
} else {
const m = new AMap.Marker({ // ③ 不存在:创建新标记
position: point,
content: ORIGIN_HTML, // '📍' emoji 作为标记内容
offset: new AMap.Pixel(-10, -10), // 居中对齐
zIndex: 205, // 层级:起点(205) < 护理员(210) < 终点(215)
})
map.add(m)
originMarker.value = m
}
}
```
**状态双轨制:**
| 轨道 | 变量 | 类型 | 用途 |
|------|------|------|------|
| 坐标层 | `originPoint` / `destPoint` | `Ref<[number,number] \| null>` | 响应式数据,驱动 `canPlanRoute`、传给 Driving API |
| 标记层 | `originMarker` / `destMarker` | `ShallowRef<AMap.Marker \| null>` | AMap 实例,用于地图渲染;用 `shallowRef` 避免深度响应式开销 |
`applyDestination` 逻辑完全对称,区别在于 `content`(🏁)、`offset`-12, -32`zIndex`215高于护理员和起点
### 阶段 4路线规划 — `planRoute()`
这是整个功能的核心,流程如下:
```
planRoute()
├─ 前置检查originPoint && destPoint && map 都存在
├─ clearRoute() ← 清除旧路线
├─ isPlanning = true ← 按钮 loading 状态
├─ loadAMap() ← 动态加载
├─ 检查 AMap.Driving 可用 ← 防御性编程
├─ new AMap.Driving({ map, policy: 0 })
│ policy: 0 = 速度优先LEAST_TIME
│ map: 必须传入Driving 依赖 map 做投影计算
├─ driving.search(originLngLat, destLngLat, callback)
└─ 回调处理 3 种 status
```
**路线数据提取(核心):**
```ts
if (status === 'complete' && result.routes?.length > 0) {
const route = result.routes[0]
const fullPath: [number, number][] = []
// Driving 返回的路径是分层结构Route → Step → Path
for (const step of route.steps) {
for (const pt of step.path) fullPath.push([pt.lng, pt.lat])
}
routePath.value = fullPath // 原始路径5000+ 点)
animPath.value = downsamplePath(fullPath, 250) // 动画路径250 点)
routeInfo.value = {
distance: route.distance, // 米
duration: route.time, // 秒
path: fullPath,
}
// 绘制蓝色折线
const polyline = new AMap.Polyline({
path: fullPath,
strokeColor: '#4A90D9', strokeWeight: 4, strokeOpacity: 0.75,
lineJoin: 'round', lineCap: 'round', zIndex: 85,
})
map.add(polyline)
routePolyline.value = polyline
// 自动缩放视野以包含整条路线
map.setFitView([polyline], false, [60, 60, 60, 60])
}
```
**为什么要 `new AMap.LngLat(lng, lat)` 而不是直接传数组?**
```ts
const originLngLat = new AMap.LngLat(orgLng, orgLat) // ✅ 推荐
const destLngLat = new AMap.LngLat(destLng, destLat)
// 某些版本的 AMap.Driving.search() 不接受 [lng, lat] 数组
```
**三种 status 的完整处理:**
| status | 含义 | 处理策略 |
|--------|------|----------|
| `'complete'` | 规划成功 | 提取路径 → 下采样 → 绘折线 → `resolve()` |
| `'error'` | API 调用失败(密钥/权限/网络) | 提取 `result.info``result.message``reject()` |
| `'no_data'` | 起终点间无可通行道路 | 提示用户缩短距离或换位置 → `reject()` |
| 其他 | 未知状态(不应发生) | 防御性兜底 → `reject()` |
**关键:`finally` 块保证状态恢复**
```ts
try {
// ... Driving 路线规划 ...
} catch (err: any) {
alert(`路线规划失败: ${err.message}`)
clearRoute() // 失败时清理半成品
} finally {
isPlanning.value = false // ← 无论如何都要关掉 loading否则按钮永久禁用
}
```
### 阶段 5级联清理 — 删除起点/终点自动清除路线
```ts
function removeOrigin(): void {
// ...移除 marker 和坐标...
clearRoute() // ← 级联:没了起点,路线也无意义
}
function removeDest(): void {
// ...移除 marker 和坐标...
clearRoute() // ← 同理
}
function clearRoute(): void {
stopAnimation() // 停动画
if (routePolyline.value) {
map.remove(routePolyline.value) // 移除折线
routePolyline.value = null
}
routePath.value = [] // 清空路径数据
animPath.value = []
routeInfo.value = null
animProgress.value = 0 // 重置进度
animStep.value = 0
}
```
**级联关系图:**
```
removeOrigin() / removeDest()
└─ clearRoute()
├─ stopAnimation() ← 先停动画,避免 setInterval 操作已删除的对象
├─ map.remove(polyline)
└─ 重置所有相关 ref
clearAll()
├─ exitSetMode() ← 退出标记模式
├─ stopAnimation()
├─ clearRoute()
├─ removeCaregiver()
├─ removeOrigin()
└─ removeDest()
```
清理顺序很重要:**先停动画 → 再删路线 → 最后删标记**。如果反过来,`stopAnimation` 中的 `setPosition` 可能操作已删除的 marker。
### 完整数据流图
```
用户操作 状态变化 地图效果
─────── ──────── ────────
点击 [📍标记起点]
→ enterSetMode('origin') settingMode='origin' 光标变十字
→ 点击地图 originPoint=[lng,lat] 📍 出现在点击位置
→ exitSetMode() settingMode=null 光标恢复默认
点击 [🏁标记终点]
→ enterSetMode('dest') settingMode='dest' 光标变十字
→ 点击地图 destPoint=[lng,lat] 🏁 出现在点击位置
→ exitSetMode() settingMode=null 光标恢复默认
canPlanRoute=true [🚗规划路线] 按钮可点击
点击 [🚗规划路线]
→ planRoute() isPlanning=true 按钮 loading
→ Driving.search()
→ 提取 path routePath=[5000+点]
→ downsamplePath(250) animPath=[250点]
→ new Polyline routePolyline=实例 蓝色折线出现
→ setFitView 视野自动缩放
isPlanning=false 按钮恢复
点击 [▶出发]
→ startAnimation() isAnimating=true
→ setInterval(40ms) animStep 0→249 👩‍⚕️ 沿路线移动
animProgress 0→1 进度条推进
→ stopAnimation() isAnimating=false 👩‍⚕️ 停在终点
```
---
## 核心技术点
### 1. 路径下采样 — 丝滑动画的秘密
@@ -163,6 +423,8 @@ enterSetMode('origin' | 'dest')
| **交互设计** | 模式切换 + 点击地图放标记 | ⭐⭐ |
| **容错设计** | 护理员缺失时自动创建 | ⭐ |
| **资源管理** | setInterval 清理 + 事件解绑 | ⭐⭐ |
| **级联清理** | 删除起点/终点时自动清除路线,避免僵尸数据 | ⭐⭐ |
| **状态双轨制** | 坐标层ref驱动逻辑 vs 标记层shallowRef驱动渲染 | ⭐⭐⭐ |
| **调试** | INVALID_USER_SCODE 问题排查 | ⭐⭐⭐⭐ |
## 加分项
@@ -198,3 +460,20 @@ enterSetMode('origin' | 'dest')
const speedMap = { '1x': 40, '2x': 20, '4x': 10 }
// 或动态计算,进度条相应调整
```
### 追问 5为什么 `originPoint` 用 `ref` 而 `originMarker` 用 `shallowRef`
**期望回答:**
- `originPoint` 是纯数据 `[number, number]`,需要响应式以驱动 `canPlanRoute` 计算属性和 Driving API 调用
- `originMarker` 是 AMap 实例对象,内部结构庞大且不应被 Vue 深度代理
- `shallowRef` 只监听 `.value` 的替换(`m = new AMap.Marker(...)``m = null`),不追踪内部属性变化
- 标记的位置更新通过 `marker.setPosition()` 直接操作 AMap 原生 API不需要 Vue 参与
### 追问 6`clearRoute()` 里为什么要先 `stopAnimation()` 再删折线?
**期望回答:** `stopAnimation` 中会调用 `caregiverMarker.value?.setPosition()`。清理顺序必须是:
1. `stopAnimation()` — 先停 `setInterval`,确保不再操作任何地图对象
2. `map.remove(routePolyline)` — 再安全删除路线折线
3. 重置所有 ref — 最后清理响应式状态
这个顺序保证了"先停用,再删除,最后置空",避免 `setInterval` 回调在对象删除后仍然触发 `setPosition`