第07章 状态管理与数据持久化
7.1 状态管理概述
map-360-demo 不使用 Pinia,而是采用 组合式函数(Composables)+ 模块级单例 的方式管理全局状态。这种方案轻量、简洁,非常适合中小型项目。
项目包含三个 composable:
| 文件 | 职责 |
|---|---|
| useAppState.ts | 全局响应式状态 + localStorage 持久化 + 导入导出 |
| useAddMarkerFlow.ts | 添加/编辑标记状态机 |
| useToast.ts | Toast 单例状态 |
7.2 useAppState:全局状态
7.2.1 模块级单例
// useAppState.ts:59-62
const scenes = ref<Scene[]>(SCENES)
const currentSceneId = ref(SCENES[0].id)
const markers = ref<MarkerData[]>(loadMarkers())
scenes、currentSceneId、markers 都是模块顶层 ref,多处调用 useAppState() 共享同一份数据。这是”模块级单例”模式的关键。
7.2.2 localStorage 持久化
// useAppState.ts:5, 74-80
const STORAGE_KEY = 'map-360-demo:markers:v1'
watch(markers, (val) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
} catch {
/* 超出配额等异常时静默忽略 */
}
}, { deep: true })
关键设计:
- 模块级 watch 与应用同生命周期,不随组件卸载停止
deep: true深度监听,标记的任何变化都会触发持久化- 首次不写盘(默认数据仅在首次变更后落盘)
- 异常静默忽略(如超出配额)
7.2.3 数据校验
// useAppState.ts:7-26
function isValidMarker(m: unknown): m is MarkerData {
// 逐字段校验 id/sceneId/name/description/createdAt
// /position.yaw/position.pitch/coordinates 数组/type/targetSceneId
}
isValidMarker 是类型守卫,逐字段校验标记数据的合法性。
7.2.4 数据加载
// useAppState.ts:29-42
function loadMarkers(): MarkerData[] {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return deepClone(DEFAULT_MARKERS)
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
return parsed.filter(isValidMarker)
}
} catch { /* 解析失败回退默认 */ }
return deepClone(DEFAULT_MARKERS)
}
从 localStorage 读取并 filter(isValidMarker),失败或非法回退到 DEFAULT_MARKERS 深拷贝。
7.2.5 id 生成
// useAppState.ts:45-57, 83-85
function nextIdFrom(markers: MarkerData[]): number {
// 扫描现有标记 id(m 前缀 + 数字)取最大值 +1 作为自增起点
}
function generateId(): string {
return 'm' + idCounter++
}
nextIdFrom 扫描现有标记 id 取最大值 +1 作为自增起点,避免刷新后冲突。
7.2.6 computed 状态
// useAppState.ts:64-70
const currentScene = computed(() => {
return scenes.value.find(s => s.id === currentSceneId.value) ?? scenes.value[0]
})
const currentMarkers = computed(() => {
return markers.value.filter(m => m.sceneId === currentSceneId.value)
})
currentScene:按 id 查找当前场景(找不到回退第一个)currentMarkers:过滤当前场景的标记
7.2.7 CRUD 操作
// useAppState.ts:87-116
function switchScene(id: string) { /* 校验存在后切换 */ }
function addMarker(payload: MarkerPayload) { /* 补 id + createdAt */ }
function updateMarker(id: string, patch: Partial<MarkerData>) { /* 按 id 原位替换 */ }
function removeMarker(id: string) { /* splice 移除 */ }
7.2.8 恢复默认
// useAppState.ts:119-128
function resetMarkers() {
markers.value = deepClone(DEFAULT_MARKERS)
}
深拷贝 DEFAULT_MARKERS(嵌套 position/coordinates 也拷贝),防止污染常量,watch 自动持久化。
7.2.9 导出
// useAppState.ts:131-133
function exportMarkers(): string {
return JSON.stringify(markers.value, null, 2) // 美化输出
}
7.2.10 导入合并
// useAppState.ts:139-170
function importMarkers(json: string): { ok: boolean, message: string } {
// 解析 → 校验数组 → filter(isValidMarker)
// → 丢弃场景不存在的标记、link 标记目标场景不存在的也丢弃
// → 重新分配 id 避免冲突 → push(...imported) 合并
return { ok, message }
}
导入合并流程:
- 解析 JSON
- 校验是否为数组
- 过滤非法标记
- 丢弃场景不存在的标记
- 丢弃 link 标记目标场景不存在的
- 重新分配 id 避免冲突
- 合并到现有标记
7.3 useAddMarkerFlow:添加/编辑状态机
7.3.1 状态
// useAddMarkerFlow.ts:24-30
const adding = ref(false) // 是否在添加模式
const showModal = ref(false) // 是否显示弹窗
const editingId = ref('') // 编辑中的标记 id(空=新增)
const pendingPosition = ref<{ yaw: number, pitch: number } | null>(null)
const pendingCoords = ref<[number, number] | null>(null)
7.3.2 派生状态
// useAddMarkerFlow.ts:33-42
const modalCoords = computed(() => {
// 优先选点坐标 → 其次编辑中标记原坐标 → 兜底场景中心
})
const editingMarker = computed(() => {
return currentMarkers.value.find(m => m.id === editingId.value)
})
7.3.3 预览 pin
// useAddMarkerFlow.ts:48-69
const previewMarker = computed(() => {
// 仅当有 pendingPosition 和 pendingCoords 时返回
// 编辑模式下若位置与原有标记完全相同则不显示预览(避免与蓝 pin 重叠)
return { id: PREVIEW_ID, ... }
})
7.3.4 关键方法
| 方法 | 职责 |
|---|---|
| startAddMarker | 进入添加模式,清空编辑状态 |
| openEdit | 进入编辑模式,预填位置和坐标 |
| exitAdding | 完全退出并清理预览 |
| onClickEmpty | 全景点击 → 估算经纬度 → 打开弹窗 |
| onMapPick | 地图点击 → 反推 yaw → 打开弹窗 |
| onModalCancel | 只关弹窗,保留预览和添加模式 |
| onModalCoordsChange | 滑块改坐标时实时同步预览 |
| onModalConfirm | 确认后新增或更新 |
7.3.5 坐标同步逆式
// useAddMarkerFlow.ts:126-132
function onModalCoordsChange(coords: [number, number]) {
// 滑块沿固定方位角移动方向不变故 yaw 无需重算
// 但距离变化需按 dist = 450 + pitch*800 逆式回推 pitch
// 钳制到 [120, 1100],否则预览 pin 纵向位置不随滑块移动
}
这是滑块移动时保持预览同步的关键逻辑。
7.4 useToast:Toast 单例
7.4.1 模块级单例
// useToast.ts:10-13
const toasts = ref<Toast[]>([])
let toastSeq = 0
const timers = new Map<number, ReturnType<typeof setTimeout>>()
7.4.2 show
// useToast.ts:16-23
function show(message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) {
const id = ++toastSeq
toasts.value.push({ id, message, type })
if (duration > 0) {
const timer = setTimeout(() => dismiss(id), duration)
timers.set(id, timer)
}
}
7.4.3 dismiss
// useToast.ts:25-35
function dismiss(id: number) {
const timer = timers.get(id)
if (timer) {
clearTimeout(timer) // 定时器清理
timers.delete(id)
}
toasts.value = toasts.value.filter(t => t.id !== id)
}
关键设计:手动点击 dismiss 时先 clearTimeout 并删除 timer,能正确取消自动关闭定时器。
7.5 状态管理总结
| 特性 | 实现方式 |
|---|---|
| 全局状态 | 模块级单例 ref |
| 持久化 | watch(deep) + localStorage |
| 数据校验 | isValidMarker 类型守卫 |
| id 生成 | 自增 + 扫描去重 |
| 状态机 | useAddMarkerFlow |
| 反馈 | useToast 单例 |
设计优势:
- 轻量:无需引入 Pinia,减少依赖
- 复用:组合式函数天然支持逻辑复用
- 清晰:状态、状态机、反馈分离,职责单一
7.6 下一步
理解了状态管理后,进入 第08章 坐标换算算法详解 了解全景与地图坐标的换算原理。