基于ElementPlus的table组件封装

前言

我们在使用UI库编写页面的时候,特别是账务系统,需要用到表格的情况会比较多,如果我们每次都是复制一遍UI库中的demo然后进行调整,这样造成的结果是多次引入 Table 组件,而且从前端开发规范来讲,不符合组件化的初衷。因此我们将 Table 组件进行二次封装,无疑是最好的选择。二次封装 Table 组件就是为了增强组件的可复用性、可维护性和功能性。

二次封装的优势
统一风格和功能
  • 样式一致性:项目中可能有多个地方使用表格,二次封装可以确保所有表格在样式和功能上保持一致。
简化使用
  • 简化 API:通过封装,可以提供更简单、更直观的 API,减少开发者在使用时需要考虑的细节。例如,可以通过 props 传递配置选项,而不必每次都重复编写配置。
  • 封装复杂逻辑:将复杂的逻辑(例如数据处理、列渲染)封装到一个组件中,调用方只需使用这个封装的组件即可,降低了使用难度。
增强功能
  • 插槽:通过插槽,可以灵活地扩展表格的功能,例如自定义列内容、操作按钮等,提升组件的灵活性。
  • 事件处理:可以统一处理表格中的各种事件,如行点击、行选中等,提供更一致的事件响应方式。
提高可维护性
  • 集中管理:将表格的所有相关逻辑集中到一个地方,使得后续的维护和修改更加简单。如果需要更改某个功能,只需在封装的组件中进行修改,而不必在每个使用表格的地方重复修改。
  • 易于调试:封装后的组件可以更容易进行单元测试和调试,确保表格的各项功能在不同场景下都能正常工作。
提高开发效率
  • 复用性:封装后的组件可以在不同的项目中复用,节省开发时间。通过封装常用的表格功能,可以减少重复劳动。
  • 快速迭代:通过提供易于使用的组件,团队成员可以快速上手并使用,而不必深入了解底层实现。

我使用的技术栈是Vue3+Ts+Element Plus,所以此次table组件的二次封装也是基于Element Plus的,其中Element Plus版本选的是2.8.0

前提条件

默认大家已经能够正确地创建Vue3+Ts的前端工程,Element Plus的安装可以参考https://element-plus.org/zh-CN/guide/installation.html,还需要大家在提前注册`Element Plus`所有的图标。代码如下:

js 复制代码
// main.ts

import * as ElementPlusIconsVue from '@element-plus/icons-vue';

// 注册elementPlus所有的图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.component(key, component)
}
封装代码源码
vue 复制代码
// c-table/components/table-column-item/index.vue

<template>
	<el-table-column
		v-if="!column.children && column.type !== 'selection' && column.type !== 'expand'"
		:prop="column.prop"
		:label="column.label"
		:width="column.width"
		:show-overflow-tooltip="column.showOverflowTooltip"
		:fixed="column.fixed"
		:min-width="column.minWidth"
		:align="column.align"
		:type="column.type"
		:resizable="column.resizable"
		:sortable="column.sortable"
		:selectable="column.selectable"
		:column-key="column.columnKey"
		:filters="column.filters"
		:filter-method="column.filterMethod"
		:sort-method="column.sortMethod"
        :sort-by="column.sortBy"
        :sort-orders="column.sortOrders"
        :header-align="column.headerAlign"
        :class-name="column.className"
        :label-class-name="column.labelClassName"
        :reserve-selection="column.reserveSelection"
        :filter-placement="column.filterPlacement"
        :filter-class-name="column.filterClassName"
        :filter-multiple="column.filterMultiple"
        :filter-value="column.filterValue"
	>
		<template #default="{ row, $index }">
			<template v-if="column.formatter">
				<template v-if="isStringFormatter(row, $index)">
					{{
						column.formatter(row, column, row[column.prop], $index)
					}}
				</template>
				<template v-else>
					<component
						:is="
							column.formatter(
								row,
								column,
								row[column.prop],
								$index
							)
						"
					/>
				</template>
			</template>
			<template v-if="column.index && !column.formatter">
				{{ column.index($index) }}
			</template>
			<component
				:is="column.slots.default"
				:row="row"
				:index="$index"
				v-if="column.slots && column.slots.default"
			/>
		</template>
		<template #header="scope" v-if="column.slots && column.slots.header">
			<component
				:is="column.slots.header"
				:row="scope.row"
				:index="scope.$index"
			/>
		</template>
	</el-table-column>
    <!-- 多级表头 -->
	<el-table-column
		v-if="column.children && column.type !== 'expand'"
		:prop="column.prop"
		:label="column.label"
		:align="column.align"
		:type="column.type"
		:resizable="column.resizable"
	>
		<template v-for="child in column.children" :key="child.prop">
			<table-column-item :column="child" />
		</template>
	</el-table-column>
    <!-- 多选 -->
	<el-table-column
		v-if="column.type == 'selection'"
		type="selection"
		:width="column.width"
		:selectable="column.selectable"
	/>
	<!-- 展开行 -->
	<el-table-column v-if="column.type == 'expand'" type="expand">
		<template #default="{ row, $index }">
			<component
				:is="column.slots.default"
                :row="row"
				:index="$index"
			/>
		</template>
	</el-table-column>
