普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月4日首页

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

作者 狗弟
2025年12月4日 18:13

作者: 狗弟 发布日期: 2025-12-04
技术栈: Vue 3 + TypeScript + Composition API + Element Plus
阅读时长: 约 15 分钟


📌 引言

在大型企业应用中,基础数据(字典、港口、货币、国家等)的管理往往是一个被忽视但至关重要的领域。一个设计良好的基础数据方案可以:

  • 🚀 减少 70% 以上的重复代码
  • 降低 API 请求次数 80%+
  • 🎯 提升开发效率和代码可维护性
  • 🌍 无缝支持国际化切换

本文将分享我们在航运物流系统中设计和实现的统一基础数据管理方案,涵盖架构设计、性能优化、缓存策略和最佳实践。


🤔 问题背景:野蛮生长的痛点

最初的混乱

在项目初期,每个开发者按自己的方式获取和使用基础数据:

// 🔴 问题代码示例:每个组件各自为政

// 组件 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 无法提示

🏗️ 架构设计:统一数据源

核心设计理念

我们采用单一数据源 + 工厂模式的架构:

┌─────────────────────────────────────────────────────────┐
│                    业务组件层                             │
│   ┌─────────┐   ┌─────────┐   ┌─────────┐              │
│   │ 下拉框   │   │ 表格列   │   │ 标签     │              │
│   └────┬────┘   └────┬────┘   └────┬────┘              │
│        │             │             │                    │
│        └─────────────┴─────────────┘                    │
│                      │                                  │
├──────────────────────▼──────────────────────────────────┤
│              Composables 统一入口                        │
│   ┌─────────────────────────────────────────────────┐   │
│   │  import { useDictType, usePorts } from          │   │
│   │         '~/composables/basicData'               │   │
│   └─────────────────────────────────────────────────┘   │
├─────────────────────────────────────────────────────────┤
│                    模块内部架构                          │
│                                                         │
│   ┌──────────┐  ┌──────────┐  ┌──────────┐            │
│   │  hooks   │  │ adapters │  │  cache   │            │
│   │ 业务封装  │  │ 数据适配  │  │ 缓存管理  │            │
│   └────┬─────┘  └────┬─────┘  └────┬─────┘            │
│        │             │             │                    │
│        └─────────────┴─────────────┘                    │
│                      │                                  │
│              ┌───────▼───────┐                          │
│              │     API       │                          │
│              │  统一数据获取   │                          │
│              └───────────────┘                          │
└─────────────────────────────────────────────────────────┘

目录结构

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 创建模式

不同类型的基础数据(港口、船舶、货币等)有相同的使用模式,我们用工厂函数消除重复:

