Vue3 组件封装实战 | 从 0 封装一个可复用的表格组件(附插槽 / Props 设计)

29 阅读8分钟

一、为什么要封装组件?

在企业级项目中,表格是最常见的 UI 形态之一。几乎每个后台管理系统都有大量的表格页面:用户列表、订单管理、商品管理...如果每个页面都重复写表格逻辑,不仅代码冗余,维护成本也极高。

封装表格组件的价值:

  • 提升开发效率:一次封装,多处使用
  • 统一交互体验:分页、排序、筛选行为一致
  • 降低维护成本:修改逻辑只需改一处
  • 代码复用:避免重复造轮子

二、组件设计思路

2.1 需求分析

一个成熟的表格组件应该具备哪些能力?

// 核心功能需求
1. 数据展示:支持列表数据渲染
2. 列配置:自定义列标题、字段、宽度、对齐方式
3. 分页:支持分页器,可配置每页条数
4. 排序:支持单列排序、多列排序
5. 筛选:支持表头筛选
6. 操作列:编辑、删除等操作按钮
7. 自定义内容:插槽支持个性化渲染
8. 加载状态:显示加载中效果
9. 空状态:无数据时显示占位
10. 选择功能:支持行选择(单选/多选)
11. 展开行:支持展开查看更多信息
12. 固定列:左侧/右侧固定列

2.2 组件设计原则

// 1. 单一职责原则
// 表格组件只负责表格渲染,不关心数据获取

// 2. 可配置原则
// 通过 props 提供灵活的配置选项

// 3. 可扩展原则
// 通过插槽支持自定义内容

// 4. 类型安全
// 使用 TypeScript 定义 Props 和事件

三、基础版本实现

3.1 项目初始化

# 创建项目
npm create vite@latest vue3-table-demo -- --template vue-ts

# 安装依赖
npm install element-plus @element-plus/icons-vue

# 启动项目
cd vue3-table-demo
npm run dev

3.2 基础表格组件

<!-- components/BaseTable.vue -->
<template>
  <div class="base-table">
    <!-- 表格主体 -->
    <el-table
      v-loading="loading"
      :data="data"
      :border="border"
      :stripe="stripe"
      :size="size"
      :empty-text="emptyText"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
    >
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        width="55"
        fixed="left"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        width="55"
        label="序号"
        fixed="left"
      />
      
      <!-- 动态渲染列 -->
      <template v-for="column in columns" :key="column.prop">
        <!-- 有自定义插槽的列 -->
        <el-table-column
          v-if="column.slot"
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
        >
          <template #default="{ row, $index }">
            <slot 
              :name="column.slot" 
              :row="row" 
              :index="$index"
              :prop="column.prop"
            >
              {{ row[column.prop] }}
            </slot>
          </template>
        </el-table-column>
        
        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :formatter="column.formatter"
          :show-overflow-tooltip="column.showTooltip"
        />
      </template>
      
      <!-- 操作列(预留插槽) -->
      <el-table-column
        v-if="$slots.action"
        label="操作"
        :width="actionWidth"
        :fixed="actionFixed"
        align="center"
      >
        <template #default="{ row, $index }">
          <slot name="action" :row="row" :index="$index" />
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页器 -->
    <div v-if="showPagination" class="table-pagination">
      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :page-sizes="pageSizes"
        :total="total"
        :layout="paginationLayout"
        background
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

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

// TypeScript 接口定义
export interface TableColumn {
  prop: string                // 字段名
  label: string               // 列标题
  width?: number | string     // 宽度
  align?: 'left' | 'center' | 'right'  // 对齐方式
  fixed?: boolean | 'left' | 'right'   // 固定列
  sortable?: boolean          // 是否可排序
  slot?: string               // 插槽名称
  formatter?: (row: any, column: any, cellValue: any, index: number) => any  // 格式化函数
  showTooltip?: boolean       // 超出是否显示tooltip
}