</template>

<script lang="ts">
import { PropType } from "vue";
import { ColumnItem } from "../../../../type";
import { ElTable, ElTableColumn } from "element-plus";

export default {
	name: "TableColumnItem",
	props: {
		column: {
			type: Object as PropType<ColumnItem>,
			default: {},
		},
	},
	components: {
		ElTableColumn,
	},
	setup(props, context) {
		const isStringFormatter = (row: any, index: number): boolean => {
			const formattedValue = props.column.formatter(
				row,
				props.column,
				row[props.column.prop],
				index
			);
			return typeof formattedValue === "string";
		};
		return {
			isStringFormatter,
		};
	},
};
</script>
vue 复制代码
// c-table/index.vue

<template>
	<el-table v-bind="$attrs" :data="tableData" ref="elTableRef">
		<TableColumnItem
			v-for="column in columns"
			:key="column.prop"
			:column="column"
		></TableColumnItem>
		<!-- 自定义空数据时的内容 -->
		<template v-slot:empty>
			<slot name="empty"></slot>
		</template>
	</el-table>
</template>

<script lang="ts">
import {
	computed,
	ref,
	reactive,
	onMounted,
	onBeforeUnmount,
	watch,
	toRefs,
	PropType,
} from "vue";
import { TableData, ColumnItem } from "../../type";
import TableColumnItem from "./components/table-column-item/index.vue";
import { ElButton, ElIcon, ElTable } from "element-plus";

export default {
	name: "CTable",
	props: {
		tableData: {
			type: Array as PropType<TableData[]>,
			default: [],
		},
		columns: {
			type: Array as PropType<ColumnItem[]>,
			default: [],
		},
	},
	components: {
		TableColumnItem,
	},
	setup(props, ctx) {
		const elTableRef = ref<InstanceType<typeof ElTable>>();
		return {
			elTableRef,
		};
	},
};
</script>
ts 复制代码
// type.ts

import { VNode } from 'vue';
import type { TableColumnCtx } from 'element-plus'

export interface TableData {
    name?: string,
    date?: string,
    address?: string,
    result?: number,
    amount?: number,
    state?: string,
    city?: string,
    zip?: string,
    hasChildren?: boolean,
    propsData?: TableData[]
}