/**
 * 创建基础数据 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:

/**
 * 特定字典类型的组合式函数
 * 提供开箱即用的下拉选项和 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 的响应式缓存:

/**
 * 带 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:下拉选择器

<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

<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:港口选择(带搜索)

<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:获取关联数据

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%↓

缓存命中率

┌────────────────────────────────────────────────────┐
│                  缓存命中情况                        │
├────────────────────────────────────────────────────┤
│ 字典数据 ████████████████████████████████ 98%       │
│ 港口数据 ██████████████████████████████░░ 92%       │
│ 货币数据 ████████████████████████████████ 99%       │
│ 国家数据 ████████████████████████████████ 99%       │
└────────────────────────────────────────────────────┘

🔧 最佳实践

✅ 推荐做法

// 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'

❌ 避免做法

// 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 步:

// 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. 易于扩展 - 工厂模式,添加新类型只需几行代码

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


📚 相关资源


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

Vue 3 企业级表格组件体系设计实战

作者 狗弟
2025年12月4日 16:29

这篇文章源于我们团队在重构一个中大型后台管理系统时的真实经历。从最初的"随便封装一下"到后来踩了无数坑,最终形成了一套相对完整的表格组件体系。希望这些实践经验能给正在做类似事情的你一些参考。

背景:为什么要自己封装?

说实话,一开始我们也没想过要搞这么复杂。

项目初期用的是 Element Plus 的 el-table,写几个列表页完全够用。但随着业务增长,问题开始浮现:

  • 每个页面都在重复写分页逻辑
  • 搜索表单的样式五花八门
  • 新人来了不知道该怎么写,代码风格各异
  • 改个全局样式要改 N 个文件

熟悉吗?这基本是所有中大型项目都会遇到的问题。

我们团队花了大概两个月时间,从零开始设计了一套表格组件体系。这篇文章就来聊聊我们的设计思路和踩过的坑。

先看成果:一个典型的列表页长什么样

废话不多说,直接上代码。现在我们写一个完整的用户管理页面,大概是这样:

<script setup lang="ts">
import type { QueryFieldConfig } from '@/components/QueryForm/types'
import type { TableColumn } from '@/components/Table/types'
import { ref } from 'vue'
import * as userApi from '@/api/user'
import DataTable from '@/components/DataTable'
import QueryForm from '@/components/QueryForm'
import TablePagination from '@/components/TablePagination'
import { useConfirm } from '@/composables/useConfirm'
import { useMessage } from '@/composables/useMessage'
import { useTableList } from '@/composables/useTableList'

// 状态选项
const STATUS_OPTIONS = [
  { label: '全部', value: '' },
  { label: '启用', value: 1 },
  { label: '禁用', value: 0 },
]

// 组合式函数
const { confirm } = useConfirm()
const { success } = useMessage()

// 搜索条件
const searchParams = ref({ keyword: '', status: '' })

// 表格数据管理 - 一行代码搞定分页、加载、搜索
const {
  tableData,
  loading,
  total,
  pageNo,
  pageSize,
  loadData,
  search,
  resetSearch,
  // 多选相关
  selectedRows,
  clearSelection,
} = useTableList({
  api: userApi.getList,
  searchParams,
  immediate: true,
  rowKey: 'id',
})

// 查询表单配置
const queryFields: QueryFieldConfig[] = [
  {
    key: 'keyword',
    type: 'input',
    label: '关键词',
    placeholder: '请输入用户名/邮箱',
    clearable: true,
  },
  {
    key: 'status',
    type: 'select',
    label: '状态',
    options: STATUS_OPTIONS,
    placeholder: '请选择',
  },
  {
    key: 'dateRange',
    type: 'daterange',
    label: '创建时间',
    advanced: true, // 折叠到高级搜索
  },
]

// 表格列配置
const columns: TableColumn[] = [
  { key: 'username', label: '用户名', minWidth: '120px' },
  { key: 'email', label: '邮箱', minWidth: '180px' },
  { key: 'role', label: '角色', width: '100px' },
  { key: 'status', label: '状态', width: '80px', align: 'center' },
  { key: 'createdAt', label: '创建时间', width: '160px' },
  { key: 'actions', label: '操作', width: '150px', align: 'center' },
]

// 事件处理
function handleQuery(data: Record<string, any>) {
  Object.assign(searchParams.value, data)
  search()
}

function handleReset() {
  searchParams.value = { keyword: '', status: '' }
  resetSearch()
}

async function handleDelete(row: any) {
  await confirm('确定删除该用户?删除后不可恢复。')
  await userApi.deleteUser(row.id)
  success('删除成功')
  loadData()
}

async function handleBatchDelete() {
  if (selectedRows.value.length === 0)
    return
  await confirm(`确定删除选中的 ${selectedRows.value.length} 条数据?`)
  await userApi.batchDelete(selectedRows.value.map(r => r.id))
  success('批量删除成功')
  clearSelection()
  loadData()
}
</script>

<template>
  <div class="page-container">
    <!-- 查询区域 -->
    <QueryForm
      :fields="queryFields"
      :loading="loading"
      @query="handleQuery"
      @reset="handleReset"
    >
      <template #extra>
        <el-button type="primary" @click="handleAdd">
          新增用户
        </el-button>
        <el-button
          type="danger"
          :disabled="selectedRows.length === 0"
          @click="handleBatchDelete"
        >
          批量删除 ({{ selectedRows.length }})
        </el-button>
      </template>
    </QueryForm>

    <!-- 表格区域 -->
    <DataTable
      :data="tableData"
      :columns="columns"
      :loading="loading"
      selection-mode="multiple"
      @selection-change="(rows) => selectedRows = rows"
    >
      <!-- 状态列自定义渲染 -->
      <template #status="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>

      <!-- 操作列 -->
      <template #actions="{ row }">
        <el-button link type="primary" @click="handleEdit(row)">
          编辑
        </el-button>
        <el-button link type="danger" @click="handleDelete(row)">
          删除
        </el-button>
      </template>
    </DataTable>

    <!-- 分页区域 -->
    <TablePagination
      :page-no="pageNo"
      :page-size="pageSize"
      :total="total"
      @change="loadData"
    />
  </div>
</template>

大概 100 行代码,一个功能完整的列表页就出来了,包含搜索、分页、多选、批量操作。

组件设计:我们是怎么拆分的

整体架构

经过几轮迭代,我们最终把列表页抽象成了四个核心模块:

┌────────────────────────────────────────────┐
│               BaseQuery                    │  ← 查询表单
│         (配置驱动,灵活扩展)                  │
├────────────────────────────────────────────┤
│                    ↓                       │
│         useTableList / useCrud             │  ← 数据管理
│   (分页、加载、搜索 / 完整CRUD封装)         │
│                    ↓                       │
├────────────────────────────────────────────┤
│         CmcCardTable / CmcTable            │  ← 表格展示
│          (两种风格可选)                      │
├────────────────────────────────────────────┤
│              Pagination                    │  ← 分页控制
└────────────────────────────────────────────┘

这个拆分不是一开始就想好的,而是写了十几个列表页之后,把重复的部分逐步提取出来的。

为什么是两个表格组件?

你可能注意到了,我们有 CmcTableCmcCardTable 两个表格组件。这不是设计失误,而是故意为之。

CmcTable - 基于 el-table 的传统表格

  • 适合数据密集型的报表场景
  • 支持复杂表头、合并单元格
  • 老项目迁移成本低

CmcCardTable - 卡片式表格

  • 现代风格,每行数据是一张卡片
  • 适合需要频繁操作的管理后台
  • 移动端适配更友好

两种风格各有适用场景,强行统一反而会牺牲灵活性。

useTableList:表格数据管理的基石

坦白说,这个 Hook 是整套方案的基础。写它的初衷很简单:我受够了每个页面都写一遍分页逻辑。

💡 选择建议:如果你的页面需要完整的增删改查功能,推荐直接使用 useCruduseTableList 适合仅需列表展示的场景。

最初的痛点

没有这个 Hook 之前,每个列表页都是这样的:

const tableData = ref([])
const loading = ref(false)
const total = ref(0)
const pageNo = ref(1)
const pageSize = ref(10)

async function loadData() {
  loading.value = true
  try {
    const res = await api.getList({
      pageNo: pageNo.value,
      pageSize: pageSize.value,
      ...searchParams.value,
    })
    tableData.value = res.data.list
    total.value = res.data.total
  } finally {
    loading.value = false
  }
}

function handleSearch() {
  pageNo.value = 1
  loadData()
}

function handlePageChange(page) {
  pageNo.value = page
  loadData()
}

// ... 还有一堆

重复写了二三十遍之后,实在忍不了了。

现在的用法

const { tableData, loading, total, pageNo, pageSize, loadData, search } = useTableList({
  api: userApi.getList,
  searchParams,
  immediate: true, // 组件挂载后立即加载
  transform: data => data.map(item => ({
    ...item,
    fullName: `${item.firstName} ${item.lastName}`,
  })),
})

一行代码,所有状态管理都搞定了。

完整实现

下面是 useTableList 的完整实现,包含错误处理、数据缓存、多选支持等功能:

// types.ts - 类型定义
import type { Ref, ComputedRef } from 'vue'

/** API 响应结构 */
interface ApiResponse<T> {
  code: number
  message: string
  data: {
    list: T[]
    total: number
  }
}

