企业级 Vue 3 基础数据管理方案:从混乱到统一

作者 : 狗弟 发布日期 : 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'

🎓 总结

通过这套基础数据管理方案,我们实现了:

  1. 统一入口 - 所有基础数据从 ~/composables/basicData 导入
  2. 自动缓存 - TTL 机制 + 全局共享,避免重复请求
  3. 类型安全 - 完整的 TypeScript 类型定义
  4. 国际化 - 自动根据语言环境切换中英文
  5. 开箱即用 - Element Plus 格式的选项,直接用于组件
  6. 易于扩展 - 工厂模式,添加新类型只需几行代码

这套方案已在我们的航运物流系统中稳定运行,支撑着日均数万次的基础数据查询,希望能给正在处理类似问题的团队一些启发。


📚 相关资源


💬 欢迎在评论区交流讨论,如果觉得有帮助,请点赞收藏~

相关推荐
前端涂涂42 分钟前
哈希指针,什么是区块链,genesis blcok,most recent block,tamper-evident log,merkle tree,binar
前端
尽兴-1 小时前
问题记录:数据库字段 `CHAR(n)` 导致前端返回值带空格的排查与修复
前端·数据库·mysql·oracle·达梦·varchar·char
DsirNg1 小时前
Vue 3:我在真实项目中如何用事件委托
前端·javascript·vue.js
克喵的水银蛇1 小时前
Flutter 适配实战:屏幕适配 + 暗黑模式 + 多语言
前端·javascript·flutter
前端涂涂1 小时前
第2讲:BTC-密码学原理 北大肖臻老师客堂笔记
前端
能不能送我一朵小红花2 小时前
基于uniapp的PDA手持设备红外扫码方案
前端·uni-app
风止何安啊2 小时前
别被 JS 骗了!终极指南:JS 类型转换真相大揭秘
前端·javascript·面试
拉不动的猪2 小时前
深入理解 Vue keep-alive:缓存本质、触发条件与生命周期对比
前端·javascript·vue.js
|晴 天|2 小时前
WebAssembly:为前端插上性能的翅膀
前端·wasm