14 KiB
高德地图 Composable 面试题
Q1:为什么 mapInstance 使用 shallowRef?
对应源码:
src/composables/useAmap.ts第 70 行
题目
在下面的 useAmap 组合式函数中,mapInstance(第 70 行)使用了 shallowRef 而非 ref。请回答以下问题:
shallowRef和ref的区别是什么?- 为什么这里必须(或更适合)使用
shallowRef?请从性能、副作用、语义三个维度分析。 - 如果把
shallowRef改成ref,会出现什么问题?
参考代码:
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 的区别
| 特性 | 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<string, Handler[]> → 事件系统
└── ...数百个内部属性/方法
如果改用 ref,Vue 会在 mapInstance.value = new AMap.Map(...) 这条赋值语句执行时,递归遍历整个地图实例的每一层属性,为它们全部创建 Proxy 代理。这个过程会:
- 阻塞主线程:大型对象深度代理可能耗时数十甚至上百毫秒。
- 消耗大量内存:每个被代理的属性都会产生额外的
ReactiveEffect和依赖追踪闭包。 - 完全无意义:因为代码中从不需要追踪
mapInstance.value.zoom或mapInstance.value.getCenter()的返回值变化。
维度二:副作用(避免污染第三方库实例)
ref 的深度 Proxy 代理会劫持对象的所有属性访问和修改。对于 AMap 这样的第三方库,这会带来严重风险:
this绑定混乱:AMap 内部大量使用this.xxx访问自身属性。Proxy 会改变this的指向,可能导致内部方法执行出错。- 黑盒状态被破坏:地图引擎有自己的渲染循环和状态机,Vue 的代理拦截可能触发非预期的重绘、事件重复触发甚至内存泄漏。
- 第三方库不感知 Proxy:AMap 不是为 Vue 响应式系统设计的,它的内部逻辑假设
this是一个普通的 JavaScript 对象,不是 Proxy。
维度三:语义正确性
回顾代码中 mapInstance 的所有使用方式:
// ① 赋值(直接替换整个实例)
mapInstance.value = new AMap.Map(containerRef.value, { ... })
// ② 调用实例方法
mapInstance.value.destroy()
// ③ 置空(销毁后)
mapInstance.value = null
我们只关心"地图实例是哪个对象",而不关心它的内部属性如何变化。这与 shallowRef 的设计初衷完全匹配 —— 它就是一种"引用型"响应式,只追踪 .value 的替换。
模板/计算属性中若需要基于地图状态做响应,正确的做法是手动同步需要的属性到一个独立的 ref:
const currentZoom = ref(mapInstance.value?.getZoom() ?? 11)
// 通过 AMap 事件手动同步
mapInstance.value.on('zoomchange', () => {
currentZoom.value = mapInstance.value!.getZoom()
})
3. 如果改成 ref 会怎样?
改成 ref 后:
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 代理了大型第三方对象,可以在浏览器控制台打印:
console.log(mapInstance.value)
// shallowRef → 输出原始 AMap.Map 对象
// ref → 输出 Proxy { ... }(注意前面的 "Proxy")
延伸思考:与 containerRef 的对比
注意到第 69 行:
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用于持有"你只关心它整体是不是变了,而不关心它内部怎么变"的第三方复杂实例 —— 既避免了深度代理的性能陷阱,也防止了响应式系统污染第三方库的内部状态。
Q2:loadAMap 的全局单例模式 & 多页面切换如何挂载地图?
对应源码:
src/composables/useAmap.ts第 9-48 行(loadAMap函数)
题目
在 useAmap.ts 中,loadAMap() 使用了模块级变量 amapPromise 和 AMapGlobal 来实现全局单例。
// 模块顶层 —— 全局单例变量
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
}
请回答以下问题:
- 这段代码是如何实现"全局单例"的?用到了哪些技巧?
- 如果我要做一个新页面(新的
.vue组件),里面也放一个地图,我该怎么写才能让地图挂载到新的 DOM 容器上?JSAPI 会重新下载吗? - 如果用户从页面 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():
<!-- views/AnotherMapPage.vue -->
<template>
<div ref="containerRef" class="my-map" />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAmap } from '@/composables/useAmap'
// ✅ 每个页面独立调用 useAmap,获得自己的 containerRef 和 mapInstance
const { containerRef, mapInstance, loading, error, initMap } = useAmap({
center: [121.473701, 31.230416], // 上海东方明珠
zoom: 12,
})
onMounted(async () => {
await initMap() // 内部调用 loadAMap() → 命中 AMapGlobal 缓存,秒返
})
</script>
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 地图),用户通过 <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闭包
总结一句话
loadAMap()的全局单例只缓存 JSAPI 脚本,不缓存地图实例 —— JSAPI 下载一次,地图实例按页面创建/销毁,各用各的 DOM 容器,切换页面自动清理旧实例。