/** Hook 配置选项 */
export interface TableListOptions<T> {
  /** API 请求函数 */
  api: (params: any) => Promise<ApiResponse<T>>
  /** 是否立即加载 */
  immediate?: boolean
  /** 默认搜索参数 */
  defaultParams?: Record<string, any>
  /** 外部搜索参数(响应式) */
  searchParams?: Ref<Record<string, any>>
  /** 数据转换函数 */
  transform?: (data: T[]) => T[]
  /** 错误处理回调 */
  onError?: (error: Error) => void
  /** 成功回调 */
  onSuccess?: (data: { list: T[], total: number }) => void
  /** 行唯一标识字段 */
  rowKey?: string
  /** 默认每页条数 */
  defaultPageSize?: number
}

/** Hook 返回值 */
export interface TableListReturn<T> {
  // 响应式数据
  tableData: Ref<T[]>
  loading: Ref<boolean>
  total: Ref<number>
  pageNo: Ref<number>
  pageSize: Ref<number>
  error: Ref<Error | null>

  // 计算属性
  hasData: ComputedRef<boolean>
  isEmpty: ComputedRef<boolean>

  // 多选相关
  selectedRows: Ref<T[]>
  selectedRowKeys: ComputedRef<(string | number)[]>
  hasSelected: ComputedRef<boolean>
  selectedCount: ComputedRef<number>

