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

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

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

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

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

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

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

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

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

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

vue 复制代码
<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 行代码,一个功能完整的列表页就出来了,包含搜索、分页、多选、批量操作。

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

整体架构

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

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

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

为什么是两个表格组件?

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

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

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

CmcCardTable - 卡片式表格

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

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

useTableList:表格数据管理的基石

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

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

最初的痛点

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

typescript 复制代码
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()
}

// ... 还有一堆

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

现在的用法

typescript 复制代码
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 的完整实现,包含错误处理、数据缓存、多选支持等功能:

typescript 复制代码
// 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 不够用,比如需要一个支持远程搜索的下拉框,或者级联选择器。

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

typescript 复制代码
// 方式一:使用 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 组件核心实现

vue 复制代码
<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。由于历史原因,列配置字段名不一样:

typescript 复制代码
// BasicTable(沿用 Element Plus 命名)
{ prop: 'name', label: '名称' }

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

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

typescript 复制代码
// 在 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 的设计是这样的:

vue 复制代码
<Pagination :page-no="pageNo" :page-size="pageSize" :total="total" />

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

后来改成了传对象:

vue 复制代码
<Pagination :condition="{ pageNo, pageSize, total }" />

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

坑三:过度抽象

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

typescript 复制代码
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 页面

使用示例

typescript 复制代码
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}" 吗?',
})

模板中的使用

vue 复制代码
<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 状态说明

typescript 复制代码
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 技术栈。

相关推荐
尘世中一位迷途小书童42 分钟前
JavaScript 一些小特性:让你的代码更优雅高效
前端·javascript·架构
草帽lufei43 分钟前
高强度SOLO真实业务项目
前端·ai编程·trae
1024肥宅43 分钟前
告别异地登录告警!用 GitHub Self-Hosted Runner 打造“零打扰”全栈自动化部署
前端·后端·github
GDAL1 小时前
CSS重置样式表(Reset CSS
前端·css
SpringLament1 小时前
TanStack Virtual 源码解析:定高/不定高虚拟列表实现原理以及框架无关设计
前端·javascript
猪猪拆迁队1 小时前
高性能 Package构建系统设计与实现
前端·后端·node.js
UIUV1 小时前
JavaScript中instanceof运算符的原理与实现
前端·javascript·代码规范
前端fighter1 小时前
全栈项目:闲置二手交易系统(一)
前端·vue.js·后端
飞行增长手记1 小时前
IP协议从跨境到物联网的场景化应用
服务器·前端·网络·安全