// Props 定义
const props = defineProps({
  // 表格数据
  data: {
    type: Array as PropType<any[]>,
    required: true,
    default: () => []
  },
  
  // 列配置
  columns: {
    type: Array as PropType<TableColumn[]>,
    required: true,
    default: () => []
  },
  
  // 总条数(用于分页)
  total: {
    type: Number,
    default: 0
  },
  
  // 是否显示分页
  showPagination: {
    type: Boolean,
    default: true
  },
  
  // 当前页码
  page: {
    type: Number,
    default: 1
  },
  
  // 每页条数
  limit: {
    type: Number,
    default: 20
  },
  
  // 每页条数选项
  pageSizes: {
    type: Array as PropType<number[]>,
    default: () => [10, 20, 50, 100]
  },
  
  // 分页布局
  paginationLayout: {
    type: String,
    default: 'total, sizes, prev, pager, next, jumper'
  },
  
  // 是否显示选择列
  showSelection: {
    type: Boolean,
    default: false
  },
  
  // 是否显示序号列
  showIndex: {
    type: Boolean,
    default: false
  },
  
  // 是否显示边框
  border: {
    type: Boolean,
    default: true
  },
  
  // 是否显示斑马纹
  stripe: {
    type: Boolean,
    default: true
  },
  
  // 表格尺寸
  size: {
    type: String as PropType<'large' | 'default' | 'small'>,
    default: 'default'
  },
  
  // 加载状态
  loading: {
    type: Boolean,
    default: false
  },
  
  // 空数据提示
  emptyText: {
    type: String,
    default: '暂无数据'
  },
  
  // 操作列宽度
  actionWidth: {
    type: [Number, String],
    default: 150
  },
  
  // 操作列是否固定
  actionFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'right'
  }
})

// 事件定义
const emit = defineEmits([
  'update:page',
  'update:limit',
  'selection-change',
  'sort-change',
  'row-click',
  'page-change'
])

// 内部状态
const currentPage = ref(props.page)
const pageSize = ref(props.limit)

// 监听外部变化
watch(() => props.page, (val) => {
  currentPage.value = val
})

watch(() => props.limit, (val) => {
  pageSize.value = val
})

// 分页变化处理
const handleSizeChange = (size: number) => {
  pageSize.value = size
  emit('update:limit', size)
  emit('page-change', { page: currentPage.value, limit: size })
}

const handleCurrentChange = (page: number) => {
  currentPage.value = page
  emit('update:page', page)
  emit('page-change', { page, limit: pageSize.value })
}

// 选择变化处理
const handleSelectionChange = (selection: any[]) => {
  emit('selection-change', selection)
}

// 排序变化处理
const handleSortChange = ({ prop, order, column }: any) => {
  emit('sort-change', { prop, order, column })
}

// 行点击处理
const handleRowClick = (row: any, column: any, event: Event) => {
  emit('row-click', { row, column, event })
}

// 暴露方法给父组件
defineExpose({
  // 清除选择
  clearSelection: () => {
    // 通过 ref 调用 el-table 的方法
  },
  
  // 切换某行的选择状态
  toggleRowSelection: (row: any, selected?: boolean) => {
    // 实现...
  }
})
</script>

<style scoped lang="scss">
.base-table {
  width: 100%;
  
  .table-pagination {
    margin-top: 20px;
    display: flex;
    justify-content: flex-end;
  }
}
</style>

四、增强版封装(企业级)

4.1 高级表格组件

