基于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组件的二次封装,并没有将分页、筛选等功能放在一起,后续会继续更新,完善组件封装的完整性。

相关推荐
m0_748247551 小时前
Web 应用项目开发全流程解析与实战经验分享
开发语言·前端·php
m0_748255022 小时前
前端常用算法集合
前端·算法
真的很上进2 小时前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
web130933203982 小时前
vue elementUI form组件动态添加el-form-item并且动态添加rules必填项校验方法
前端·vue.js·elementui
NiNg_1_2343 小时前
Echarts连接数据库,实时绘制图表详解
前端·数据库·echarts
如若1233 小时前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~4 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语4 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport4 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg4 小时前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全