# 高德地图 Composable 面试题 --- ## Q1:为什么 mapInstance 使用 shallowRef? > 对应源码:`src/composables/useAmap.ts` 第 70 行 --- ## 题目 在下面的 `useAmap` 组合式函数中,`mapInstance`(第 70 行)使用了 `shallowRef` 而非 `ref`。请回答以下问题: 1. **`shallowRef` 和 `ref` 的区别是什么?** 2. **为什么这里必须(或更适合)使用 `shallowRef`?请从性能、副作用、语义三个维度分析。** 3. **如果把 `shallowRef` 改成 `ref`,会出现什么问题?** 参考代码: ```typescript export function useAmap(options: AMap.MapOptions = {}) { const containerRef = ref(null) // ← DOM 引用用 ref const mapInstance = shallowRef(null) // ← 地图实例用 shallowRef const loading = ref(false) const error = ref(null) // ... } ``` --- ## 参考答案 ### 1. `shallowRef` 和 `ref` 的区别 | 特性 | `ref` | `shallowRef` | |------|-------|-------------| | 深度响应式 | ✅ 递归地对 `.value` 的所有嵌套属性进行 Proxy 代理 | ❌ 仅对 `.value` 本身的替换做响应式追踪 | | 触发更新的时机 | 任意深层属性的修改都会触发视图更新 | **仅当 `.value` 整体被重新赋值时**才会触发更新 | | 性能开销 | 大对象初始化时有显著的递归代理开销 | 几乎零开销,仅监听顶层引用变化 | | 适用场景 | 组件内部状态、表单数据等需要逐属性追踪的场景 | 第三方类实例、大型只读对象、不可变数据 | Vue 3 源码层面的本质区别: - `ref` 内部会调用 `reactive`(或 `toReactive`)对 `.value` 做一次深度 `Proxy` 包装。 - `shallowRef` 的 `.value` **不会被 `reactive` 处理**,只依赖 `getter/setter` 中的 `triggerRef` 机制。 ### 2. 为什么这里必须使用 `shallowRef`? #### 维度一:性能 `AMap.Map` 是高德地图的核心类,实例化后内部包含极其庞大的对象树: ``` AMap.Map 实例(示意结构) ├── _layers: Layer[] → 图层管理器 ├── _overlays: OverlayGroup[] → 叠加物组 ├── _status: { ... } → 数十个状态字段 ├── _canvas: HTMLCanvasElement → WebGL 渲染上下文 ├── _events: Map → 事件系统 └── ...数百个内部属性/方法 ``` 如果改用 `ref`,Vue 会在 `mapInstance.value = new AMap.Map(...)` 这条赋值语句执行时,**递归遍历整个地图实例的每一层属性**,为它们全部创建 Proxy 代理。这个过程会: - **阻塞主线程**:大型对象深度代理可能耗时数十甚至上百毫秒。 - **消耗大量内存**:每个被代理的属性都会产生额外的 `ReactiveEffect` 和依赖追踪闭包。 - **完全无意义**:因为代码中**从不需要**追踪 `mapInstance.value.zoom` 或 `mapInstance.value.getCenter()` 的返回值变化。 #### 维度二:副作用(避免污染第三方库实例) `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 ``` **我们只关心"地图实例是哪个对象"**,而不关心它的内部属性如何变化。这与 `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(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(null) // DOM ref 用了 ref ``` 这里用的是 `ref` 而非 `shallowRef`。原因是: - DOM 元素引用是**模板 ref 的约定**,Vue 的模板编译器会自动将 `ref="containerRef"` 对应的值写入 `ref` 的 `.value`。 - DOM 元素本身是一个相对"轻量"的对象(没有深层嵌套的自定义数据),`ref` 的深度代理成本可忽略不计。 - 但即使改成 `shallowRef` 用于 DOM 引用也不会出问题,因为模板 ref 只会整体替换 `.value`(mount 时赋值,unmount 时置 null)。 --- ## 总结一句话 > **`shallowRef` 用于持有"你只关心它整体是不是变了,而不关心它内部怎么变"的第三方复杂实例** —— 既避免了深度代理的性能陷阱,也防止了响应式系统污染第三方库的内部状态。 --- ## Q2:loadAMap 的全局单例模式 & 多页面切换如何挂载地图? > 对应源码:`src/composables/useAmap.ts` 第 9-48 行(`loadAMap` 函数) --- ## 题目 在 `useAmap.ts` 中,`loadAMap()` 使用了模块级变量 `amapPromise` 和 `AMapGlobal` 来实现全局单例。 ```typescript // 模块顶层 —— 全局单例变量 let amapPromise: Promise | null = null // ① JSAPI 加载 Promise(防重复加载) let AMapGlobal: typeof AMap | null = null // ② JSAPI 加载结果缓存(后续调用秒返) export async function loadAMap(): Promise { 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()`: ```vue ``` **JSAPI 下载次数:1 次。** ``` 页面 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 地图),用户通过 `` 从 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` 闭包 --- ## 总结一句话 > **`loadAMap()` 的全局单例只缓存 JSAPI 脚本,不缓存地图实例 —— JSAPI 下载一次,地图实例按页面创建/销毁,各用各的 DOM 容器,切换页面自动清理旧实例。**