<!-- components/ProTable.vue -->
<template>
  <div class="pro-table">
    <!-- 工具栏 -->
    <div v-if="showToolbar" class="table-toolbar">
      <div class="toolbar-left">
        <slot name="toolbar-left">
          <span class="table-title">{{ title }}</span>
        </slot>
      </div>
      
      <div class="toolbar-right">
        <slot name="toolbar-right">
          <!-- 刷新按钮 -->
          <el-button 
            v-if="showRefresh" 
            :icon="Refresh" 
            circle 
            @click="handleRefresh"
          />
          
          <!-- 密度切换 -->
          <el-dropdown v-if="showDensity" @command="handleDensityChange">
            <el-button :icon="Grid" circle />
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item command="large">宽松</el-dropdown-item>
                <el-dropdown-item command="default">默认</el-dropdown-item>
                <el-dropdown-item command="small">紧凑</el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
          
          <!-- 列设置 -->
          <el-popover
            v-if="showColumnSetting"
            placement="bottom-end"
            :width="200"
            trigger="click"
          >
            <template #reference>
              <el-button :icon="Setting" circle />
            </template>
            
            <div class="column-setting">
              <div class="setting-header">
                <span>列展示</span>
                <el-checkbox 
                  v-model="checkAll" 
                  :indeterminate="isIndeterminate"
                  @change="handleCheckAllChange"
                >
                  全选
                </el-checkbox>
              </div>
              <el-divider />
              <el-checkbox-group v-model="checkedColumns" @change="handleCheckedChange">
                <div v-for="col in allColumns" :key="col.prop" class="setting-item">
                  <el-checkbox :label="col.prop">
                    {{ col.label }}
                  </el-checkbox>
                  <el-icon class="drag-icon"><Rank /></el-icon>
                </div>
              </el-checkbox-group>
            </div>
          </el-popover>
        </slot>
      </div>
    </div>
    
    <!-- 表格主体 -->
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="filteredData"
      :border="border"
      :stripe="stripe"
      :size="tableSize"
      :empty-text="emptyText"
      :row-key="rowKey"
      :expand-row-keys="expandRowKeys"
      :default-sort="defaultSort"
      :span-method="spanMethod"
      :row-class-name="rowClassName"
      :cell-class-name="cellClassName"
      :header-row-class-name="headerRowClassName"
      :header-cell-class-name="headerCellClassName"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
      @row-dblclick="handleRowDblClick"
      @expand-change="handleExpandChange"
    >
      <!-- 展开行 -->
      <el-table-column
        v-if="showExpand"
        type="expand"
        width="50"
      >
        <template #default="{ row }">
          <slot name="expand" :row="row" />
        </template>
      </el-table-column>
      
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        :width="selectionWidth"
        :fixed="selectionFixed"
        :selectable="selectable"
        :reserve-selection="reserveSelection"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        :width="indexWidth"
        :label="indexLabel"
        :fixed="indexFixed"
        :index="indexMethod"
      />
      
      <!-- 动态渲染列(支持拖拽排序) -->
      <template v-for="column in visibleColumns" :key="column.prop">
        <!-- 有自定义插槽的列 -->
        <el-table-column
          v-if="column.slot"
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :sort-method="column.sortMethod"
          :sort-by="column.sortBy"
          :sort-orders="column.sortOrders"
          :resizable="column.resizable !== false"
          :show-overflow-tooltip="column.showTooltip"
        >
          <template #default="{ row, $index }">
            <slot 
              :name="column.slot" 
              :row="row" 
              :index="$index"
              :prop="column.prop"
              :column="column"
            >
              {{ formatCellValue(row, column) }}
            </slot>
          </template>
          
          <template #header="{ column: col, $index }">
            <slot 
              :name="`header-${column.prop}`" 
              :column="col" 
              :index="$index"
              :prop="column.prop"
            >
              {{ column.label }}
            </slot>
          </template>
        </el-table-column>
        
        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :sort-method="column.sortMethod"
          :sort-by="column.sortBy"
          :sort-orders="column.sortOrders"
          :resizable="column.resizable !== false"
          :formatter="column.formatter"
          :show-overflow-tooltip="column.showTooltip"
        >
          <template #default="{ row, column: col, $index }">
            {{ formatCellValue(row, column) }}
          </template>
        </el-table-column>
      </template>
      
      <!-- 操作列 -->
      <el-table-column
        v-if="hasAction"
        :label="actionLabel"
        :width="actionWidth"
        :min-width="actionMinWidth"
        :fixed="actionFixed"
        :align="actionAlign"
      >
        <template #default="{ row, $index }">
          <slot 
            name="action" 
            :row="row" 
            :index="$index"
          />
        </template>
      </el-table-column>
      
      <!-- 自定义列插槽 -->
      <slot name="append" />
    </el-table>
    
    <!-- 底部区域 -->
    <div class="table-footer">
      <!-- 左侧统计信息 -->
      <div v-if="showSummary" class="footer-left">
        <slot name="summary">
          <span>共 {{ total }} 条记录</span>
          <span v-if="showSelection && selectedRows.length">
            已选择 {{ selectedRows.length }} 条
          </span>
        </slot>
      </div>
      
      <!-- 右侧分页器 -->
      <div v-if="showPagination" class="footer-right">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :page-sizes="pageSizes"
          :total="total"
          :layout="paginationLayout"
          :background="paginationBackground"
          :disabled="paginationDisabled"
          :hide-on-single-page="hideOnSinglePage"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { Refresh, Grid, Setting, Rank } from '@element-plus/icons-vue'