  // 方法
  loadData: () => Promise<void>
  refresh: () => Promise<void>
  search: (params?: Record<string, any>) => Promise<void>
  resetSearch: () => Promise<void>

  // 多选操作
  setSelectedRows: (rows: T[]) => void
  clearSelection: () => void
  toggleRowSelection: (row: T, selected?: boolean) => void
  toggleAllSelection: (selected?: boolean) => void

  // 导出
  getExportData: (options?: { all?: boolean }) => Promise<T[]>
}
```

```typescript
// useTableList.ts - 核心实现
import { ref, computed, watch, unref, onMounted } from 'vue'
import type { Ref } from 'vue'
import type { TableListOptions, TableListReturn } from './types'

export function useTableList<T extends Record<string, any>>(
  options: TableListOptions<T>
): TableListReturn<T> {
  const {
    api,
    immediate = false,
    defaultParams = {},
    searchParams: externalSearchParams,
    transform,
    onError,
    onSuccess,
    rowKey = 'id',
    defaultPageSize = 20,
  } = options

  // ==================== 响应式状态 ====================
  const tableData = ref<T[]>([]) as Ref<T[]>
  const loading = ref(false)
  const total = ref(0)
  const pageNo = ref(1)
  const pageSize = ref(defaultPageSize)
  const searchParams = ref<Record<string, any>>({ ...defaultParams })
  const error = ref<Error | null>(null)
  const selectedRows = ref<T[]>([]) as Ref<T[]>

  // ==================== 计算属性 ====================
  const hasData = computed(() => tableData.value.length > 0)
  const isEmpty = computed(() => !loading.value && tableData.value.length === 0)
  const selectedRowKeys = computed(() => selectedRows.value.map(row => row[rowKey]))
  const hasSelected = computed(() => selectedRows.value.length > 0)
  const selectedCount = computed(() => selectedRows.value.length)

  // ==================== 核心方法 ====================

  /** 构建请求参数 */
  function buildRequestParams() {
    const params: Record<string, any> = {
      pageNo: pageNo.value,
      pageSize: pageSize.value,
      ...searchParams.value,
    }

    // 合并外部搜索参数
    if (externalSearchParams) {
      Object.assign(params, unref(externalSearchParams))
    }

    // 过滤空值(可选优化)
    return Object.fromEntries(
      Object.entries(params).filter(([_, v]) => v !== '' && v != null)
    )
  }

  /** 加载数据 */
  async function loadData() {
    loading.value = true
    error.value = null

    try {
      const params = buildRequestParams()
      const response = await api(params)

      if (response?.data) {
        const { list, total: totalCount } = response.data
        tableData.value = transform ? transform(list) : list
        total.value = totalCount
        onSuccess?.(response.data)
      }
    } catch (err) {
      const errorObj = err instanceof Error ? err : new Error(String(err))
      error.value = errorObj
      onError?.(errorObj)
      tableData.value = []
      total.value = 0
    } finally {
      loading.value = false
    }
  }

  /** 刷新(保持当前页) */
  const refresh = () => loadData()

  /** 搜索(重置到第一页) */
  async function search(params?: Record<string, any>) {
    if (params) Object.assign(searchParams.value, params)
    pageNo.value = 1
    await loadData()
  }

  /** 重置搜索条件 */
  async function resetSearch() {
    searchParams.value = { ...defaultParams }
    pageNo.value = 1
    await loadData()
  }

  // ==================== 多选操作 ====================

  const setSelectedRows = (rows: T[]) => { selectedRows.value = rows }
  const clearSelection = () => { selectedRows.value = [] }

  function toggleRowSelection(row: T, selected?: boolean) {
    const key = row[rowKey]
    const index = selectedRows.value.findIndex(r => r[rowKey] === key)

    if (selected === undefined) {
      index > -1 ? selectedRows.value.splice(index, 1) : selectedRows.value.push(row)
    } else if (selected && index === -1) {
      selectedRows.value.push(row)
    } else if (!selected && index > -1) {
      selectedRows.value.splice(index, 1)
    }
  }

  function toggleAllSelection(selected?: boolean) {
    if (selected === undefined) {
      selectedRows.value = selectedRows.value.length === tableData.value.length ? [] : [...tableData.value]
    } else {
      selectedRows.value = selected ? [...tableData.value] : []
    }
  }

  // ==================== 导出功能 ====================

  async function getExportData(exportOptions?: { all?: boolean }): Promise<T[]> {
    if (!exportOptions?.all) return tableData.value

    const params = buildRequestParams()
    params.pageNo = 1
    params.pageSize = total.value || 10000

    try {
      const response = await api(params)
      return transform ? transform(response.data.list) : response.data.list
    } catch {
      return []
    }
  }

  // ==================== 副作用 ====================

  if (externalSearchParams) {
    watch(externalSearchParams, () => { pageNo.value = 1; loadData() }, { deep: true })
  }

  if (immediate) onMounted(loadData)

  return {
    tableData, loading, total, pageNo, pageSize, error,
    hasData, isEmpty,
    selectedRows, selectedRowKeys, hasSelected, selectedCount,
    loadData, refresh, search, resetSearch,
    setSelectedRows, clearSelection, toggleRowSelection, toggleAllSelection,
    getExportData,
  }
}
```

这个 Hook 的设计遵循了几个原则:

1. **单一职责**:只负责表格数据的获取和状态管理
2. **可组合**:可以和其他 Hook(如 `useConfirm`)自由组合
3. **类型安全**:完整的 TypeScript 泛型支持
4. **可扩展**:通过 `transform` 支持数据转换,通过回调支持自定义行为

## QueryForm:配置驱动的查询表单

另一个让我很满意的设计是查询表单组件。

### 设计思路

传统做法是每个页面手写搜索表单,这样会有几个问题:

1. 样式不统一(每个人写的都不一样)
2. 重复代码多(input、select、datepicker 写了无数遍)
3. 新增字段麻烦(要改模板又要改逻辑)

我们的方案是:**用配置描述表单结构,组件自动渲染。**

```typescript
const queryFields = [
  { key: 'keyword', type: 'input', label: '关键词', placeholder: '请输入' },
  { key: 'status', type: 'select', label: '状态', options: STATUS_OPTIONS },
  { key: 'dateRange', type: 'daterange', label: '日期范围' },
  { key: 'category', type: 'select', label: '分类', advanced: true }, // 高级搜索
]

