feat: 添加switchMap demo

This commit is contained in:
2026-06-25 13:58:07 +08:00
parent 91afc9d81c
commit 882aa21cb3
8 changed files with 784 additions and 24 deletions

2
.gitignore vendored
View File

@@ -7,6 +7,8 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.reasonix
reasonix.toml
node_modules
dist
dist-ssr

321
docs/AMap面试题.md Normal file
View File

@@ -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<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 这样的第三方库,这会带来严重风险:
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<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()`
```vue
<!-- 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 容器,切换页面自动清理旧实例。**

28
package-lock.json generated
View File

@@ -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",

View File

@@ -6,6 +6,7 @@
<router-link to="/">首页</router-link>
<router-link to="/about">关于</router-link>
<router-link to="/map">地图</router-link>
<router-link to="/map-switch-demo">切换演示</router-link>
</nav>
</header>
<main class="sub-app-main">

139
src/components/MapPanel.vue Normal file
View File

@@ -0,0 +1,139 @@
<template>
<div class="map-panel">
<!-- 标题浮层 -->
<div class="panel-overlay">
<span class="panel-title" :style="{ color }">{{ title }}</span>
<span v-if="loading" class="panel-status"> 加载中...</span>
<span v-else-if="error" class="panel-status panel-error"> {{ error }}</span>
<span v-else class="panel-status panel-ok"> 地图就绪</span>
</div>
<!-- 地图容器 每个 MapPanel 有自己独立的 DOM 元素 -->
<div
ref="containerRef"
class="panel-map"
/>
<!-- 底部说明条 -->
<div class="panel-footer">
<span>🆔 容器<code>containerRef</code>独立 DOM</span>
<span>📦 JSAPI全局单例不重复下载</span>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import { useAmap } from '@/composables/useAmap'
const props = defineProps<{
title: string
center: [number, number]
zoom: number
color: string
}>()
const emit = defineEmits<{
(e: 'map-created'): void
(e: 'map-destroyed'): void
}>()
// ═══ 每个 MapPanel 独立调用 useAmap() ═══
// 各自的 containerRef 绑定到各自的 <div ref="containerRef" />
// 互不干扰
const { containerRef, loading, error, initMap, destroyMap } = useAmap({
center: props.center,
zoom: props.zoom,
})
async function bootstrapMap() {
const map = await initMap()
if (map) {
emit('map-created')
}
}
// 组件挂载 → 初始化地图
onMounted(() => {
bootstrapMap()
})
// 组件卸载 → 销毁地图(由 v-if 切换触发)
onUnmounted(() => {
destroyMap()
emit('map-destroyed')
})
// 暴露给父组件的调试用 ref实际项目中不需要
defineExpose({ containerRef })
</script>
<style scoped>
.map-panel {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
/* ── 标题浮层 ── */
.panel-overlay {
position: absolute;
top: 12px;
left: 16px;
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 16px;
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
pointer-events: none;
}
.panel-title {
font-size: 14px;
font-weight: 600;
}
.panel-status {
font-size: 12px;
color: #999;
}
.panel-ok {
color: #27ae60;
}
.panel-error {
color: #e74c3c;
}
/* ── 地图 ── */
.panel-map {
flex: 1;
width: 100%;
}
/* ── 底部说明 ── */
.panel-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 16px;
background: #fff;
border-top: 1px solid #e8e8e8;
font-size: 11px;
color: #999;
flex-shrink: 0;
}
.panel-footer code {
font-family: 'Consolas', 'Courier New', monospace;
font-size: 11px;
background: #f5f5f5;
padding: 1px 6px;
border-radius: 3px;
}
</style>

View File

@@ -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')
}
]

310
src/views/MapSwitchDemo.vue Normal file
View File