import type { PropType } from 'vue'
import type { TableColumn } from './BaseTable'
import Sortable from 'sortablejs'

// Props 定义(继承 BaseTable 的 props 并扩展)
const props = defineProps({
  // ... 继承 BaseTable 的所有 props
  
  // 表格标题
  title: {
    type: String,
    default: ''
  },
  
  // 是否显示工具栏
  showToolbar: {
    type: Boolean,
    default: true
  },
  
  // 是否显示刷新按钮
  showRefresh: {
    type: Boolean,
    default: true
  },
  
  // 是否显示密度切换
  showDensity: {
    type: Boolean,
    default: true
  },
  
  // 是否显示列设置
  showColumnSetting: {
    type: Boolean,
    default: true
  },
  
  // 行唯一标识
  rowKey: {
    type: String,
    default: 'id'
  },
  
  // 是否显示展开行
  showExpand: {
    type: Boolean,
    default: false
  },
  
  // 展开行的 keys
  expandRowKeys: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  
  // 默认排序
  defaultSort: {
    type: Object as PropType<{ prop: string; order: 'ascending' | 'descending' }>,
    default: null
  },
  
  // 合并单元格的方法
  spanMethod: {
    type: Function as PropType<({
      row,
      column,
      rowIndex,
      columnIndex
    }: {
      row: any
      column: any
      rowIndex: number
      columnIndex: number
    }) => number[] | { rowspan: number; colspan: number }>,
    default: null
  },
  
  // 是否显示汇总信息
  showSummary: {
    type: Boolean,
    default: true
  },
  
  // 选择列宽度
  selectionWidth: {
    type: [Number, String],
    default: 55
  },
  
  // 选择列是否固定
  selectionFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'left'
  },
  
  // 行是否可选
  selectable: {
    type: Function as PropType<(row: any, index: number) => boolean>,
    default: null
  },
  
  // 是否保留选择(数据更新后)
  reserveSelection: {
    type: Boolean,
    default: false
  },
  
  // 序号列宽度
  indexWidth: {
    type: [Number, String],
    default: 60
  },
  
  // 序号列标签
  indexLabel: {
    type: String,
    default: '序号'
  },
  
  // 序号列是否固定
  indexFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'left'
  },
  
  // 序号生成方法
  indexMethod: {
    type: Function as PropType<(index: number) => number>,
    default: (index: number) => index + 1
  },
  
  // 操作列标签
  actionLabel: {
    type: String,
    default: '操作'
  },
  
  // 操作列最小宽度
  actionMinWidth: {
    type: [Number, String],
    default: 120
  },
  
  // 操作列对齐方式
  actionAlign: {
    type: String as PropType<'left' | 'center' | 'right'>,
    default: 'center'
  },
  
  // 分页器背景
  paginationBackground: {
    type: Boolean,
    default: true
  },
  
  // 分页器禁用
  paginationDisabled: {
    type: Boolean,
    default: false
  },
  
  // 只有一页时是否隐藏分页器
  hideOnSinglePage: {
    type: Boolean,
    default: false
  },
  
  // 行类名
  rowClassName: {
    type: [String, Function] as PropType<string | (({ row, rowIndex }: { row: any; rowIndex: number }) => string)>,
    default: ''
  },
  
  // 单元格类名
  cellClassName: {
    type: [String, Function] as PropType<string | (({ row, column, rowIndex, columnIndex }: any) => string)>,
    default: ''
  }
})

