基于 Element-Plus 的增强型表格组件封装实践

Element Plus提供了功能丰富的el-table,但在复杂业务场景下,直接使用原生表格组件会导致大量重复代码,封装一个增强型表格组件(EnhancedTable),解决项目中表格使用痛点,提升开发效率。

为什么需要封装表格组件?

在实际项目开发中,直接使用Element Plus表格存在以下问题:

  • 重复代码多:每个页面都需要处理分页、加载状态、列配置等基础逻辑
  • 功能扩展难:新增功能(如列设置、自定义操作栏)需要在每个表格重复实现
  • 代码维护成本高:表格逻辑分散在各处,修改一处需多处调整
  • UI风格不统一:不同开发人员实现的表格样式和交互存在差异

通过封装EnhancedTable组件,我们实现了一次封装,多处复用,显著提升了开发效率和用户体验一致性。

关键实现细节

1. 类型定义与Props设计

interface TableColumn extends Partial<TableColumnCtx<any>> {
  prop: string
  label: string
  minWidth?: string | number
  sortable?: boolean | string
  filters?: any[]
  filterMethod?: (value: any, row: any, column: TableColumnCtx<any>) => boolean
  slot?: boolean // 标记是否使用自定义插槽
  disabledColumnSetting?: boolean
  fixed?: boolean | string
}

interface EnhancedTableProps {
  tableLoading: boolean
  data: any[]
  columns: TableColumn[]
  defaultVisibleColumns?: string[]
  showHeader?: boolean
  showSelection?: boolean
  indexConfig?: IndexConfig
  showPagination?: boolean
}
  • 继承TableColumnCtx但仅保留必要属性,避免类型不匹配问题
  • 添加slot属性标记自定义列,解决原生表格插槽使用不便问题
  • 通过IndexConfig精细化控制序号列显示

2. 属性合并与透传

