引言
在后台管理系统开发中,表格是最常用的 UI 组件之一。Element Plus 提供了功能强大的 el-table 组件,但在实际项目中,我们往往需要根据业务需求进行二次封装,以提高开发效率和代码复用性。
本文将详细介绍如何基于 Element Plus 封装一个功能完善、易于扩展的表格组件。
功能特性
- 支持自定义列配置
- 内置分页功能
- 支持加载状态
- 支持空状态定制
- 支持全局序号列
- 支持插槽自定义
实现细节
Props
| 参数 | 说明 | 类型 | 默认值 | 可选值 |
|---|---|---|---|---|
data | 表格数据 | Array | [] | - |
columns | 列配置 | Array | [] | - |
loading | 加载状态 | Boolean | false | true/false |
pagination | 分页配置 | Object/Boolean | {} | - |
paginationOptions | 分页组件配置 | Object | {} | - |
emptyHeight | 空数据表格高度 | String | "100%" | - |
emptyOptions | 空状态组件配置 | Object | {} | - |
fit | 列宽是否自适应 | Boolean | true | true/false |
showHeader | 是否显示表头 | Boolean | true | true/false |
stripe | 是否显示斑马纹 | Boolean | - | true/false |
border | 是否显示边框 | Boolean | - | true/false |
size | 表格尺寸 | String | - | medium/small/mini |
height | 表格高度 | String/Number | - | - |
| 其他配置 | 支持 Element UI el-table 的所有配置 | - |
Events
| 事件名称 | 说明 | 回调参数 |
|---|---|---|
pagination:size-change | 每页条数变化时触发 | size |
pagination:current-change | 当前页码变化时触发 | current |
| 其他事件 | 支持 Element UI el-table 的所有事件 | 同 Element UI |
Column 配置项
| 参数 | 说明 | 类型 | 默认值 | 可选值 |
|---|---|---|---|---|
prop | 列字段名 | String | - | - |
label | 列标题 | String | - | - |
width | 列宽 | String/Number | - | - |
minWidth | 最小列宽 | String/Number | - | - |
fixed | 是否固定列 | String/Boolean | - | left/right/true/false |
type | 列类型 | String | - | globalIndex/index/selection/expand |
hidden | 是否隐藏列 | Boolean | false | true/false |
| 其他配置 | 支持 Element UI el-table-column 的所有配置 | - |
Slots
| 插槽名称 | 说明 |
|---|---|
tableEmpty | 空状态插槽 |
tableEmptyDescription | 空状态描述插槽 |
tableEmptyImage | 空状态图片插槽 |
tableEmptyDefault | 空状态默认插槽 |
tableAppend | 表格底部追加内容插槽 |
tablePaginationDefault | 分页组件默认插槽 |
[prop]-header | 自定义列头插槽,[prop] 为列的 prop 值 |
[prop] | 自定义列内容插槽,[prop] 为列的 prop 值 |
组件源码
LdTableColumn
<script setup lang="ts">
defineOptions({
name: 'LdTableColumn',
});
defineProps({
column: {
type: Object,
default: () => ({}),
},
});
</script>
<template>
<el-table-column v-bind="column">
<!-- 递归渲染子列 -->
<template v-if="column.children && column.children.length > 0">
<ld-table-column
v-for="childCol in column.children"
:key="childCol.prop || childCol.label"
:column="childCol"
/>
</template>
<!-- 表头插槽 -->
<template
v-if="
(!column.children || column.children.length === 0) && column.useHeaderSlot && column.prop
"
#header="{ column: elColumn, $index }"
>
<slot :name="`${column.prop}-header`" :column="elColumn" :index="$index">
{{ column.label }}
</slot>
</template>
<!-- 内容插槽 -->
<template
v-if="(!column.children || column.children.length === 0) && column.useSlot && column.prop"
#default="{ row, column: elColumn, $index }"
>
<slot :name="column.prop" :row="row" :column="elColumn" :index="$index">
{{
column.formatter
? column.formatter(row, elColumn, row[column.prop], $index)
: row[column.prop]
}}
</slot>
</template>
</el-table-column>
</template>
LdTable
<script setup lang="ts">
import { useResizeObserver } from '@/composables';
import type { LdTableEmits, LdTablePaginationProps, LdTableProps, LdTableSlots } from './LdTable';
defineOptions({
name: 'LdTable',
});
const elTableRef = useTemplateRef('elTableRef');
const paginationRef = useTemplateRef('paginationRef');
const paginationHeight = ref(0);
// 分页器与表格之间的间距常量
const PAGINATION_SPACING = 6;
useResizeObserver(paginationRef, (entries) => {
const entry = entries[0];
if (entry) {
requestAnimationFrame(() => {
paginationHeight.value = entry.contentRect.height;
});
}
});
const props = withDefaults(defineProps<LdTableProps>(), {
columns: () => [],
fit: true,
showHeader: true,
stripe: undefined,
border: undefined,
size: undefined,
emptyHeight: '100%',
emptyText: '暂无数据',
});
// const instance = getCurrentInstance();
const attrs = useAttrs();
const emits = defineEmits<LdTableEmits>();
const slots = defineSlots<LdTableSlots>();
// 合并分页组件配置
const mergedPaginationOptions = computed(() => {
const defaultOptions: Partial<LdTablePaginationProps> = {
pageSizes: [10, 20, 30, 50, 100],
align: 'center',
background: true,
layout: 'total, prev, pager, next, sizes, jumper',
hideOnSinglePage: false,
size: 'default',
pagerCount: 7,
};
return { ...defaultOptions, ...props.paginationOptions } as LdTablePaginationProps;
});
// 空数据判断
const isEmpty = computed(() => {
return props.data && props.data.length === 0;
});
// 表格高度逻辑
const tableHeight = computed(() => {
// 空数据且非加载状态时固定高度
if (isEmpty.value && !props.loading) return props.emptyHeight;
// 使用传入的高度
if (props.height) return props.height;
// 默认占满容器高度
return '100%';
});
// 容器高度计算
const containerHeight = computed(() => {
const matchPaginationHeight = showPagination.value ? paginationHeight.value : 0;
const offset = matchPaginationHeight + PAGINATION_SPACING;
return {
height: offset === 0 ? '100%' : `calc(100% - ${offset}px)`,
};
});
const mergedTableProps = computed(() => {
return {
...attrs,
...props,
height: tableHeight.value,
};
});
const showPagination = computed(() => {
return props.pagination && !isEmpty.value;
});
// 合并空状态组件配置
const mergedEmptyOptions = computed(() => {
const defaultOptions = {
description: props.emptyText,
imageSize: 120,
};
return { ...defaultOptions, ...props.emptyOptions };
});
const tableColumns = computed(() => {
return props.columns
.map((column) => ({
hidden: column.hidden ?? false,
...column,
}))
.filter((column) => !column.hidden);
});
const getGlobalIndex = (index: number) => {
if (!props.pagination) return index + 1;
const { currentPage = 1, pageSize = 10 } = props.pagination;
return isNaN((currentPage - 1) * pageSize + index + 1)
? index + 1
: (currentPage - 1) * pageSize + index + 1;
};
const handleSizeChange = (size: number) => {
emits('pagination:size-change', size);
};
const handleCurrentChange = (current: number) => {
emits('pagination:current-change', current);
scrollToTop(); // 页码改变后滚动到表格顶部
};
const handlePaginationChange = (pageSize: number, currentPage: number) => {
emits('pagination:change', pageSize, currentPage);
};
const handlePrevClick = (currentPage: number) => {
emits('pagination:prev-click', currentPage);
};
const handleNextClick = (currentPage: number) => {
emits('pagination:next-click', currentPage);
};
// 滚动表格内容到顶部
const scrollToTop = () => {
nextTick(() => {
if (elTableRef.value) {
elTableRef.value.setScrollTop(0);
}
});
};
defineExpose({
elTableRef,
paginationRef,
});
</script>
<template>
<div class="ld-table" :class="{ 'ld-table--empty': isEmpty }" :style="containerHeight">
<el-table ref="elTableRef" v-loading="!!loading" v-bind="mergedTableProps">
<template v-for="col in tableColumns">
<!-- 渲染全局序号列 -->
<el-table-column
v-if="col.type === 'globalIndex'"
v-bind="{ ...col }"
:key="'globalIndex-' + (col.prop || col.type)"
:fixed="col.fixed || 'left'"
:align="col.align || 'center'"
:header-align="col.headerAlign || 'center'"
>
<template #header="{ column, $index }">
<slot :name="`${column.prop}-header`" :column="column" :index="$index">
<span>{{ col.label || '序号' }}</span>
</slot>
</template>
<template #default="{ row, column, $index }">
<slot :name="`${column.prop}`" :row="row" :column="column" :index="$index">
<span>{{ getGlobalIndex($index) }}</span>
</slot>
</template>
</el-table-column>
<!-- 渲染列 -->
<ld-table-column v-else :key="col.prop || col.label" :column="col">
<template v-for="(_, slotName) in slots" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</ld-table-column>
</template>
<template v-if="slots['default']" #default>
<slot></slot>
</template>
<template #empty>
<slot name="tableEmpty">
<div v-if="loading"></div>
<el-empty v-else v-bind="mergedEmptyOptions">
<template v-if="slots['tableEmptyDescription']" #description>
<slot name="tableEmptyDescription"></slot>
</template>
<template v-if="slots['tableEmptyImage']" #image>
<slot name="tableEmptyImage"></slot>
</template>
<template v-if="slots['tableEmptyDefault']" #default>
<slot name="tableEmptyDefault"></slot>
</template>
</el-empty>
</slot>
</template>
<template #append>
<slot name="tableAppend"></slot>
</template>
</el-table>
<div
v-if="showPagination"
ref="paginationRef"
class="ld-table__pagination ld-table__pagination--custom"
:class="'ld-table__pagination--' + mergedPaginationOptions.align"
>
<el-pagination
v-bind="mergedPaginationOptions"
:total="pagination?.total"
:disabled="loading"
:page-size="pagination?.pageSize"
:current-page="pagination?.currentPage"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@change="handlePaginationChange"
@prev-click="handlePrevClick"
@next-click="handleNextClick"
>
<template v-if="slots['tablePaginationDefault']" #default>
<slot name="tablePaginationDefault"></slot>
</template>
</el-pagination>
</div>
</div>
</template>
<style lang="scss" scoped>
.ld-table {
width: 100%;
position: relative;
> * {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.el-table {
width: 100%;
}
.ld-table__pagination {
margin-top: 13px;
&.ld-table__pagination--custom {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
&.ld-table__pagination--left {
justify-content: flex-start;
}
&.ld-table__pagination--center {
justify-content: center;
}
&.ld-table__pagination--right {
justify-content: flex-end;
}
}
}
</style>
类型注解
import type { EmptyProps, PaginationProps, TableColumnCtx, TableProps } from 'element-plus';
import type { VNode } from 'vue';
export interface LdTableEmits {
(e: 'pagination:current-change', current: number): void;
(e: 'pagination:size-change', size: number): void;
(e: 'pagination:change', pageSize: number, currentPage: number): void;
(e: 'pagination:prev-click', currentPage: number): void;
(e: 'pagination:next-click', currentPage: number): void;
}
export interface LdTableSlots {
default: (scope: unknown) => VNode[];
tablePaginationDefault: (scope: unknown) => VNode[];
tableEmpty: (scope: unknown) => VNode[];
tableEmptyDefault: (scope: unknown) => VNode[];
tableEmptyDescription: (scope: unknown) => VNode[];
tableEmptyImage: (scope: unknown) => VNode[];
tableAppend: (scope: unknown) => VNode[];
[key: string]: (scope: unknown) => VNode[];
}
export interface LdTablePaginationConfig {
// 总页数
total: number;
// 当前页码
currentPage: number;
// 每页显示条数
pageSize: number;
}
// 表格列配置接口
export interface ColumnOption<T = unknown> {
// 列类型
type?: 'selection' | 'expand' | 'index' | 'globalIndex';
// 列属性名
prop?: string;
// 列标题
label?: string;
// 列宽度
width?: string | number;
// 最小列宽度
minWidth?: string | number;
// 固定列
fixed?: boolean | 'left' | 'right';
// 是否可排序
sortable?: boolean;
// 过滤器选项
filters?: unknown[];
// 过滤方法
filterMethod?: (value: unknown, row: T) => boolean;
// 过滤器位置
filterPlacement?: string;
// 是否禁用
disabled?: boolean;
// 是否显示列
visible?: boolean;
// 是否选中显示
checked?: boolean;
// 自定义渲染函数
formatter?: (row: T) => unknown;
// 插槽相关配置
// 是否使用插槽渲染内容
useSlot?: boolean;
// 插槽名称(默认为 prop 值)
slotName?: string;
// 是否使用表头插槽
useHeaderSlot?: boolean;
// 表头插槽名称(默认为 `${prop}-header`)
headerSlotName?: string;
// 其他属性
[key: string]: unknown;
}
export type TableSize = 'large' | 'default' | 'small';
export interface LdTableColumnProps extends Partial<TableColumnCtx<Record<string, unknown>>> {
prop?: string;
label?: string;
type?: string;
hidden?: boolean;
fixed?: 'left' | 'right' | boolean;
align?: 'left' | 'center' | 'right';
headerAlign?: 'left' | 'center' | 'right';
}
export interface LdTableConfig {
columns: LdTableColumnProps[];
loading?: boolean;
pagination?: LdTablePaginationConfig;
paginationOptions?: PaginationProps;
emptyHeight?: string | number;
emptyOptions?: EmptyProps;
fit?: boolean;
showHeader?: boolean;
stripe?: boolean;
border?: boolean;
size?: TableSize;
height?: string | number;
emptyText?: string;
}
export type LdTableProps = LdTableConfig & Partial<TableProps<Record<string, unknown>>>;
export type LdTablePaginationProps = PaginationProps & {
align?: 'left' | 'center' | 'right';
};
工具 hooks
import {
computed,
toValue,
watch,
getCurrentScope,
onScopeDispose,
type MaybeRefOrGetter,
} from 'vue';
function unrefElement(
el: MaybeRef<HTMLElement | SVGElement | ComponentPublicInstance | undefined | null>,
) {
const _el = toValue(el);
return (_el as ComponentPublicInstance)?.$el ?? _el;
}
export const useResizeObserver = (
target:
| MaybeRefOrGetter<HTMLElement | SVGElement | ComponentPublicInstance | null | undefined>
| MaybeRefOrGetter<HTMLElement | SVGElement | ComponentPublicInstance | null | undefined>[],
callback: ResizeObserverCallback,
options: ResizeObserverOptions = {},
) => {
let observer: ResizeObserver | undefined;
const isSupported = computed(() => window && 'ResizeObserver' in window);
const cleanup = () => {
if (observer) {
observer.disconnect();
observer = undefined;
}
};
const targets = computed(() => {
const _targets = toValue(target);
return Array.isArray(_targets)
? _targets.map((el) => unrefElement(el as ComponentPublicInstance))
: [unrefElement(_targets)];
});
const stopWatch = watch(
targets,
(els) => {
cleanup();
if (isSupported.value && window) {
observer = new ResizeObserver(callback);
for (const _el of els) {
if (_el) observer.observe(_el, options);
}
}
},
{ immediate: true, flush: 'post' },
);
const stop = () => {
cleanup();
stopWatch();
};
if (getCurrentScope()) {
onScopeDispose(stop);
}
return {
isSupported,
stop,
};
};
FAQ
- 当使用
pagination功能时,需要传入正确的current、size和total值 - 当使用
globalIndex类型的列时,会自动计算全局序号,不受分页影响 - 当表格数据为空且非加载状态时,会显示空状态组件
- TODO:
render函数来定义列渲染
感谢阅读,敬请斧正!