// 事件定义
const emit = defineEmits([
  // ... 继承 BaseTable 的事件
  'refresh',
  'density-change',
  'column-change',
  'row-dblclick',
  'expand-change'
])

// 表格引用
const tableRef = ref()

// 内部状态
const tableSize = ref<'large' | 'default' | 'small'>(props.size as any)
const selectedRows = ref<any[]>([])
const checkedColumns = ref<string[]>([])
const allColumns = ref<TableColumn[]>([])

// 计算属性:是否有操作列
const hasAction = computed(() => !!props.$slots.action)

// 计算属性:可见列
const visibleColumns = computed(() => {
  if (!checkedColumns.value.length) return allColumns.value
  return allColumns.value.filter(col => checkedColumns.value.includes(col.prop))
})

// 计算属性:过滤后的数据(可用于前端搜索)
const filteredData = computed(() => {
  // 实现前端筛选逻辑
  return props.data
})

// 初始化列配置
onMounted(() => {
  allColumns.value = props.columns.filter(col => !col.hidden)
  checkedColumns.value = allColumns.value.map(col => col.prop)
  initDrag()
})

// 初始化拖拽排序
const initDrag = () => {
  nextTick(() => {
    const settingEl = document.querySelector('.column-setting .el-checkbox-group')
    if (!settingEl) return
    
    new Sortable(settingEl as HTMLElement, {
      animation: 150,
      handle: '.drag-icon',
      onEnd: (evt) => {
        const { oldIndex, newIndex } = evt
        if (oldIndex === newIndex) return
        
        // 重新排序列
        const newColumns = [...allColumns.value]
        const [movedColumn] = newColumns.splice(oldIndex!, 1)
        newColumns.splice(newIndex!, 0, movedColumn)
        allColumns.value = newColumns
        
        emit('column-change', newColumns)
      }
    })
  })
}

// 格式化单元格值
const formatCellValue = (row: any, column: TableColumn) => {
  if (column.formatter) {
    return column.formatter(row, column, row[column.prop], 0)
  }
  return row[column.prop]
}

// 列设置相关
const checkAll = computed({
  get: () => checkedColumns.value.length === allColumns.value.length,
  set: (val) => {
    checkedColumns.value = val ? allColumns.value.map(col => col.prop) : []
  }
})

const isIndeterminate = computed(() => {
  return checkedColumns.value.length > 0 && 
         checkedColumns.value.length < allColumns.value.length
})

const handleCheckAllChange = (val: boolean) => {
  checkedColumns.value = val ? allColumns.value.map(col => col.prop) : []
  emit('column-change', visibleColumns.value)
}

const handleCheckedChange = (value: string[]) => {
  emit('column-change', visibleColumns.value)
}

// 密度切换
const handleDensityChange = (size: string) => {
  tableSize.value = size as any
  emit('density-change', size)
}

// 刷新
const handleRefresh = () => {
  emit('refresh')
}

// 双击行
const handleRowDblClick = (row: any, column: any) => {
  emit('row-dblclick', { row, column })
}

// 展开行变化
const handleExpandChange = (row: any, expandedRows: any[]) => {
  emit('expand-change', { row, expandedRows })
}

