Files
microapp-vue3-interview/docs/AMap面试题.md
2026-06-25 13:58:07 +08:00

14 KiB
Raw Blame History

高德地图 Composable 面试题


Q1为什么 mapInstance 使用 shallowRef

对应源码:src/composables/useAmap.ts 第 70 行


题目

在下面的 useAmap 组合式函数中,mapInstance(第 70 行)使用了 shallowRef 而非 ref。请回答以下问题:

  1. shallowRefref 的区别是什么?
  2. 为什么这里必须(或更适合)使用 shallowRef?请从性能、副作用、语义三个维度分析。
  3. 如果把 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. shallowRefref 的区别

特性 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[]> → 事件系统
└── ...数百个内部属性/方法

如果改用 refVue 会在 mapInstance.value = new AMap.Map(...) 这条赋值语句执行时,递归遍历整个地图实例的每一层属性,为它们全部创建 Proxy 代理。这个过程会:

  • 阻塞主线程:大型对象深度代理可能耗时数十甚至上百毫秒。
  • 消耗大量内存:每个被代理的属性都会产生额外的 ReactiveEffect 和依赖追踪闭包。
  • 完全无意义:因为代码中从不需要追踪 mapInstance.value.zoommapInstance.value.getCenter() 的返回值变化。

维度二:副作用(避免污染第三方库实例)

ref 的深度 Proxy 代理会劫持对象的所有属性访问和修改。对于 AMap 这样的第三方库,这会带来严重风险:

  1. this 绑定混乱AMap 内部大量使用 this.xxx 访问自身属性。Proxy 会改变 this 的指向,可能导致内部方法执行出错。
  2. 黑盒状态被破坏地图引擎有自己的渲染循环和状态机Vue 的代理拦截可能触发非预期的重绘、事件重复触发甚至内存泄漏。
  3. 第三方库不感知 ProxyAMap 不是为 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 只会整体替换 .valuemount 时赋值unmount 时置 null

总结一句话

shallowRef 用于持有"你只关心它整体是不是变了,而不关心它内部怎么变"的第三方复杂实例 —— 既避免了深度代理的性能陷阱,也防止了响应式系统污染第三方库的内部状态。


Q2loadAMap 的全局单例模式 & 多页面切换如何挂载地图?

对应源码:src/composables/useAmap.ts 第 9-48 行(loadAMap 函数)


题目

useAmap.ts 中,loadAMap() 使用了模块级变量 amapPromiseAMapGlobal 来实现全局单例。

// 模块顶层 —— 全局单例变量
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
失败重试 catchamapPromise = 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. 页面切换时的完整生命周期

假设有 页面 AHome 地图)和 页面 BAbout 地图),用户通过 <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 响应式系统 mapInstancecontainerRef 等响应式变量随组件实例一起被 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 容器,切换页面自动清理旧实例。