advanced: true 的字段会被折叠到高级搜索里,点击展开才显示。这样既保持了界面简洁,又不丢失功能。

自定义组件扩展

有时候标准的 input、select 不够用,比如需要一个支持远程搜索的下拉框,或者级联选择器。

这种情况下可以通过插槽或者注册自定义组件来扩展:

// 方式一:使用 render 函数
const queryFields = [
  {
    key: 'category',
    type: 'custom',
    render: (h, { model }) => h(CategoryCascader, {
      'modelValue': model.category,
      'onUpdate:modelValue': v => model.category = v
    })
  },
]

// 方式二:注册业务组件
const queryFields = [
  { key: 'department', type: 'component', component: 'DepartmentSelect' },
]

这样既保持了配置驱动的优势,又能处理复杂场景。

QueryForm 组件核心实现

<script setup lang="ts">
import { computed, ref } from 'vue'

interface FieldConfig {
  key: string
  type: 'input' | 'select' | 'daterange' | 'custom' | 'component'
  label?: string
  placeholder?: string
  options?: Array<{ label: string, value: any }>
  advanced?: boolean // 是否在高级搜索中
  props?: Record<string, any>
}

const props = defineProps<{
  fields: FieldConfig[]
  loading?: boolean
  showAdvanced?: boolean
}>()