// 暴露方法
defineExpose({
  // 清除选择
  clearSelection: () => {
    tableRef.value?.clearSelection()
    selectedRows.value = []
  },
  
  // 切换行选择
  toggleRowSelection: (row: any, selected?: boolean) => {
    tableRef.value?.toggleRowSelection(row, selected)
  },
  
  // 切换所有行选择
  toggleAllSelection: () => {
    tableRef.value?.toggleAllSelection()
  },
  
  // 设置某行展开状态
  toggleRowExpansion: (row: any, expanded?: boolean) => {
    tableRef.value?.toggleRowExpansion(row, expanded)
  },
  
  // 设置当前行
  setCurrentRow: (row: any) => {
    tableRef.value?.setCurrentRow(row)
  },
  
  // 清除排序
  clearSort: () => {
    tableRef.value?.clearSort()
  },
  
  // 清除筛选
  clearFilter: (columnKeys?: string[]) => {
    tableRef.value?.clearFilter(columnKeys)
  },
  
  // 重新布局
  doLayout: () => {
    tableRef.value?.doLayout()
  },
  
  // 滚动到某行
  scrollToRow: (row: any, offset?: number) => {
    // 实现滚动逻辑
  }
})
</script>

<style scoped lang="scss">
.pro-table {
  background-color: #fff;
  border-radius: 4px;
  padding: 16px;
  
  .table-toolbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    
    .toolbar-left {
      .table-title {
        font-size: 16px;
        font-weight: 600;
        color: #303133;
      }
    }
    
    .toolbar-right {
      display: flex;
      gap: 8px;
    }
  }
  
  .table-footer {
    margin-top: 16px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    
    .footer-left {
      color: #909399;
      font-size: 14px;
      
      span {
        margin-right: 16px;
      }
    }
  }
  
  .column-setting {
    padding: 8px;
    
    .setting-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 8px;
    }
    
    .setting-item {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 4px 0;
      
      &:hover {
        background-color: #f5f7fa;
      }
      
      .drag-icon {
        cursor: move;
        color: #909399;
      }
    }
  }
}
</style>

五、使用示例

5.1 基础用法

<!-- views/UserList.vue -->
<template>
  <div class="user-list">
    <pro-table
      ref="tableRef"
      :data="userList"
      :columns="columns"
      :total="total"
      :loading="loading"
      :show-selection="true"
      :show-index="true"
      :page="page"
      :limit="limit"
      @page-change="handlePageChange"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @refresh="handleRefresh"
    >
      <!-- 自定义状态列 -->
      <template #status="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'info'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
      
      <!-- 自定义操作列 -->
      <template #action="{ row }">
        <el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
        <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
      </template>
    </pro-table>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ProTable from '@/components/ProTable.vue'
import type { TableColumn } from '@/components/BaseTable'
import { getUserList } from '@/api/user'

// 表格列配置
const columns: TableColumn[] = [
  {
    prop: 'name',
    label: '姓名',
    width: 120,
    sortable: true
  },
  {
    prop: 'age',
    label: '年龄',
    width: 80,
    align: 'center'
  },
  {
    prop: 'email',
    label: '邮箱',
    minWidth: 200,
    showTooltip: true
  },
  {
    prop: 'phone',
    label: '手机号',
    width: 150
  },
  {
    prop: 'status',
    label: '状态',
    width: 80,
    slot: 'status'  // 使用自定义插槽
  },
  {
    prop: 'createTime',
    label: '创建时间',
    width: 180,
    sortable: true,
    formatter: (row: any, column: any, value: string) => {
      return new Date(value).toLocaleString()
    }
  }
]

// 表格数据
const userList = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const limit = ref(20)

// 获取数据
const fetchData = async () => {
  loading.value = true
  try {
    const res = await getUserList({
      page: page.value,
      limit: limit.value
    })
    userList.value = res.list
    total.value = res.total
  } finally {
    loading.value = false
  }
}

// 分页变化
const handlePageChange = ({ page: newPage, limit: newLimit }: any) => {
  page.value = newPage
  limit.value = newLimit
  fetchData()
}