const attrs = useAttrs()
const mergedTableProps = computed(() => {
  const { paginationAttrs, ...tableAttrs } = attrs
  return {
    ...props,
    ...tableAttrs
  }
})
  • 支持将额外属性透传给底层el-table
  • 避免属性冲突(特别处理了paginationAttrs
  • 保持组件API的灵活性

3. 列可见性控制

const visibleColumns = ref<string[]>(...defaultVisibleColumns)
const filteredColumns = computed(() => {
  const visibleSet = new Set(visibleColumns.value)
  return props.columns.filter(col => visibleSet.has(col.prop))
})
  • 支持动态显示/隐藏列(为后续列设置功能打基础)
  • 通过计算属性实现高效更新
  • 保持数据源与展示层分离

4. 分页状态管理

const pageInfo = defineModel('pageInfo', {
  type: Object as PropType<PageInfo>,
  default: () => ({ currentPage: 1, pageSize: 10, total: 100 })
})

const updatePageInfo = debounce({ delay: 300 }, () => emits('updatePageInfo'))
  • 使用defineModel实现v-model双向绑定
  • 添加防抖机制避免频繁请求
  • 提供默认分页配置,减少重复代码

5. 插槽机制设计

<template v-for="column in filteredColumns" :key="column.prop">
  <el-table-column v-bind="column">
    <template #default="scope" v-if="column.slot">
      <slot :name="column.prop" v-bind="scope"></slot>
    </template>
  </el-table-column>
</template>

解决痛点:

  • 通过column.slot标记简化自定义列配置
  • 保持与Element Plus插槽API兼容
  • 使模板代码更简洁直观

使用示例

基础用法

<EnhancedTable
  :data="tableData"
  :columns="columns"
  :paginationAttrs="paginationAttrs"
  v-model:pageInfo="pagination"
  @updatePageInfo="fetchData"
>
  <!-- 自定义状态列 -->
  <template #status="{ row }">
    <el-tag :type="row.status === 'active' ? 'success' : 'danger'">
      {{ row.status }}
    </el-tag>
  </template>
  
  <!-- 自定义操作列 -->
  <template #action="{ row }">
    <el-button type="primary" @click="edit(row)">编辑</el-button>
  </template>
  
  <!-- 自定义工具栏 -->
  <template #toolbar>
    <el-input v-model="searchQuery" placeholder="搜索..." />
    <el-button type="primary" @click="fetchData">搜索</el-button>
  </template>
</EnhancedTable>

高级配置

// 列配置示例
const columns = ref<CustomColumn[]>([
  { 
    property: 'name', 
    label: '姓名', 
    align: 'center', 
    width: '120',
    sortable: true 
  },
  { 
    property: 'status', 
    label: '状态', 
    width: '100', 
    slot: true,
    filterMethod: (value, row) => row.status === value
  },
  { 
    property: 'action', 
    label: '操作', 
    width: '120', 
    slot: true, 
    fixed: 'right' 
  }
])

// 分页配置
const paginationAttrs = {
  background: true,
  pageSizes: [10, 20, 50],
  layout: 'total, sizes, prev, pager, next'
}

未来优化方向

  • 列设置功能:添加列显示/隐藏的UI控制
  • 表格配置持久化:保存用户自定义的列设置
  • 虚拟滚动支持:优化大数据量场景性能
  • 导出功能集成:内置Excel导出能力
  • 响应式布局:适配不同屏幕尺寸

总结

组件EnhancedTable完整代码

<template>
  <div class="enhanced-table-container">
    <!-- 表格顶部操作区域 -->
    <div v-if="$slots.toolbar" class="w-full flex justify-between items-center mb-4">
      <slot name="toolbar"></slot>
    </div>
    
    <!-- 主表格 -->
    <el-table
      ref="tableRef"
      v-loading="tableLoading"
      v-bind="mergedTableProps"
      :data="tableData"
      :show-header="showHeader"
    >
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        width="55"
        align="center"
        fixed="left"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="indexConfig.showIndex"
        :label="indexConfig.label ?? '序号'"
        :width="indexConfig.width ?? '80'"
        :fixed="indexConfig.fixed ?? false"
        type="index"
        align="center"
      />
      
      <!-- 动态列 -->
      <template v-for="column in filteredColumns" :key="column.prop">
        <el-table-column
          v-bind="column"
          :prop="column.prop"
          :label="column.label"
          :min-width="column.minWidth"
          :sortable="column.sortable || false"
          :filters="column.filters"
          :filter-method="column.filterMethod"
        >
          <!-- 自定义列内容 -->
          <template #default="scope" v-if="column.slot">
            <slot :name="column.prop" v-bind="scope"></slot>
          </template>
        </el-table-column>
      </template>
      
      <!-- 自定义插槽 -->
      <slot></slot>
    </el-table>
    
    <!-- 分页区域 -->
    <div v-if="showPagination" class="flex justify-end mt-4">
      <el-pagination
        ref="paginationRef"
        v-bind="paginationAttrs"
        :total="pageInfo.total"
        :disabled="tableLoading"
        v-model:current-page="pageInfo.currentPage"
        v-model:page-size="pageInfo.pageSize"
        @update:current-page="updatePageInfo"
        @update:page-size="updatePageInfo"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import type { TableInstance, TableColumnCtx, ElPagination, PaginationProps } from 'element-plus' 
import { debounce } from 'radashi'

// 定义类型
interface TableColumn extends Partial<TableColumnCtx<any>> {
  prop: string
  label: string
  minWidth?: string | number
  sortable?: boolean | string
  filters?: any[]
  filterMethod?: (value: any, row: any, column: TableColumnCtx<any>) => boolean
  slot?: boolean
  disabledColumnSetting?: boolean
  fixed?: boolean | string
}

interface IndexConfig {
  showIndex?: boolean // 是否显示序号列
  label?: string  // 序号列标签
  width?: string | number // 序号列宽度
  fixed?: boolean | string  // 是否固定列
}
interface EnhancedTableProps {
  tableLoading: boolean // 表格加载状态
  data: any[] // 表格数据
  columns: TableColumn[]  // 列配置
  defaultVisibleColumns?: string[]  // 默认显示的列(用于列设置)
  showHeader?: boolean  // 是否显示表头
  showSelection?: boolean // 是否显示选择列
  indexConfig?: IndexConfig  // 序号列
  showPagination?: boolean  // 是否显示分页
}

const props = withDefaults(defineProps<EnhancedTableProps>(), { 
  tableLoading: false,
  data: () => [], 
  columns: () => [],   
  defaultVisibleColumns: () => [], 
  showHeader: true, 
  showSelection: false, 
  indexConfig: () => ({  
    showIndex: false
  }),
  showPagination: true,
})

// 合并props和attrs Table用
const attrs = useAttrs()
const mergedTableProps = computed(() => {
  // paginationAttrs作为Paginantion组件使用的属性,所以要排除
  const { paginationAttrs, ...tableAttrs } = attrs
  return {
    ...props,
    ...tableAttrs
  }
})

// 表格引用
const tableRef = ref<TableInstance | null>(null)

// 表格数据
const tableData = computed(() => props.data)

// 可见列
const visibleColumns = ref<string[]>(
  props.defaultVisibleColumns.length > 0 
    ? [...props.defaultVisibleColumns] 
    : props.columns.map(col => col.prop)
)

// 过滤后的列
const filteredColumns = computed(() => {
  const visibleSet = new Set(visibleColumns.value)
  return props.columns.filter(col => visibleSet.has(col.prop))
})

// pagination用
const paginationRef = ref<InstanceType<typeof ElPagination> | null>(null) 
const paginationAttrs  = computed(() => {
  const defaultAttrs = {
    background: true,
    hideOnSinglePage: true,
    pageSizes: [10, 20, 50, 100],
    layout: 'total, sizes, prev, pager, next, jumper',
  } as PaginationProps;

  const baseAttrs = attrs.paginationAttrs;
  const validAttrs = typeof baseAttrs === 'object' && baseAttrs !== null 
    ? baseAttrs 
    : {};

  return {
    ...defaultAttrs,
    ...validAttrs,
  } as PaginationProps;
})
const emits = defineEmits<{
  (e: 'updatePageInfo'): void
}>()

interface PageInfo {
  currentPage: number
  pageSize: number
  total: number
}
const pageInfo = defineModel('pageInfo', {
  type: Object as PropType<PageInfo>,
  default: () => ({
    currentPage: 1,
    pageSize: 10,
    total: 100
  })
})
const updatePageInfo = debounce(
  { delay: 300 }, 
  () => emits('updatePageInfo')
)


// 暴露方法
defineExpose({
  // 获取表格实例
  getTableRef: () => tableRef.value,

  /**
   * TypeScript 对 Vue 组件实例类型推导的限制,只暴露明确需要的 API,而非整个组件实例,否则打包时会报错,
   * 也可通过修改tsconfig.json避免报错: "compilerOptions": { "verbatimModuleSyntax": false,  "isolatedModules": false }
   * ...tableRef.value ? tableRef.value : {},
   */
  // 其它需要暴露的API
})
</script>

完整使用示例

<template>
  <EnhancedTable
    :data="tableData"
    :columns="columns"
    :tableLoading="fetchLoading"
    :toolbarOptions="toolbarOptions"
    :border="true"
    :show-selection="true"
    :show-index="true"
    :index-config="indexConfig"
    :scrollbar-always-on="true"
    :paginationAttrs="paginationAttrs"
    v-model:pageInfo="pagination"
    @updatePageInfo="fetchData"
    @selection-change="handleSelectionChange"
    @sort-change="handleSortChange"
  >
    <!-- slot使用示例:自定义状态列 -->
    <template #status="{ row }">
      <el-tag :type="row.status === 'active' ? 'success' : 'danger'">
        {{ row.status }}
      </el-tag>
    </template>

    <!-- slot使用示例:自定义操作列 -->
    <template #action="{ row }">
      <div class="flex items-center">
        <el-button type="primary" :icon="Edit" circle />
        <el-button type="danger" :icon="Delete" circle />
      </div>
    </template>

    
    <!-- 自定义表头 -->
    <template #toolbar>
        <div>
          <el-input 
            v-model="searchQuery" 
            placeholder="搜索..." 
            style="width: 200px;"
          />
          <el-button type="primary" @click="fetchData">Search</el-button>
        </div>

        <div class="flex items-center gap-2">
          <el-button type="primary">新增</el-button>
          <el-button type="primary">导出</el-button>
        </div>
    </template>
  </EnhancedTable>
</template>

<script setup lang="ts">
import { Edit, Delete } from '@element-plus/icons-vue'
import type { TableColumnCtx } from 'element-plus'
const EnhancedTable = defineAsyncComponent(() => import('@/components/basic/EnhancedTable/index.vue'))

const searchQuery = ref<string>('')

const indexConfig = ref({
  showIndex: true,
  fixed: 'left'
})
const toolbarOptions = {
  refresh: true,
  columnSetting: true
}

interface User {
  id: number
  name: string
  age: number
  [k: string]: any
  status: string
}
// 表格数据
const tableData = ref<User[]>([])

// 列配置
const columns = [
  { prop: 'name', label: '姓名', align: 'center',  minWidth: '120' },
  { prop: 'status', label: '状态', align: 'center', width: '100', 
    slot: true, 
    filters: [
      { text: 'Active', value: 'active' },
      { text: 'Inactive', value: 'inactive' }
    ],
    filterMethod: (value: string, row: any, column: TableColumnCtx<User>) =>{
      const property = column['property']
      return row[property] === value
    },
    filterPlacement: 'bottom-end'
  },
  { prop: 'age', label: '年龄', align: 'center', width: '100', sortable: true },
  { prop: 'age1', label: '年龄1', align: 'center', width: '200', sortable: true },
  { prop: 'age2', label: '年龄2', align: 'center', width: '200', sortable: true },
  { prop: 'age3', label: '年龄3', align: 'center', width: '200', sortable: true },
  { prop: 'age4', label: '年龄4', align: 'center', width: '200', sortable: true },
  { prop: 'age5', label: '年龄5', align: 'center', width: '200', sortable: true },
  { prop: 'age6', label: '年龄6', align: 'center', width: '200', sortable: true },
  { prop: 'age61', label: '年龄61', align: 'center', width: '200', sortable: true },
  { prop: 'age62', label: '年龄62', align: 'center', width: '200', sortable: true },
  { prop: 'age63', label: '年龄63', align: 'center', width: '200', sortable: true },
  { prop: 'age64', label: '年龄64', align: 'center', width: '200', sortable: true },
  { prop: 'age65', label: '年龄65', align: 'center', width: '200', sortable: true },
  // 操作列
  { prop: 'action', label: '操作', align: 'center', width: '120', slot: true, fixed: 'right' }
]

const fetchLoading = ref<boolean>(true)
// 分页配置
const paginationAttrs = {
  background: true,
  hideOnSinglePage: false
}
const pagination = ref({
  currentPage: 1,
  pageSize: 10,
  total: 0
})

// 获取数据
const fetchData = () => {
  fetchLoading.value = true
  // 模拟API调用
  console.log('刷新数据...', pagination.value)

  setTimeout(() => {
    fetchLoading.value = false
    pagination.value.total = 100
    tableData.value = [
      { id: 1, name: '张三', age: 25, age3: 25, age6: 25, age62: 25, status: 'active' },
      { id: 2, name: '李四', age: 30, status: 'inactive' },
      { id: 3, name: '王五', age: 28, status: 'active' }
    ]
  }, 1000)
}


const handleSelectionChange = (selection: any) => {
  console.log('选中行:', selection)
}
// 处理排序变化
const handleSortChange = (sort: any) => {
  console.log('排序变化:', sort)
}


onMounted(() => {
  fetchData()
})
</script>
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