const emit = defineEmits<{
  query: [data: Record<string, any>]
  reset: []
}>()

// 表单数据
const formData = ref<Record<string, any>>({})

// 是否展开高级搜索
const showAdvancedFields = ref(false)

// 基础字段 vs 高级字段
const basicFields = computed(() => props.fields.filter(f => !f.advanced))
const advancedFields = computed(() => props.fields.filter(f => f.advanced))

// 初始化表单数据
props.fields.forEach((field) => {
  formData.value[field.key] = field.type === 'daterange' ? [] : ''
})

function handleQuery() {
  emit('query', { ...formData.value })
}

function handleReset() {
  props.fields.forEach((field) => {
    formData.value[field.key] = field.type === 'daterange' ? [] : ''
  })
  emit('reset')
}
</script>

<template>
  <div class="query-form">
    <el-form :model="formData" inline>
      <!-- 基础字段 -->
      <el-form-item v-for="field in basicFields" :key="field.key" :label="field.label">
        <el-input
          v-if="field.type === 'input'"
          v-model="formData[field.key]"
          :placeholder="field.placeholder"
          clearable
        />
        <el-select
          v-else-if="field.type === 'select'"
          v-model="formData[field.key]"
          :placeholder="field.placeholder"
          clearable
        >
          <el-option
            v-for="opt in field.options"
            :key="opt.value"
            :label="opt.label"
            :value="opt.value"
          />
        </el-select>
        <el-date-picker
          v-else-if="field.type === 'daterange'"
          v-model="formData[field.key]"
          type="daterange"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
        />
      </el-form-item>

      <!-- 高级搜索(可折叠) -->
      <template v-if="showAdvancedFields && advancedFields.length">
        <el-form-item v-for="field in advancedFields" :key="field.key" :label="field.label">
          <!-- 同上渲染逻辑 -->
        </el-form-item>
      </template>

      <!-- 操作按钮 -->
      <el-form-item>
        <el-button type="primary" :loading="loading" @click="handleQuery">
          查询
        </el-button>
        <el-button @click="handleReset">
          重置
        </el-button>
        <el-button
          v-if="advancedFields.length"
          link
          @click="showAdvancedFields = !showAdvancedFields"
        >
          {{ showAdvancedFields ? '收起' : '展开' }}
        </el-button>
      </el-form-item>
    </el-form>

    <!-- 额外操作区(如新增按钮) -->
    <div class="query-form__extra">
      <slot name="extra" />
    </div>
  </div>
