作者 : 狗弟 发布日期 : 2025-12-04
技术栈 : Vue 3 + TypeScript + Composition API + Element Plus
阅读时长: 约 15 分钟
📌 引言
在大型企业应用中,基础数据(字典、港口、货币、国家等)的管理往往是一个被忽视但至关重要的领域。一个设计良好的基础数据方案可以:
- 🚀 减少 70% 以上的重复代码
- ⚡ 降低 API 请求次数 80%+
- 🎯 提升开发效率和代码可维护性
- 🌍 无缝支持国际化切换
本文将分享我们在航运物流系统中设计和实现的统一基础数据管理方案,涵盖架构设计、性能优化、缓存策略和最佳实践。
🤔 问题背景:野蛮生长的痛点
最初的混乱
在项目初期,每个开发者按自己的方式获取和使用基础数据:
typescript
// 🔴 问题代码示例:每个组件各自为政
// 组件 A:直接调用 API
const res = await api.getDictList('ORDER_STATUS')
const statusList = res.data
// 组件 B:使用 hooks 但没有缓存
const { data } = useAllDict('ORDER_STATUS') // 每次调用都请求 API
// 组件 C:在 Vuex 中存储
store.dispatch('loadDictData', 'ORDER_STATUS')
const statusList = store.state.dict.ORDER_STATUS
// 组件 D:硬编码
const statusList = [
{ value: 1, label: '待处理' },
{ value: 2, label: '已完成' },
// ...
]
这导致了严重的问题
| 问题 | 影响 |
|---|---|
| API 请求爆炸 | 同一个字典在 10 个组件中被请求 10 次 |
| 数据不一致 | 硬编码的数据与后端不同步 |
| 国际化困难 | 中英文切换需要手动处理每个地方 |
| 代码重复 | 格式化、查找 label 的逻辑到处都是 |
| 类型缺失 | 没有 TypeScript 类型,IDE 无法提示 |
🏗️ 架构设计:统一数据源
核心设计理念
我们采用单一数据源 + 工厂模式的架构:
javascript
┌─────────────────────────────────────────────────────────┐
│ 业务组件层 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 下拉框 │ │ 表格列 │ │ 标签 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └─────────────┴─────────────┘ │
│ │ │
├──────────────────────▼──────────────────────────────────┤
│ Composables 统一入口 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ import { useDictType, usePorts } from │ │
│ │ '~/composables/basicData' │ │
│ └─────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ 模块内部架构 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ hooks │ │ adapters │ │ cache │ │
│ │ 业务封装 │ │ 数据适配 │ │ 缓存管理 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └─────────────┴─────────────┘ │
│ │ │
│ ┌───────▼───────┐ │
│ │ API │ │
│ │ 统一数据获取 │ │
│ └───────────────┘ │
└─────────────────────────────────────────────────────────┘
目录结构
bash
src/composables/basicData/
├── index.ts # 统一导出入口
├── hooks.ts # 业务数据 Hooks(港口、船舶、航线等)
├── useDict.ts # 字典数据 Hooks
├── cache.ts # 缓存管理(TTL、清理策略)
├── adapters.ts # 数据适配器(API → 标准格式)
├── api/ # API 封装
│ └── index.ts
└── types/ # TypeScript 类型定义
└── index.ts
💡 核心实现
1. 工厂函数:统一的 Hook 创建模式
不同类型的基础数据(港口、船舶、货币等)有相同的使用模式,我们用工厂函数消除重复:
typescript
/**
* 创建基础数据 Hook 的工厂函数
* 所有基础数据 Hook 共享相同的接口和行为
*/
function createBaseDataHook<T extends BaseDataItem, R>(
fetchFn: () => Promise<R>,
transformFn: (response: R) => T[],
cacheConfig: CacheConfig,
): (params?: QueryParams) => BaseDataHookResult<T> {
return (params: QueryParams = {}): BaseDataHookResult<T> => {
const { useEnglish = false } = params
// 使用缓存系统
const { data, loading, error, refresh, clearCache } = useBasicDataCache(
cacheConfig.key,
async () => transformFn(await fetchFn()),
{ ttl: cacheConfig.ttl },
)
// 根据参数过滤数据
const filteredData = computed(() => {
let result = data.value || []
if (params.keyword) {
result = BaseAdapter.filterByKeyword(result, params.keyword)
}
if (params.enabledOnly) {
result = BaseAdapter.filterByEnabled(result, true)
}
return result
})
// Element Plus 格式的选项
const options = computed(() =>
BaseAdapter.toOptions(filteredData.value, useEnglish)
)
return {
data: filteredData,
loading,
error,
options,
isEmpty: computed(() => filteredData.value.length === 0),
isReady: computed(() => !loading.value && !error.value),
refresh,
search: (keyword) => BaseAdapter.filterByKeyword(data.value, keyword),
getByCode: (code) => data.value?.find(item => item.code === code),
clearCache,
}
}
}
// 一行代码创建新的基础数据 Hook
export const usePorts = createBaseDataHook(
queryPortList,
PortAdapter.transform,
{ key: 'PORTS', ttl: 10 * 60 * 1000 }
)
export const useVessels = createBaseDataHook(
queryVesselList,
VesselAdapter.transform,
{ key: 'VESSELS', ttl: 15 * 60 * 1000 }
)
2. 字典数据:专为 UI 组件优化
字典数据是最常用的基础数据类型,我们为其设计了专门的 API:
typescript
/**
* 特定字典类型的组合式函数
* 提供开箱即用的下拉选项和 label 查询
*/
export function useDictType(dictType: string) {
const { locale } = useI18n()
const { data: dictMap, loading, error, refresh } = useAllDictData()
// 响应式的选项列表,自动根据语言切换
const options = computed(() => {
const items = dictMap.value?.[dictType] || []
return items.map(item => ({
label: locale.value === 'en' ? item.labelEn : item.label,
value: item.value,
}))
})
// 根据 code 获取 label,支持国际化
function getLabel(code: string): string {
const items = dictMap.value?.[dictType] || []
const item = items.find(i => i.value === code)
if (!item) return code
return locale.value === 'en' ? item.labelEn : item.label
}
return {
options,
items: computed(() => dictMap.value?.[dictType] || []),
loading,
error,
getLabel,
getLabels: (codes: string[]) => codes.map(getLabel),
refresh,
}
}
3. 智能缓存:TTL + 全局共享
缓存是性能优化的关键,我们实现了带 TTL 的响应式缓存:
typescript
/**
* 带 TTL 的响应式缓存 Hook
* 支持过期自动刷新、手动清除
*/
export function useBasicDataCache<T>(
key: string,
fetcher: () => Promise<T>,
options: { ttl: number }
) {
// 使用 VueUse 的 useStorageAsync 实现持久化
const cached = useStorageAsync<CacheEntry<T> | null>(
`basic-data:${key}`,
null,
localStorage
)
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
// 检查缓存是否过期
const isExpired = computed(() => {
if (!cached.value) return true
return Date.now() - cached.value.timestamp > options.ttl
})
// 加载数据(带去重)
let loadingPromise: Promise<void> | null = null
async function load() {
if (loadingPromise) return loadingPromise
if (!isExpired.value && cached.value) {
data.value = cached.value.data
return
}
loading.value = true
loadingPromise = fetcher()
.then(result => {
data.value = result
cached.value = { data: result, timestamp: Date.now() }
})
.catch(err => {
error.value = err
// 如果有旧缓存,降级使用
if (cached.value) {
data.value = cached.value.data
}
})
.finally(() => {
loading.value = false
loadingPromise = null
})
return loadingPromise
}
// 自动加载
load()
return {
data: computed(() => data.value),
loading: computed(() => loading.value),
error: computed(() => error.value),
refresh: () => {
cached.value = null
return load()
},
clearCache: () => {
cached.value = null
data.value = null
}
}
}
🎯 使用示例
场景 1:下拉选择器
vue
<template>
<el-select v-model="form.status" placeholder="请选择状态">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
<script setup lang="ts">
import { useDictType } from '~/composables/basicData'
const { options: statusOptions } = useDictType('ORDER_STATUS')
</script>
场景 2:表格列显示 label
vue
<template>
<el-table :data="tableData">
<el-table-column prop="code" label="编号" />
<el-table-column label="状态">
<template #default="{ row }">
<el-tag :type="getStatusColor(row.status)">
{{ getStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import { useDictType } from '~/composables/basicData'
const { getLabel: getStatusLabel, getColorType: getStatusColor } =
useDictType('ORDER_STATUS')
</script>
场景 3:港口选择(带搜索)
vue
<template>
<el-select
v-model="selectedPort"
filterable
remote
:remote-method="handleSearch"
:loading="loading"
placeholder="搜索港口..."
>
<el-option
v-for="port in portOptions"
:key="port.value"
:label="port.label"
:value="port.value"
/>
</el-select>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { usePorts } from '~/composables/basicData'
const keyword = ref('')
const searchParams = computed(() => ({
keyword: keyword.value,
enabledOnly: true
}))
const { options: portOptions, loading } = usePorts(searchParams)
function handleSearch(query: string) {
keyword.value = query
}
</script>
场景 4:获取关联数据
typescript
import { usePorts, useCountries } from '~/composables/basicData'
const { getByCode: getPort } = usePorts()
const { getByCode: getCountry } = useCountries()
// 获取港口及其所属国家信息
function getPortWithCountry(portCode: string) {
const port = getPort(portCode)
if (!port) return null
const country = port.countryCode ? getCountry(port.countryCode) : null
return {
...port,
countryName: country?.nameCn || '',
countryNameEn: country?.nameEn || '',
}
}
⚡ 性能优化效果
Before vs After
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 字典 API 请求次数/页 | 15-20 次 | 1 次 | 95%↓ |
| 首屏加载时间 | 3.2s | 1.8s | 44%↓ |
| 内存占用(字典数据) | 分散存储 | 统一缓存 | 60%↓ |
| 代码行数(基础数据相关) | ~2000 行 | ~500 行 | 75%↓ |
缓存命中率
erlang
┌────────────────────────────────────────────────────┐
│ 缓存命中情况 │
├────────────────────────────────────────────────────┤
│ 字典数据 ████████████████████████████████ 98% │
│ 港口数据 ██████████████████████████████░░ 92% │
│ 货币数据 ████████████████████████████████ 99% │
│ 国家数据 ████████████████████████████████ 99% │
└────────────────────────────────────────────────────┘
🔧 最佳实践
✅ 推荐做法
typescript
// 1. 使用解构获取需要的方法
const { options, getLabel, loading } = useDictType('STATUS')
// 2. 使用 computed 传递动态参数
const params = computed(() => ({ keyword: search.value }))
const { data } = usePorts(params)
// 3. 处理加载状态
<template v-if="loading">加载中...</template>
<template v-else>{{ getLabel(code) }}</template>
// 4. 统一从入口导入
import { useDictType, usePorts } from '~/composables/basicData'
❌ 避免做法
typescript
// 1. 不要在循环中调用 Hook
// ❌ 错误
tableData.forEach(row => {
const { getLabel } = useDictType('STATUS') // 每次循环都创建新实例
row.statusLabel = getLabel(row.status)
})
// ✅ 正确
const { getLabel } = useDictType('STATUS')
tableData.forEach(row => {
row.statusLabel = getLabel(row.status)
})
// 2. 不要忽略加载状态
// ❌ 错误
const label = getLabel(code) // 数据可能还未加载
// ✅ 正确
const label = computed(() => loading.value ? '加载中' : getLabel(code))
📦 扩展:添加新的基础数据类型
添加新的基础数据类型非常简单,只需 3 步:
typescript
// 1. 定义 API
// api/index.ts
export async function queryNewDataList() {
return request.get('/api/new-data/list')
}
// 2. 定义适配器
// adapters.ts
export const NewDataAdapter = {
transform(response: ApiResponse): BaseDataItem[] {
return response.data.map(item => ({
code: item.id,
nameCn: item.name,
nameEn: item.nameEn,
enabled: item.status === 1,
}))
}
}
// 3. 创建 Hook
// hooks.ts
export const useNewData = createBaseDataHook(
queryNewDataList,
NewDataAdapter.transform,
{ key: 'NEW_DATA', ttl: 10 * 60 * 1000 }
)
// 4. 导出
// index.ts
export { useNewData } from './hooks'
🎓 总结
通过这套基础数据管理方案,我们实现了:
- 统一入口 - 所有基础数据从
~/composables/basicData导入 - 自动缓存 - TTL 机制 + 全局共享,避免重复请求
- 类型安全 - 完整的 TypeScript 类型定义
- 国际化 - 自动根据语言环境切换中英文
- 开箱即用 - Element Plus 格式的选项,直接用于组件
- 易于扩展 - 工厂模式,添加新类型只需几行代码
这套方案已在我们的航运物流系统中稳定运行,支撑着日均数万次的基础数据查询,希望能给正在处理类似问题的团队一些启发。
📚 相关资源
💬 欢迎在评论区交流讨论,如果觉得有帮助,请点赞收藏~