本文记录了我们团队在 Vue 3 + TypeScript 项目中,如何将散乱的基础数据管理逻辑重构为统一的「基础数据中心」。如果你的项目也有类似的痛点,希望这篇文章能给你一些参考。
一、问题是怎么来的
做过 B 端系统的同学应该都有体会------基础数据无处不在。港口、船舶、航线、货币、字典......这些数据在几乎每个页面都会用到,要么是下拉选择,要么是代码翻译,要么是表格筛选。
我们项目一开始的做法很「朴素」:哪里用到就哪里请求。后来发现这样不行,同一个港口列表接口一个页面能请求三四次。于是开始加缓存,问题是加着加着,代码变成了这样:
bash
store/basicData/cache.ts <- Pinia 实现的缓存
composables/basicData/cache.ts <- VueUse + localStorage 实现的缓存
store/port.ts <- 独立的港口缓存(历史遗留)
三套缓存系统,各自为政。更要命的是 CACHE_KEYS 这个常量在两个地方都有定义,改一处忘一处是常态。
某天排查一个 bug:用户反馈页面显示的港口名称和实际不一致。查了半天发现是两套缓存系统的数据版本不同步------A 组件用的 Pinia 缓存已经过期刷新了,B 组件用的 localStorage 缓存还是旧数据。
是时候重构了。
二、想清楚再动手
重构之前,我们先梳理了需求优先级:
| 需求 | 优先级 | 说明 |
|---|---|---|
| 跨组件数据共享 | P0 | 同一份数据,全局只请求一次 |
| 缓存 + 过期机制 | P0 | 减少请求,但数据要能自动刷新 |
| 请求去重 | P1 | 并发请求同一接口时,只发一次 |
| 持久化 | P1 | 关键数据存 localStorage,提升首屏速度 |
| DevTools 调试 | P2 | 能在 Vue DevTools 里看到缓存状态 |
基于这些需求,我们确定了架构原则:
Store 管状态,Composable 封业务,Component 只消费。
三、分层架构设计
最终的架构分三层:
scss
┌─────────────────────────────────────────────────┐
│ Component Layer │
│ (Vue 组件/页面) │
│ 只使用 Composables,不直接访问 Store │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Composable Layer │
│ (composables/basicData/) │
│ usePorts / useVessels / useDict / ... │
│ 封装 Store,提供业务友好的 API │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Store Layer │
│ (store/basicData/) │
│ useBasicDataStore │
│ 统一缓存、加载状态、请求去重、持久化 │
└─────────────────────────────────────────────────┘
为什么要分这么多层?
- Store 层:单一数据源,解决「数据从哪来」的问题
- Composable 层:业务封装,解决「数据怎么用」的问题
- Component 层:纯消费,只关心「界面怎么展示」
这样分层之后,职责边界就清晰了。组件开发者不用关心缓存策略,只管调 usePorts() 拿数据就行。
四、核心实现
4.1 Store 层:请求去重是关键
Store 层最核心的逻辑是 loadData 方法。这里要处理三种情况:
- 缓存命中 → 直接返回
- 有相同请求正在进行 → 复用已有 Promise
- 发起新请求 → 请求完成后写入缓存
typescript
// store/basicData/useBasicData.ts
export const useBasicDataStore = defineStore('basic-data', () => {
const cacheMap = ref<Map<BasicDataType, CacheEntry>>(new Map())
const pendingRequests = new Map<BasicDataType, Promise<unknown>>()
async function loadData<T>(
type: BasicDataType,
fetcher: () => Promise<T>,
config?: CacheConfig
): Promise<T | null> {
// 1. 缓存命中
const cached = getCache<T>(type)
if (cached !== null) return cached
// 2. 请求去重------这是关键
const pending = pendingRequests.get(type)
if (pending) return pending as Promise<T | null>
// 3. 发起新请求
const request = (async () => {
try {
const data = await fetcher()
setCache(type, data, config)
return data
} finally {
pendingRequests.delete(type)
}
})()
pendingRequests.set(type, request)
return request
}
return { loadData, getCache, setCache, clearCache }
})
请求去重的实现很简单:用一个 Map 存储正在进行的 Promise。当第二个请求进来时,直接返回已有的 Promise,不发新请求。
这样即使页面上 10 个组件同时调用 usePorts(),实际 API 请求也只有 1 次。
4.2 Composable 层:工厂函数批量生成
港口、船舶、航线......这些 Composable 的逻辑高度相似,用工厂函数批量生成:
typescript
// composables/basicData/hooks.ts
function createBasicDataComposable<T extends BaseDataItem>(
type: BasicDataType,
fetcher: () => Promise<T[]>,
config?: CacheConfig
) {
return () => {
const store = useBasicDataStore()
// 响应式数据
const data = computed(() => store.getCache<T[]>(type) || [])
const loading = computed(() => store.getLoadingState(type).loading)
const isReady = computed(() => data.value.length > 0)
// 自动加载
store.loadData(type, fetcher, config)
// 业务方法
const getByCode = (code: string) =>
data.value.find(item => item.code === code)
const options = computed(() =>
data.value.map(item => ({
label: item.nameCn,
value: item.code
}))
)
return { data, loading, isReady, getByCode, options, refresh }
}
}
// 一行代码定义一个 Composable
export const usePorts = createBasicDataComposable('ports', fetchPorts, { ttl: 15 * 60 * 1000 })
export const useVessels = createBasicDataComposable('vessels', fetchVessels, { ttl: 15 * 60 * 1000 })
export const useLanes = createBasicDataComposable('lanes', fetchLanes, { ttl: 30 * 60 * 1000 })
这样做的好处是:
- 新增一种基础数据,只需加一行代码
- 所有 Composable 的 API 完全一致,学习成本低
- 类型安全,TypeScript 能正确推断返回类型
4.3 字典数据:特殊处理
字典数据稍微复杂一些,因为它是按类型分组的。我们单独封装了 useDict:
typescript
export function useDict() {
const store = useBasicDataStore()
// 加载全量字典数据
store.loadData('dict', fetchAllDict, { ttl: 30 * 60 * 1000 })
const getDictItems = (dictType: string) => {
const all = store.getCache<DictData>('dict') || {}
return all[dictType] || []
}
const getDictLabel = (dictType: string, value: string) => {
const items = getDictItems(dictType)
return items.find(item => item.value === value)?.label || value
}
const getDictOptions = (dictType: string) => {
return getDictItems(dictType).map(item => ({
label: item.label,
value: item.value
}))
}
return { getDictItems, getDictLabel, getDictOptions }
}
使用起来非常直观:
vue
<script setup>
const dict = useDict()
const cargoTypeLabel = dict.getDictLabel('CARGO_TYPE', 'FCL') // "整箱"
</script>
<template>
<el-select>
<el-option
v-for="opt in dict.getDictOptions('CARGO_TYPE')"
:key="opt.value"
v-bind="opt"
/>
</el-select>
</template>
五、实际使用场景
场景一:下拉选择器
最常见的场景。以前要自己请求数据、处理格式,现在一行搞定:
vue
<script setup>
import { usePorts } from '@/composables/basicData'
const { options: portOptions, loading } = usePorts()
const selectedPort = ref('')
</script>
<template>
<el-select v-model="selectedPort" :loading="loading" filterable>
<el-option v-for="opt in portOptions" :key="opt.value" v-bind="opt" />
</el-select>
</template>
场景二:表格中的代码翻译
订单列表里显示港口代码,用户看不懂,要翻译成中文:
vue
<script setup>
import { usePorts } from '@/composables/basicData'
const { getByCode } = usePorts()
// 翻译函数
const translatePort = (code: string) => getByCode(code)?.nameCn || code
</script>
<template>
<el-table :data="orderList">
<el-table-column prop="polCode" label="起运港">
<template #default="{ row }">
{{ translatePort(row.polCode) }}
</template>
</el-table-column>
</el-table>
</template>
场景三:字典标签渲染
状态、类型这类字段,通常要显示成带颜色的标签:
vue
<script setup>
import { useDict } from '@/composables/basicData'
const dict = useDict()
</script>
<template>
<el-tag :type="dict.getDictColorType('ORDER_STATUS', row.status)">
{{ dict.getDictLabel('ORDER_STATUS', row.status) }}
</el-tag>
</template>
场景四:数据刷新
用户修改了基础数据,需要刷新缓存:
typescript
import { usePorts, clearAllCache } from '@/composables/basicData'
const { refresh: refreshPorts } = usePorts()
// 刷新单个
await refreshPorts()
// 刷新全部
clearAllCache()
六、缓存策略
不同数据的变化频率不同,缓存策略也不一样:
| 数据类型 | TTL | 持久化 | 原因 |
|---|---|---|---|
| 国家/货币 | 1 小时 | ✅ | 几乎不变 |
| 港口/码头 | 15-30 分钟 | ✅ | 偶尔变化 |
| 船舶 | 15 分钟 | ❌ | 数据量大(10万+),不适合 localStorage |
| 航线/堆场 | 30 分钟 | ✅ | 相对稳定 |
| 字典 | 30 分钟 | ✅ | 偶尔变化 |
持久化用的是 localStorage,配合 TTL 一起使用。数据写入时记录时间戳,读取时检查是否过期。
船舶数据量太大,存 localStorage 会导致写入超时,所以不做持久化,每次刷新页面重新请求。
七、调试支持
用 Pinia 还有一个好处:Vue DevTools 原生支持。
打开 DevTools,切到 Pinia 面板,能看到:
- 当前缓存了哪些数据
- 每种数据的加载状态
- 数据的具体内容
排查问题时非常方便。
另外我们还提供了 getCacheInfo() 方法,可以在控制台查看缓存统计:
typescript
import { getCacheInfo } from '@/composables/basicData'
console.log(getCacheInfo())
// {
// ports: { cached: true, size: 102400, remainingTime: 600000 },
// vessels: { cached: false, size: 0, remainingTime: 0 },
// ...
// }
八、踩过的坑
坑 1:响应式丢失
一开始我们这样写:
typescript
// ❌ 错误写法
const { data } = usePorts()
const portList = data.value // 丢失响应式!
data 是 computed,取 .value 之后就变成普通值了,后续数据更新不会触发视图刷新。
正确做法是保持响应式引用:
typescript
// ✅ 正确写法
const { data: portList } = usePorts()
// 或者
const portList = computed(() => usePorts().data.value)
坑 2:循环依赖
Store 和 Composable 互相引用导致循环依赖。解决办法是严格遵守分层原则:Composable 可以引用 Store,Store 不能引用 Composable。
坑 3:SSR 兼容
localStorage 在服务端不存在。如果你的项目需要 SSR,持久化逻辑要加判断:
typescript
const storage = typeof window !== 'undefined' ? localStorage : null
九、总结
重构前后的对比:
| 维度 | 重构前 | 重构后 |
|---|---|---|
| 缓存系统 | 3 套并存 | 1 套统一 |
| 代码复用 | 到处复制粘贴 | 工厂函数批量生成 |
| 请求优化 | 无去重,重复请求 | 自动去重 |
| 调试 | 只能打 log | DevTools 原生支持 |
| 类型安全 | 部分 any | 完整类型推断 |
核心收益:
- 开发效率提升:新增基础数据类型从半天缩短到 10 分钟
- Bug 减少:数据不一致问题基本消失
- 性能优化:重复请求减少 60%+
如果你的项目也有类似的基础数据管理问题,可以参考这个思路。关键是想清楚分层,把「状态管理」和「业务封装」分开,剩下的就是体力活了。
本文基于实际项目经验整理,代码已做脱敏处理。欢迎讨论交流。