</template>

踩过的坑

说了这么多好的,也得聊聊踩过的坑。

坑一:两个表格组件的 API 不统一

我们有两个表格组件:传统的 BasicTable 和卡片式的 CardTable。由于历史原因,列配置字段名不一样:

// BasicTable(沿用 Element Plus 命名)
{ prop: 'name', label: '名称' }

// CardTable(新设计)
{ key: 'name', label: '名称' }

两个组件加起来有 50 多个页面在用,强行统一 API 改动太大。我们的做法是在组件内部做兼容

// 在 CardTable 组件中兼容 prop 字段
const warnedColumns = new Set<string>()

const normalizedColumns = computed(() => {
  return props.columns.map((col) => {
    // 如果使用了 prop,发出废弃警告
    if (col.prop && !col.key) {
      if (import.meta.env.DEV && !warnedColumns.has(col.prop)) {
        warnedColumns.add(col.prop)
        console.warn(
          `[CardTable] 列配置 "prop" 已废弃,请使用 "key"。\n`
          + `  问题: { prop: "${col.prop}" }\n`
          + `  修改为: { key: "${col.prop}" }`
        )
      }
      return { ...col, key: col.prop }
    }
    return col
  })
})

这样老代码继续工作,新代码统一用 key,开发环境会有废弃警告提醒。

坑二:Pagination 的传参方式

最初 Pagination 的设计是这样的:

<Pagination :page-no="pageNo" :page-size="pageSize" :total="total" />

看起来很直观对吧?但用起来发现个问题:useTableList 返回的 pageNo 是 ref,直接传会丢失响应性。

后来改成了传对象:

<Pagination :condition="{ pageNo, pageSize, total }" />

虽然稍微啰嗦一点,但避免了响应性问题。

坑三:过度抽象

有段时间我们想把所有东西都抽象成配置,包括操作按钮:

const columns = [
  { key: 'actions', actions: [
    { label: '编辑', onClick: handleEdit },
    { label: '删除', onClick: handleDelete, type: 'danger' },
  ] }
]

看起来很优雅,但实际用的时候发现问题一堆:

  • 按钮需要动态显隐怎么办?
  • 按钮之间要加分隔符怎么办?
  • 需要 loading 状态怎么办?

最后还是改回了插槽的方式,让业务代码自己写按钮。有些事情就是不适合抽象,硬抽象只会增加复杂度。

一些实践建议

基于我们的经验,给正在做类似事情的同学几个建议:

1. 先写业务代码,再考虑抽象

不要一上来就想着设计完美的组件体系。先写几个页面,看看哪些代码在重复,然后再提取公共部分。

我们的 useTableList 就是写了二十多个列表页之后才提取出来的。

2. 兼容性比完美更重要

老项目升级最怕的就是 breaking change。如果新旧 API 可以共存,就让它们共存。

用废弃警告提醒开发者,让迁移自然发生,而不是强制所有人一起改。

3. 保持克制

不是所有东西都需要抽象。有时候复制粘贴比抽象更简单,也更容易理解。

问自己一个问题:这个抽象能让代码更简单吗? 如果答案是否定的,那就别抽象。

4. 文档和示例很重要

我们专门做了一个"开发者中心"页面,里面有各种组件的用法示例。新人入职直接看这个页面,比翻文档效率高多了。

useCrud:完整 CRUD 封装(新增)

基于 useTableList 的实践,我们发现新增/编辑弹窗的逻辑也在重复写。于是封装了 useCrud Hook,把完整的增删改查操作都包含进去。

