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