// 选择变化
const handleSelectionChange = (selection: any[]) => {
  console.log('选中:', selection)
}

// 排序变化
const handleSortChange = ({ prop, order }: any) => {
  console.log('排序:', prop, order)
  // 可以在这里处理排序逻辑
}

// 刷新
const handleRefresh = () => {
  fetchData()
}

// 编辑
const handleEdit = (row: any) => {
  console.log('编辑:', row)
}

// 删除
const handleDelete = (row: any) => {
  ElMessageBox.confirm('确认删除该用户吗?', '提示', {
    type: 'warning'
  }).then(() => {
    // 调用删除接口
    ElMessage.success('删除成功')
    fetchData()
  })
}

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

5.2 高级用法:动态列 + 展开行

<!-- views/OrderList.vue -->
<template>
  <pro-table
    :data="orderList"
    :columns="dynamicColumns"
    :total="total"
    :show-expand="true"
    :show-summary="true"
    :span-method="objectSpanMethod"
  >
    <!-- 展开行内容 -->
    <template #expand="{ row }">
      <div class="order-detail">
        <h4>订单详情</h4>
        <el-descriptions :column="3" border>
          <el-descriptions-item label="商品名称">{{ row.productName }}</el-descriptions-item>
          <el-descriptions-item label="单价">¥{{ row.price }}</el-descriptions-item>
          <el-descriptions-item label="数量">{{ row.quantity }}</el-descriptions-item>
          <el-descriptions-item label="总价">¥{{ row.totalPrice }}</el-descriptions-item>
          <el-descriptions-item label="下单时间">{{ row.orderTime }}</el-descriptions-item>
          <el-descriptions-item label="支付方式">{{ row.payMethod }}</el-descriptions-item>
        </el-descriptions>
      </div>
    </template>
    
    <!-- 自定义操作列 -->
    <template #action="{ row }">
      <el-button type="primary" link @click="viewOrder(row)">查看</el-button>
      <el-button type="success" link @click="processOrder(row)">处理</el-button>
    </template>
  </pro-table>
</template>

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

// 动态列配置(可以根据权限动态生成)
const columnsConfig = ref([
  { prop: 'orderNo', label: '订单号', width: 180, fixed: 'left' },
  { prop: 'customer', label: '客户', width: 120 },
  { prop: 'amount', label: '金额', width: 120, align: 'right' },
  { prop: 'status', label: '状态', width: 100 },
  { prop: 'payStatus', label: '支付状态', width: 100 },
  { prop: 'deliveryStatus', label: '发货状态', width: 100 },
  { prop: 'createTime', label: '创建时间', width: 180 },
  { prop: 'updateTime', label: '更新时间', width: 180 }
])

// 根据用户权限过滤列
const dynamicColumns = computed(() => {
  const userPermissions = ['orderNo', 'customer', 'amount', 'status']
  return columnsConfig.value.filter(col => userPermissions.includes(col.prop))
})

// 合并单元格
const objectSpanMethod = ({ row, column, rowIndex, columnIndex }: any) => {
  if (columnIndex === 0) {
    if (rowIndex % 2 === 0) {
      return {
        rowspan: 2,
        colspan: 1
      }
    } else {
      return {
        rowspan: 0,
        colspan: 0
      }
    }
  }
}
</script>

六、单元测试

// __tests__/ProTable.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ProTable from '@/components/ProTable.vue'

