前言
对于面向企业的项目而言,前端开发中最常见的场景之一便是后台管理系统,而这类系统的核心页面多为基于表格表单的 CURD(增删改查)操作页面。随着同类页面开发量的增加,封装一套通用的 CURD 功能组件变得尤为必要——这不仅能显著提升开发效率,避免重复性工作,更能为后续的统一修改与维护提供便利。
一个标准的 CURD 页面通常包含以下核心功能模块:搜索条、操作工具条、数据表格、新增 / 编辑弹窗、详情弹窗等。
本方案将采用 “分而治之” 的策略:将每个功能模块封装为独立的基础组件,再通过组件组合的方式快速搭建完整的 CURD 页面。
在之前的文章中已经介绍了搜索条功能模块组件的封装,参见文章:
本篇文章将继续介绍数据表格功能模块的封装实现细节。欢迎大家持续关注,并提出宝贵的建议与反馈。
数据表格
数据表格主要由表格主体和分页器两部分组成,二者配合实现结构化数据的高效展示与交互。
表格主体采用 JSON 配置化设计,与搜索条组件逻辑一致:通过配置项数组动态渲染表格列,无需重复编写列标签,降低冗余且便于修改。
若表格列需特殊展示(如标签、图片、操作按钮),可通过配置slot插槽实现,即可自定义单元格内容。
实现
template模板
1.动态列渲染:用配置驱动视图
表格最核心的功能是列渲染,传统写法中需要手动编写多个<el-table-column>,而封装的关键就在于用配置数组动态生成列。模板中通过v-for="item in columns"遍历配置数组,将每个配置项映射为<el-table-column>。
<template v-for="item in columns" :key="item.prop">
<el-table-column
:type="item.type"
:prop="item.prop"
:label="item.label"
:width="item.width"
:min-width="item.minWidth"
:align="item.align"
:sortable="item.sortable"
:class-name="item.className"
:formatter="item.formatter"
/>
</template>
这里的设计思路是保留原生表格列的核心属性,同时通过配置项传递。比如:
prop和label对应数据字段和列标题,是表格的基础配置width/minWidth控制列宽,align设置对齐方式(left/center/right)sortable支持排序功能formatter可直接配置单元格格式化函数
这种设计既降低了学习成本(和原生表格属性一致),又实现了 "一处配置,多处复用"。
2.特殊列的内置支持
除了动态列,业务中常需要复选框列(selection)和序号列(index),模板通过条件渲染实现:
<el-table-column v-if="showSelection" type="selection" width="40" />
<el-table-column v-if="showIndex" type="index" label="序号" width="60" />
通过showSelection和showIndex两个布尔值参数,调用者可以一键开启 / 关闭这些特殊列,无需重复编写代码。
3.通过插槽自定义单元格
固定的配置项无法满足所有场景,比如需要在单元格中显示按钮组、图片或自定义组件。这时插槽(slot) 就成了关键的扩展点。
- 单元格内容插槽
模板中为每个列提供了内容插槽,通过配置项的slot字段启用:
<template #default="{ row }">
<!-- 列内容插槽 -->
<slot v-if="item.slot" :name="item.slot" :row="row"></slot>
<span v-else>{{ row[item.prop] }}</span>
</template>
- 当配置
item.slot = 'operation'时,会渲染名为operation的插槽,并传入当前行数据row,可用于添加表格的操作列 - 未配置插槽时,默认显示
row[item.prop],即数据字段的原始值
<!-- 调用组件时 -->
<ts-table :columns="columns">
<template #operation="{ row }">
<el-button @click="edit(row)">编辑</el-button>
<el-button @click="delete(row)">删除</el-button>
</template>
</ts-table>
<script>
columns = [
// 其他列...
{ label: '操作', slot: 'operation' }
]
</script>
- 表头插槽
除了单元格内容,表头有时也需要自定义(比如添加表头说明),模板通过headerSlot字段支持表头插槽:
<template v-if="item.headerSlot" #header>
<slot :name="item.headerSlot"></slot>
</template>
4.原生属性透传
v-bind="$attrs"外部属性绑定在<el-table>标签上,调用者可以直接使用 Element Plus 表格的所有原生属性(如border、stripe等),无需在封装组件中重复声明这些属性,减少维护成本。
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :max-height="height">
例如调用时添加边框和斑马纹:
<ts-table border stripe :columns="columns" :data="tableData" />
5.动态高度适配
表格高度是一个常见的痛点。我们希望单应用页面不会出现滚动条,如果表格内容超出一屏,通过表格内形成滚动条向下浏览。
我们使用 Element Plus 的<el-auto-resizer>组件解决这个问题。el-auto-resizer会自动计算父容器的剩余空间高度,并通过插槽传递给表格。
<el-auto-resizer>
<template #default="{ height }">
<el-table :max-height="height" />
</template>
</el-auto-resizer>
6.分页器
分页是表格的常见需求,但并非所有场景都需要。模板将分页器设计为可选模块:
<div v-if="showPage" class="page-wrapper">
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:page-sizes="pageSizes"
:total="total"
:layout="pageLayout"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
- 通过
showPage参数控制是否显示分页器 - 暴露
currentPage、pageSize等分页相关参数 - 监听
size-change和current-change事件,将分页变化传递给父组件
完整代码
<template>
<div class="ts-table">
<div class="table-wrapper">
<el-auto-resizer>
<template #default="{ height }">
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :max-height="height">
<el-table-column v-if="showSelection" type="selection" width="40" />
<el-table-column v-if="showIndex" type="index" label="序号" width="60" />
<template v-for="item in columns" :key="item.prop">
<el-table-column
:type="item.type"
:prop="item.prop"
:label="item.label"
:width="item.width"
:min-width="item.minWidth"
:align="item.align"
:sortable="item.sortable"
:class-name="item.className"
:formatter="item.formatter"
>
<template #default="{ row }">
<!-- 列内容插槽-->
<slot v-if="item.slot" :name="item.slot" :row="row"></slot>
<span v-else>{{ row[item.prop] }}</span>
</template>
<!-- 列表头插槽-->
<template v-if="item.headerSlot" #header>
<slot :name="item.headerSlot"></slot>
</template>
</el-table-column>
</template>
</el-table>
</template>
</el-auto-resizer>
</div>
<!-- 分页器 -->
<div v-if="showPage" class="page-wrapper">
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:page-sizes="pageSizes"
:total="total"
:layout="pageLayout"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.page-base {
display: flex;
flex-direction: column;
height: 100%;
.tool-wrapper {
margin-bottom: 10px;
}
.table-wrapper {
flex: 1;
.el-link + .el-link {
margin-left: 10px;
}
}
}
</style>
script逻辑
组件对外暴露多组 props 配置项:
-
表格基础配置:必填项columns(列配置数组),可选showIndex(默认显序号列)、showSelection(默认隐复选列)、loading(加载态);
-
分页控制配置:showPage(默认显分页器)、total(数据总条数)、currentPage(当前页)、pageSize(每页条数)、pageSizes(可选条数)、pageLayout(布局)。
对外抛出两个事件:sizeChange(每页条数变化回调)、currentChange(页码变化回调),支持外部自定义数据加载逻辑。
对外暴露一个方法:getSelection(获取表格选中行数据),同时通过inheritAttrs: false配合模板实现属性精准透传。
<script setup>
const props = defineProps({
columns: {
type: Array,
required: true,
default: () => []
},
showIndex: {
type: Boolean,
default: true
},
showSelection: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
showPage: {
type: Boolean,
default: true
},
// 总条数
total: {
type: Number,
default: 0
},
// 当前页码
currentPage: {
type: Number,
default: 10
},
// 默认每页条数
pageSize: {
type: Number,
default: 10
},
// 可选的每页条数
pageSizes: {
type: Array,
default: () => [10, 20, 50, 100]
},
// 分页器布局
pageLayout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper'
}
});
const emit = defineEmits(['sizeChange', 'currentChange']);
defineOptions({
inheritAttrs: false
});
const tableRef = ref();
function handleSizeChange(val) {
emit('sizeChange', val);
}
function handleCurrentChange(val) {
emit('currentChange', val);
}
function getSelection() {
return tableRef.value.getSelectionRows();
}
defineExpose({
getSelection
});
</script>
调用
以下为组件的完整调用示例,包含基础配置项与插槽用法。
tableData为通过getData调用接口获取到的表格数据,tableColumns为json表格列配置项数组。
其中“通讯地址”列使用了内容插槽和表头插槽,并且添加了operation操作列插槽可以插入操作按钮。
<template>
<div class="table-wrapper">
<ts-table
ref="tsTableRef"
:loading="loading"
:data="tableData"
:columns="tableColumns"
stripe
:row-class-name="tableRowClassName"
show-overflow-tooltip
style="width: 100%"
show-selection
:span-method="arraySpanMethod"
:current-page="pageOption.pageNum"
:page-size="pageOption.pageSize"
:total="total"
@row-click="handleRowClick"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<!-- 展开列插槽 -->
<template #expand="{ row }">
{{ row }}
</template>
<!-- 表格列插槽 -->
<template #address="{ row }">
<span>slot: {{ row.address }}</span>
</template>
<!-- 自定义表头插槽 -->
<template #header-address>
<el-tooltip content="这里是自定义表头的说明" placement="top">
<div>自定义地址表头</div>
</el-tooltip>
</template>
<template #operation="{ row }">
<el-link type="primary" @click.stop="handleEdit(row)">编辑</el-link>
<el-link type="primary" @click.stop="handleDel(row)">删除</el-link>
<el-link type="primary" @click.stop="handleDetail(row)">详情</el-link>
</template>
</ts-table>
</div>
</template>
<script setup>
const tsTableRef = ref();
const tableData = ref([]);
const tableColumns = reactive([
{
type: 'expand',
slot: 'expand'
},
{
prop: 'name',
label: '姓名',
align: 'center'
},
{
prop: 'gender',
label: '性别',
width: 120
},
{
prop: 'age',
label: '年龄',
sortable: true,
width: 120
},
{
prop: 'date',
label: '出生日期',
fixed: true
},
{
prop: 'address',
label: '通讯地址',
slot: 'address',
headerSlot: 'header-address',
width: 600
},
{
label: '操作',
slot: 'operation'
}
]);
const pageOption = reactive({
pageNum: 1,
pageSize: 10
});
const total = ref(0);
const loading = ref(false);
watch(pageOption, getData);
const tableRowClassName = ({ rowIndex }) => {
if (rowIndex === 0) {
return 'warning-row';
} else if (rowIndex === 2) {
return 'success-row';
}
return '';
};
/**
* @description: 分页器每页数据条数切换
* @param {*} val
* @return {*}
*/
function handleSizeChange(val) {
pageOption.pageSize = val;
}
/**
* @description: 分页器当前页码切换
* @param {*} val
* @return {*}
*/
function handleCurrentChange(val) {
pageOption.pageNum = val;
}
/**
* @description: 获取表格数据
* @return {*}
*/
async function getData() {
try {
loading.value = true;
const data = { ...pageOption, ...handleSearchData(searchData, searchColumns) };
console.log('getData data: ', data);
const res = await getItems(data);
console.log('getData res: ', res);
tableData.value = res.rows;
total.value = res.total;
} catch (err) {
console.error(err);
} finally {
loading.value = false;
}
}
getData();
</script>
总结
数据表格组件的核心设计思路是 “配置化 + 插槽扩展”:通过 JSON 配置数组实现表格列的动态渲染;借助插槽机制支持单元格、表头、特殊列的自定义,兼顾业务灵活性;搭配分页器参数配置覆盖数据展示与分页控制的通用场景。
后续将继续介绍新增 / 编辑弹窗、详情弹窗等组件的封装实现,敬请关注!