export interface ColumnItem {
    prop?: string,
    label: string,
    width?: number | string,
    minWidth?: number,
    align?: string,
    type?: string,
    sortable?: boolean | string,
    resizable?: boolean,
    columnKey?: string,
    selectable?: () => boolean,
    index?: (index: number) => number,
    filterMethod?: (value: any, row: any, column: any) => void,
    formatter?: (row: any, column: any, cellValue: any, index: number) => VNode | string,
    sortMethod?: (a: TableData, b: TableData) => number,
    slots?: {
        default?: (a: SlotsItem) => VNode;
        header?: (a: SlotsItem) => VNode;
    };
    showOverflowTooltip?: boolean,
    fixed?: boolean | 'left' | 'right',
    children?: ColumnItem[],
    filters?: FilterItem[],
    sortBy?: (row: any, index: number) => string | string | string[],
    sortOrders?: ('ascending' | 'descending' | null)[],
    headerAlign?: 'left' | 'center' | 'right',
    filterPlacement?: 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end',
    className?: string,
    labelClassName?: string,
    filterClassName?: string,
    reserveSelection?: boolean,
    filterMultiple?: boolean,
    filterValue?: string[],
}

export interface SlotsItem {
    index: number,
    row: TableData,
}

export interface FilterItem {
    text: string,
    value: string,
}

export interface SummaryMethodProps<T = TableData> {
    columns: TableColumnCtx<T>[]
    data: T[]
}

export interface SpanMethodProps {
    row: TableData
    column: TableColumnCtx<TableData>
    rowIndex: number
    columnIndex: number
}
使用
vue 复制代码
<template>
	<div class="table-list">
		<!-- lazy
			:load="load"
			:tree-props="{ children: 'children', hasChildren: 'hasChildren' }" -->
		<c-Table
			:span-method="arraySpanMethod"
			show-summary
			:summary-method="getSummaries"
			:default-expand-all="false"
			:row-class-name="tableRowClassName"
			ref="tableRef"
			:data="tableData"
			:columns="columns"
			border
			stripe
			style="width: 100%"
			height="500"
			row-key="name"
			highlight-current-row
			@current-change="handleCurrentChange"
			table-layout="auto"
		>
			<template v-slot:empty>
				<div style="text-align: center; padding: 20px; color: #999">
					<p>没有数据可显示</p>
					<el-button type="primary">添加数据</el-button>
				</div>
			</template>
		</c-Table>
		<div style="margin-top: 20px">
			<el-button @click="setCurrent(tableData[1])">选择第二行</el-button>
			<el-button @click="setCurrent()">清除选中行</el-button>
			<el-button
				@click="toggleSelection([tableData[1], tableData[2]], false)"
			>
				勾选目标行
			</el-button>
			<el-button @click="toggleSelection()">清除勾选数据</el-button>
		</div>
	</div>
</template>

<script lang="ts">
import { ref, defineComponent, reactive, toRefs, h, VNode } from "vue";
import CTable from "./components/c-table/index.vue";
import {
	TableData,
	SlotsItem,
	ColumnItem,
	SummaryMethodProps,
	SpanMethodProps,
} from "./type";
import { ElButton, ElIcon, ElInput } from "element-plus";
import type { TableColumnCtx } from "element-plus";
import { Check, Close } from "@element-plus/icons-vue";

