diff --git a/.gitignore b/.gitignore index a547bf3..b46b5f1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +.reasonix +reasonix.toml node_modules dist dist-ssr diff --git a/docs/AMap面试题.md b/docs/AMap面试题.md new file mode 100644 index 0000000..fb96e11 --- /dev/null +++ b/docs/AMap面试题.md @@ -0,0 +1,321 @@ +# 高德地图 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 容器,切换页面自动清理旧实例。** diff --git a/package-lock.json b/package-lock.json index f8d23a2..ffe8a7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,29 +74,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -430,6 +407,7 @@ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1009,6 +987,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1118,6 +1097,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1139,6 +1119,7 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -1223,6 +1204,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/compiler-sfc": "3.5.38", diff --git a/src/App.vue b/src/App.vue index 3e3c5c5..b6f61ca 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,6 +6,7 @@ 首页 关于 地图 + 切换演示
diff --git a/src/components/MapPanel.vue b/src/components/MapPanel.vue new file mode 100644 index 0000000..4775656 --- /dev/null +++ b/src/components/MapPanel.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/src/router/index.ts b/src/router/index.ts index 1ab2f4f..1425073 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -15,6 +15,11 @@ const routes = [ path: '/map', name: 'map', component: () => import('@/views/MapView.vue') + }, + { + path: '/map-switch-demo', + name: 'map-switch-demo', + component: () => import('@/views/MapSwitchDemo.vue') } ] diff --git a/src/views/MapSwitchDemo.vue b/src/views/MapSwitchDemo.vue new file mode 100644 index 0000000..afbb041 --- /dev/null +++ b/src/views/MapSwitchDemo.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/vite.config.ts b/vite.config.ts index 80ae96e..a1b8aef 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ }) ], // 子应用 base 路径,与主应用的 baseroute 保持一致 - base: '/vue3-app/', + // base: '/vue3-app/', server: { port: 3001, // 允许主应用跨域请求子应用资源