describe('ProTable.vue', () => {
  const mockColumns = [
    { prop: 'name', label: '姓名' },
    { prop: 'age', label: '年龄' }
  ]
  
  const mockData = [
    { name: '张三', age: 25 },
    { name: '李四', age: 30 }
  ]
  
  it('renders table correctly', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 2
      }
    })
    
    expect(wrapper.find('.pro-table').exists()).toBe(true)
    expect(wrapper.findAll('.el-table__row').length).toBe(2)
  })
  
  it('emits page-change event when pagination changes', async () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 100,
        showPagination: true
      }
    })
    
    // 模拟分页变化
    await wrapper.find('.el-pagination .btn-next').trigger('click')
    
    expect(wrapper.emitted('page-change')).toBeTruthy()
    expect(wrapper.emitted('page-change')?.[0]).toEqual([{ page: 2, limit: 20 }])
  })
  
  it('shows loading state', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: [],
        columns: mockColumns,
        loading: true
      }
    })
    
    expect(wrapper.find('.el-loading-mask').exists()).toBe(true)
  })
  
  it('renders custom slot content', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: [
          { prop: 'name', label: '姓名', slot: 'customName' }
        ]
      },
      slots: {
        customName: '<span class="custom-name">{{ row.name }}</span>'
      }
    })
    
    expect(wrapper.find('.custom-name').exists()).toBe(true)
  })
})

七、性能优化

7.1 虚拟滚动(大数据量)

<!-- 对于大量数据,可以使用虚拟滚动 -->
<template>
  <el-table
    v-loading="loading"
    :data="visibleData"
    :height="tableHeight"
    style="width: 100%"
    @scroll="handleScroll"
  >
    <!-- 列配置 -->
  </el-table>
</template>

<script setup>
import { ref, computed } from 'vue'

const props = defineProps({
  data: {
    type: Array,
    default: () => []
  },
  rowHeight: {
    type: Number,
    default: 48
  },
  bufferSize: {
    type: Number,
    default: 10
  }
})

const scrollTop = ref(0)
const tableHeight = ref(600)

// 计算可见范围
const visibleCount = computed(() => Math.ceil(tableHeight.value / props.rowHeight))

const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.bufferSize)
})

const endIndex = computed(() => {
  return Math.min(
    props.data.length,
    startIndex.value + visibleCount.value + props.bufferSize * 2
  )
})

const visibleData = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop
}
</script>

7.2 大数据量优化策略

// 1. 使用虚拟滚动
// 2. 按需渲染
// 3. 使用函数式组件
// 4. 避免不必要的响应式
// 5. 使用 computed 缓存计算结果
// 6. 列表项使用唯一的 key
// 7. 使用 v-once 处理静态内容

八、总结与最佳实践

8.1 组件设计要点

  1. Props 设计原则

    • 提供合理的默认值
    • 使用 TypeScript 类型定义
    • 保持 API 简洁但够用
  2. 插槽设计原则

    • 提供足够的自定义能力
    • 作用域插槽传递必要数据
    • 预留扩展位置
  3. 事件设计原则

    • 遵循 v-model 规范
    • 提供完整的事件体系
    • 事件命名清晰规范

8.2 使用建议

// 1. 合理配置列宽度
const columns = [
  { prop: 'name', label: '姓名', width: 120 }, // 固定宽度
  { prop: 'address', label: '地址', minWidth: 200 }, // 最小宽度
  { prop: 'description', label: '描述', width: 'auto' } // 自适应
]

// 2. 使用唯一 rowKey
<pro-table :data="list" row-key="id" />

// 3. 合理使用插槽
<template #status="{ row }">
  <Badge :status="row.status" />
</template>

// 4. 处理加载状态
<pro-table :loading="loading" :data="list" />

// 5. 处理空状态
<pro-table :data="[]" empty-text="暂无数据" />

8.3 扩展思考

  1. 如何支持表格导出?

    • 添加导出按钮和导出方法
    • 支持导出当前页或全部数据
    • 支持导出格式配置(CSV/Excel)
  2. 如何支持表格打印?

    • 添加打印样式
    • 隐藏操作列和按钮
    • 调整列宽适配打印
  3. 如何支持表格列拖动调整宽度?

    • 使用 resizable 属性
    • 保存用户调整后的宽度到 localStorage
  4. 如何支持表格状态持久化?

    • 保存列显示状态
    • 保存排序状态
    • 保存筛选状态

通过合理封装表格组件,可以极大提升开发效率,保证项目代码质量,这也是企业级前端开发的核心能力之一。