export default defineComponent({
	name: "tableList",
	props: {},
	components: {
		CTable,
	},
	setup() {
		const search = ref("");
		const searchValueChange = (val: any) => {
			console.log("searchValueChange", val);
		};
		const handleInputChange = (val: any) => {
			console.log("handleInputChange", val);
		};
		const filterHandler = (
			value: string,
			row: TableData,
			column: TableColumnCtx<TableData>
		) => {
			const property = column.property as keyof TableData;
			return row[property] === value;
		};
		const subTableRef = ref();
		const tableInfo = reactive({
			tableData: [
				{
					date: "2016-05-03",
					name: "Alice",
					address: "No. 189, Grove St, Los Angeles1",
					state: "California",
					city: "Los Angeles",
					zip: "CA 90036",
					result: 0,
					amount: 12,
					// hasChildren: true,
					// propsData: [
					// 	{
					// 		name: "Jerry",
					// 		state: "California",
					// 		city: "San Francisco",
					// 		address: "3650 21st St, San Francisco",
					// 		zip: "CA 94114",
					// 	},
					// ],
				},
				{
					date: "2016-05-02",
					name: "Tom",
					address: "No. 189, Grove St, Los Angeles2",
					state: "California",
					city: "Los Angeles",
					zip: "CA 90036",
					result: 1,
					amount: 19,
				},
				{
					date: "2016-05-01",
					name: "Bob",
					address: "No. 189, Grove St, Los Angeles12",
					state: "California",
					city: "Los Angeles",
					zip: "CA 90038",
					result: 0,
					amount: 120,
				},
			] as TableData[],
			subColumn: [
				{
					prop: "name",
					label: "name",
					width: 120,
				},
				{
					prop: "state",
					label: "state",
					width: 120,
				},
				{
					prop: "city",
					label: "city",
					width: 120,
				},
				{
					prop: "address",
					label: "address",
					width: 120,
				},
				{
					prop: "zip",
					label: "zip",
				},
			] as ColumnItem[],

			columns: [
				{
					label: "索引",
					width: 60,
					fixed: true,
					type: "index",
					formatter: (
						row: any,
						column: any,
						cellValue: any,
						index: number
					): string => {
						return String(index * 2);
					},
				},
				{
					type: "selection",
					width: 100,
					selectable: (val: TableData) => {
						return true;
					},
					reserveSelection: true,
				},
				{
					type: "expand",
					slots: {
						default: ({ index, row }: SlotsItem): VNode => {
							return h(CTable, {
								ref: subTableRef,
								data: row.propsData,
								columns: tableInfo.subColumn,
								border: true,
								stripe: true,
								style: { width: "100%" },
								height: "auto",
								"row-key": "name",
								"highlight-current-row": true,
								"table-layout": "auto",
							});
						},
					},
				},
				{
					prop: "date",
					label: "时间",
					width: 140,
					sortable: true,
					columnKey: "date",
					filterMethod: filterHandler,
					filterValue: ["2016-05-01", "2016-05-02"],
					filters: [
						{ text: "2016-05-01", value: "2016-05-01" },
						{ text: "2016-05-02", value: "2016-05-02" },
						{ text: "2016-05-03", value: "2016-05-03" },
						{ text: "2016-05-04", value: "2016-05-04" },
					],
				},
				{
					prop: "name",
					label: "姓名",
					width: 240,
					formatter: (
						row: any,
						column: any,
						cellValue: any,
						index: number
					): VNode | string => {
						if (!cellValue) return h("span", "");
						return h(
							"span",
							{ style: { color: "red" } },
							cellValue
						);
					},
					sortable: true,
					sortMethod: (a, b) => {
						return a.name.localeCompare(b.name);
					},
					sortOrders: ["ascending", "descending"],
					align: "center",
					className: "custom-column",
					labelClassName: "custom-labelClassName",
				},
				{
					prop: "address",
					label: "地址",
					width: 120,
					// renderHeader: (data) => {
					//     console.log('-----', data);
					//     return '1'
					// } -> 建议通过插槽的方式实现
				},
				{
					label: "第一级表头",
					align: "center",
					resizable: true,
					children: [
						{
							label: "第二级表头",
							align: "center",
							resizable: true,
							children: [
								{
									prop: "state",
									label: "州",
									width: 320,
									resizable: true,
								},
								{
									prop: "city",
									label: "城市",
									width: 400,
									resizable: true,
								},
							],
						},
						{
							prop: "zip",
							label: "Zip",
							resizable: true,
							width: 280,
						},
					],
				},
				{
					prop: "amount",
					label: "数量",
					width: 120,
				},
				{
					prop: "result",
					label: "校验结果",
					align: "center",
					width: 120,
					slots: {
						default: ({ row }: SlotsItem): VNode => {
							return h(
								ElIcon,
								{
									id: "result-i",
									size: 14,
									color: row.result == 1 ? "red" : "green",
								},
								{
									default: () =>
										row.result == 0 ? h(Check) : h(Close),
								}
							);
						},
					},
				},
				{
					label: "操作",
					fixed: "right",
					minWidth: 180,
					slots: {
						default: (val: SlotsItem): VNode => {
							return h(
								ElButton,
								{
									type: "primary",
									onClick: (event) => {
										event.stopPropagation(); // 阻止事件冒泡
										checkF(val.row);
									},
								},
								() => "查看"
							);
						},
						header: (val: SlotsItem): VNode => {
							return h(ElInput, {
								size: "small",
								placeholder: "请输入",
								modelValue: search.value,
								"onUpdate:modelValue": (value) => {
									search.value = value; // 更新 search
								},
								onChange: (value) => {
									searchValueChange(value); // 处理 change 事件,失去焦点才会触发
								},
								onInput: (value) => {
									handleInputChange(value); // 处理 input 事件
								},
								disabled: false,
							});
						},
					},
				},
			] as ColumnItem[],
		});
		const tableRef = ref();
		const currentRow = ref();

		const load = (
			row: TableData,
			treeNode: unknown,
			resolve: (data: TableData[]) => void
		) => {
			setTimeout(() => {
				resolve([
					{
						date: "2016-05-01",
						name: "wangxiaohu",
						address: "No. 189, Grove St, Los Angeles",
					},
					{
						date: "2016-05-01",
						name: "wangxiaohu",
						address: "No. 189, Grove St, Los Angeles",
					},
				]);
			}, 1000);
		};

		const getSummaries = (param: SummaryMethodProps) => {
			const { columns, data } = param;
			const sums: (string | VNode)[] = [];
			columns.forEach((column, index) => {
				if (index === 0) {
					sums[index] = h(
						"div",
						{ style: { textDecoration: "underline" } },
						["Total Cost"]
					);
					return;
				}
				const values = data.map((item: any) =>
					Number(item[column.property])
				);
				if (!values.every((value) => Number.isNaN(value))) {
					sums[index] = `$ ${values.reduce((prev, curr) => {
						const value = Number(curr);
						if (!Number.isNaN(value)) {
							return prev + curr;
						} else {
							return prev;
						}
					}, 0)}`;
				} else {
					sums[index] = "N/A";
				}
			});

			return sums;
		};
		const tableRowClassName = ({
			rowIndex,
		}: {
			rowIndex: number;
		}): string => {
			if (rowIndex === 1) {
				return "warning-row";
			} else if (rowIndex === 3) {
				return "success-row";
			}
			return "";
		};
		const checkF = (val: TableData) => {
			console.log("checkF", val, tableRef.value);
		};
		const handleCurrentChange = (val: TableData | undefined) => {
			currentRow.value = val;
		};
		const setCurrent = (row?: TableData) => {
			tableRef.value.elTableRef!.setCurrentRow(row);
		};
		const toggleSelection = (
			rows?: TableData[],
			ignoreSelectable?: boolean
		) => {
			if (rows) {
				rows.forEach((row) => {
					tableRef.value.elTableRef!.toggleRowSelection(
						row,
						undefined,
						ignoreSelectable
					);
				});
			} else {
				tableRef.value.elTableRef!.clearSelection();
			}
		};
		const arraySpanMethod = ({
			row,
			column,
			rowIndex,
			columnIndex,
		}: SpanMethodProps) => {
			if (rowIndex % 2 === 0) {
				if (columnIndex === 0) {
					return [1, 2];
				} else if (columnIndex === 1) {
					return [0, 0];
				}
			}
		};
		return {
			...toRefs(tableInfo),
			tableRowClassName,
			checkF,
			tableRef,
			handleCurrentChange,
			currentRow,
			setCurrent,
			toggleSelection,
			getSummaries,
			arraySpanMethod,
			load,
		};
	},
});
</script>

<style lang="scss" scoped>
.table-list {
	height: 100%;
	width: 100%;
	padding: 12px;
	::v-deep .el-table {
		.warning-row .el-table__cell {
			background-color: #fdf6ec;
		}
		.success-row .el-table__cell {
			background-color: #f0f9eb;
		}
		.el-button:focus {
			outline: none;
			border: none;
		}
	}
}
</style>

!CAUTION

Table 暴露出来的函数,需要通过tableRef.value.elTableRef来访问

总结

此次封装是针对Table组件的二次封装,并没有将分页、筛选等功能放在一起,后续会继续更新,完善组件封装的完整性。

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax