一、为什么要封装组件?
在企业级项目中,表格是最常见的 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 组件设计要点
-
Props 设计原则
- 提供合理的默认值
- 使用 TypeScript 类型定义
- 保持 API 简洁但够用
-
插槽设计原则
- 提供足够的自定义能力
- 作用域插槽传递必要数据
- 预留扩展位置
-
事件设计原则
- 遵循 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 扩展思考
-
如何支持表格导出?
- 添加导出按钮和导出方法
- 支持导出当前页或全部数据
- 支持导出格式配置(CSV/Excel)
-
如何支持表格打印?
- 添加打印样式
- 隐藏操作列和按钮
- 调整列宽适配打印
-
如何支持表格列拖动调整宽度?
- 使用 resizable 属性
- 保存用户调整后的宽度到 localStorage
-
如何支持表格状态持久化?
- 保存列显示状态
- 保存排序状态
- 保存筛选状态
通过合理封装表格组件,可以极大提升开发效率,保证项目代码质量,这也是企业级前端开发的核心能力之一。