useCrud vs useTableList

特性 useTableList useCrud
列表查询
分页管理
多选操作
表单弹窗
新增/编辑
删除/批量删除
适用场景 仅列表展示 完整 CRUD 页面

使用示例

import { useCrud } from '~/composables/business/useCrud'

const {
  // 列表状态
  tableData, loading, total,
  loadData, search, resetSearch,
  
  // 多选状态
  hasSelected, selectedCount,
  setSelectedRows, handleBatchDelete,
  
  // 表单弹窗
  formDialog,
  openCreateDialog,
  openEditDialog,
  openViewDialog,
  closeDialog,
  submitForm,
  
  // 单条操作
  handleDelete,
  
  // 分页
  paginationCondition,
} = useCrud<UserItem>({
  listApi: userApi.getList,
  createApi: userApi.create,
  updateApi: userApi.update,
  deleteApi: userApi.delete,
  immediate: true,
  searchParams,
  rowKey: 'id',
  deleteConfirmTemplate: '确定要删除 "{name}" 吗?',
})

模板中的使用

<template>
  <!-- 操作按钮 -->
  <el-button @click="openCreateDialog()">新增</el-button>
  <el-button :disabled="!hasSelected" @click="handleBatchDelete">
    批量删除 ({{ selectedCount }})
  </el-button>

  <!-- 表格操作列 -->
  <template #actions="{ row }">
    <el-button @click="openViewDialog(row)">查看</el-button>
    <el-button @click="openEditDialog(row)">编辑</el-button>
    <el-button @click="handleDelete(row)">删除</el-button>
  </template>

  <!-- 新增/编辑弹窗 -->
  <el-dialog v-model="formDialog.visible" :title="formDialog.title">
    <el-form :model="formDialog.formData" :disabled="formDialog.mode === 'view'">
      <el-form-item label="名称">
        <el-input v-model="formDialog.formData.name" />
      </el-form-item>
      <!-- 其他字段... -->
    </el-form>
    <template #footer>
      <el-button @click="closeDialog">取消</el-button>
      <el-button 
        v-if="formDialog.mode !== 'view'" 
        type="primary" 
        :loading="formDialog.submitting"
        @click="submitForm"
      >
        确定
      </el-button>
    </template>
  </el-dialog>
</template>

formDialog 状态说明

interface FormDialogState {
  visible: boolean              // 弹窗显示状态
  mode: 'create' | 'edit' | 'view'  // 弹窗模式
  title: string                 // 弹窗标题(自动设置)
  formData: Record<string, any> // 表单数据
  submitting: boolean           // 提交中状态
  editingId: string | number | null  // 编辑中的记录 ID
}

设计考量

  1. 逻辑集中:将分散在各个方法中的 CRUD 逻辑统一管理
  2. 弹窗状态封装formDialog 响应式对象包含所有弹窗相关状态
  3. 操作方法内置handleDeletehandleBatchDelete 等包含确认弹窗、成功提示、自动刷新
  4. 可选使用:不需要完整 CRUD 的页面可以继续使用 useTableList

后续计划

这套体系还在持续迭代。接下来我们打算做的事情:

  1. useCrud Hook - 把表单弹窗的逻辑也封装进去(已完成)
  2. 表格状态持久化 - 保存列宽、排序配置到 localStorage
  3. 虚拟滚动支持 - 处理大数据量场景

如果你对这些话题感兴趣,欢迎关注后续更新。

最后

组件设计没有银弹,适合自己团队的才是最好的。

这篇文章分享的是我们团队的实践,不一定适用于所有项目。但如果其中某个点对你有启发,那这篇文章就没白写。

有问题欢迎评论区讨论~


本文首发于 2025 年 12 月,基于 Vue 3.5 + TypeScript 5 + Element Plus 2.9 技术栈。

❌
❌