@@ -0,0 +1,310 @@
<template>
<div class="demo-page">
<!-- 顶部信息栏 -->
<div class="demo-header">
<h2>🔄 地图单例模式 & 页面切换演示</h2>
<span class="demo-badge">Q2 面试题现场版</span>
</div>
<!-- 标签切换栏 -->
<div class="demo-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="demo-tab"
:class="{ active: activeTab === tab.id }"
@click="switchTab(tab.id)"
>
{{ tab.label }}
</button>
<!-- 全局状态指示器 -->
<div class="demo-indicator">
<span class="indicator-dot" :class="jsapiLoaded ? 'on' : 'off'" />
JSAPI: {{ jsapiLoaded ? '✅ 已加载(全局单例)' : '⏳ 未加载' }}
</div>
</div>
<!-- 地图区域 -->
<div class="demo-map-area">
<!-- 页面 A北京 -->
<MapPanel
v-if="activeTab === 'pageA'"
key="pageA"
title="📍 页面 A — 北京天安门"
:center="[116.397428, 39.90923]"
:zoom="12"
color="#42b883"
@map-created="onMapCreated('A')"
@map-destroyed="onMapDestroyed('A')"
/>
<!-- 页面 B上海 -->
<MapPanel
v-if="activeTab === 'pageB'"
key="pageB"
title="📍 页面 B — 上海东方明珠"
:center="[121.473701, 31.230416]"
:zoom="13"
color="#4A90D9"
@map-created="onMapCreated('B')"
@map-destroyed="onMapDestroyed('B')"
/>
</div>
<!-- 底部日志面板 -->
<div class="demo-log">
<div class="demo-log-header">
<span>📋 生命周期日志</span>
<button class="demo-log-clear" @click="logs.length = 0">清空</button>
</div>
<div class="demo-log-list" ref="logListRef">
<div
v-for="(log, i) in logs"
:key="i"
class="demo-log-item"
:class="'log-' + log.type"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-msg">{{ log.msg }}</span>
</div>
<div v-if="logs.length === 0" class="demo-log-empty">
暂无日志 点击上方标签切换页面试试
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue'
import MapPanel from '@/components/MapPanel.vue'
// ═══ 标签页配置 ═══
const tabs = [
{ id: 'pageA' as const, label: '🏙️ 页面 A — 北京' },
{ id: 'pageB' as const, label: '🌆 页面 B — 上海' },
]
const activeTab = ref<'pageA' | 'pageB'>('pageA')
// ═══ JSAPI 状态 ═══
const jsapiLoaded = ref(false)
// ═══ 日志系统 ═══
interface LogEntry {
time: string
msg: string
type: 'created' | 'destroyed' | 'info'
}
const logs = ref<LogEntry[]>([])
const logListRef = ref<HTMLDivElement | null>(null)
function addLog(msg: string, type: LogEntry['type'] = 'info') {
const now = new Date()
const time = now.toLocaleTimeString('zh-CN', { hour12: false })
logs.value.push({ time, msg, type })
// 自动滚到底部
nextTick(() => {
if (logListRef.value) {
logListRef.value.scrollTop = logListRef.value.scrollHeight
}
})
}
// ═══ 标签切换 ═══
function switchTab(id: 'pageA' | 'pageB') {
if (activeTab.value === id) return
const from = activeTab.value === 'pageA' ? 'A' : 'B'
const to = id === 'pageA' ? 'A' : 'B'
addLog(`🔄 用户点击切换:页面 ${from} → 页面 ${to}`, 'info')
activeTab.value = id
}
// ═══ 地图事件回调 ═══
function onMapCreated(page: string) {
jsapiLoaded.value = true
addLog(`✅ 页面 ${page} 地图实例创建完成new AMap.Map 挂载到自己的 containerRef`, 'created')
}
function onMapDestroyed(page: string) {
addLog(`💀 页面 ${page} 地图实例已销毁onUnmounted → destroyMap → map.destroy()`, 'destroyed')
}
onMounted(() => {
addLog('🚀 演示页面加载,当前显示页面 A', 'info')
})
</script>
<style scoped>
/* ═══ 页面容器 ═══ */
.demo-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: #f5f5f5;
}
/* ═══ 顶部 ═══ */
.demo-header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0;
}
.demo-header h2 {
margin: 0;
font-size: 16px;
color: #333;
}
.demo-badge {
font-size: 11px;
padding: 2px 10px;
border-radius: 10px;
background: #f0faf5;
color: #42b883;
font-weight: 600;
}
/* ═══ 标签栏 ═══ */
.demo-tabs {
display: flex;
align-items: center;
gap: 0;
padding: 0 20px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0;
}
.demo-tab {
padding: 10px 20px;
border: none;
border-bottom: 3px solid transparent;
background: transparent;
color: #999;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: -1px;
}
.demo-tab:hover {
color: #555;
background: #fafafa;
}
.demo-tab.active {
color: #333;
font-weight: 600;
border-bottom-color: #42b883;
}
.demo-indicator {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #888;
}
.indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.indicator-dot.on {
background: #27ae60;
box-shadow: 0 0 4px rgba(39, 174, 96, 0.4);
}
.indicator-dot.off {
background: #ccc;
}
/* ═══ 地图区域 ═══ */
.demo-map-area {
flex: 1;
min-height: 0;
position: relative;
}
/* ═══ 日志面板 ═══ */
.demo-log {
height: 160px;
background: #1e1e1e;
color: #d4d4d4;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 12px;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.demo-log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 16px;
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
}
.demo-log-clear {
padding: 2px 10px;
border: 1px solid #555;
border-radius: 3px;
background: transparent;
color: #999;
font-size: 11px;
cursor: pointer;
}
.demo-log-clear:hover {
background: #444;
color: #ccc;
}
.demo-log-list {
flex: 1;
overflow-y: auto;
padding: 8px 16px;
}
.demo-log-item {
padding: 2px 0;
line-height: 1.6;
}
.log-time {
color: #6a9955;
margin-right: 8px;
}
.log-created .log-msg {
color: #4ec9b0;
}
.log-destroyed .log-msg {
color: #ce9178;
}
.log-info .log-msg {
color: #9cdcfe;
}
.demo-log-empty {
color: #666;
font-style: italic;
padding: 8px 0;
}
</style>

View File

@@ -20,7 +20,7 @@ export default defineConfig({
})
],
// 子应用 base 路径,与主应用的 baseroute 保持一致
base: '/vue3-app/',
// base: '/vue3-app/',
server: {
port: 3001,
// 允许主应用跨域请求子应用资源