日常前端开发中肯定会用到表格,写一个 table 的流程如下:
- 写 el-table 和 el-table-column 标签,绑定参数;如果需要把值处理后再显示,还需要调用函数或者自定义组件来处理数据
- 在 mounted 或者 created 生命周期中调接口获取数据,然后把数据传给 table 的 data 属性
- 表格右边往往会有操作列,需要定义好操作的名称和对应的回调函数
每次用到 element 的 table 表格组件时,都要编写一大堆这样的模板代码,很重复繁琐。
于是,我们希望有一种更配置化的开发方式,通过传入元数据,来动态生成表格。
设计
如何设计出配置化的动态表格组件呢?
我们先对目前的表格代码进行分析,发现常见的表格包括几部分内容:
- 表头
- 普通的单元格,简单点就直接显示该字段的值,复杂点可能需要使用其他组件,或者调用函数来显示值
- 操作列:有几个操作按钮,如果按钮数量比较多,还可能需要折叠起来
- 在业务组件的 mounted 或者 created 生命周期中调接口获取数据,传给 data 属性
- 表格需要分页
如何把这几部分内容进行封装呢?
- 对于表头,可以采用传入
columns数组的方式。每个column对象定义了以下属性:
| 属性 | 说明 | 类型 |
|---|---|---|
| prop | 从 data 中读取的字段 | String |
| label | 表头显示的属性名 | String |
| align | 同 table 的 align | String |
| fixed | 该列是否锁定 | Boolean |
- 对于表中的单元格,一般有几种类型:
- 直接显示字段的值
- 对字段的值进行转换,再显示转换后的文本
- 使用其他组件来渲染内容
以上几种情况的处理思路如下:
- 直接通过
row[col.prop]来取值 - 写一个转换函数 format,由于可能需要用到其他属性,所以传入的是表格该行的值:row;返回转换后的结果
- 为了足够灵活,我们借鉴了
Vue的render函数的写法,使用JSX的方式来编写动态组件;render函数传入h函数和当前列对象 column,以及当前行对象 row;通过在template中嵌入JSX的方式来渲染
column 对象要添加的属性,整理成表格如下:
| 属性 | 说明 | 类型 |
|---|---|---|
| format | 对原始值进行转换,返回转换后的结果。 | (row)=>String |
| render | 渲染函数 | (h,column,row)=>JSX |
如何在 template 中嵌入 JSX 呢?
我们编写一个函数式组件 Vnodes:
{
functional: true,
render: (h, ctx) => ctx.props.vnodes,
}
在需要嵌入 JSX 的 template 中使用 Vnodes 组件
<Vnodes :vnodes="col.render($createElement, col, row)" />
- 单元格处理完了,那表格右侧的操作列如何封装呢?
很简单,就像 columns 那样传入一个 operations 数组即可。
每个操作需要有一个唯一 key,我们用 id 来表示
每个操作的名字我们用 label 表示
一些危险性的操作,比如“删除”,需要用警示色来表示,那就传入一个 isDangerous 属性,组件内部来决定显示的颜色
按钮数量较多时,需要有一个临界值来控制最多显示多少个按钮,其余的按钮折叠,我们用 maxOperationNum 来表示
点击操作按钮后的回调,我们通过传入一个函数 actionMenuClick 来实现
operation 对象的属性如下:
| 属性 | 说明 | 类型 |
|---|---|---|
| id | 操作的 key | String |
| label | 操作的名称 | String |
| isDangerous | 是否危险操作 | Boolean |
把 maxOperationNum 和 actionMenuClick 放到 table 最外层:
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| maxOperationNum | 操作按钮不折叠时最多显示多少个按钮 | Number | 2 |
| actionMenuClick | 按钮点击事件的处理函数 | (menu, row)=>void, menu 为 operation 对象,row 为表格数据行对象 |
表格数据更新的业务逻辑,往往是调接口异步获取数据,传给 data。如何封装异步更新表格数据的函数呢?
我们定义一个函数 fetchData,这个函数返回一个 Promise,Promise resolve 后的对象需要符合这个格式:
IData
{
data: Object,
page: {
total: Number
}
}
把这个函数传给动态表格组件,组件内部在 mounted 生命周期函数中调用这个函数,接收到数据后,再把 data 塞给 table 的 data 属性,把 page.total 塞给分页组件
如果不需要异步加载数据,就不传入 fetchData 函数即可(fetchData 优先级高于 data)
如果需要手动刷新表格,就让动态表格组件暴露一个函数 refreshData,在业务代码中通过 ref 来调用 refreshData 这函数
综上,动态表格所有的参数如下:
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| columns | 表格的列的元数据 | Array | |
| data | 表格数据 | Array | |
| operations | 操作列 | Array | |
| maxOperationNum | 操作按钮不折叠时最多显示多少个按钮 | Number | 2 |
| actionMenuClick | 按钮点击事件的处理函数 | (menu:IOperation, row)=>void | |
| fetchData | 异步更新表格数据 | ()=>Promise |
methods:
| 方法名 | 说明 | 参数 |
|---|---|---|
| refreshData | 手动刷新表格 | 无 |
IColumn 类型定义
{
prop: String,
label: String,
align:String,
fixed:String,
format:(row)=>String,
render:(h,column,row)=>JSX
}
IOperation 类型定义
{
id:String,
label:String,
isDangerous:Boolean,
}
至此,动态表格组件已经设计好了,可以开发了
实现
下面基于 Vue2 版的 Element 组件,来开发动态表格组件(基于 Vue3 版的 Element Plus 或其他组件库的实现思路类似)
表格动态列的实现如下:
<el-table-column
v-for="col of columns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:align="col.align"
>
<template #header="{ column }">{{ column.label }}</template>
<template #default="{ row }">
<template v-if="typeof col.render === 'function'">
<Vnodes :vnodes="col.render($createElement, col, row)" />
</template>
<template v-else-if="typeof col.format == 'function'"
>{{ col.format(row) }}</template
>
<template v-else>{{ row[col.prop] }}</template>
</template>
</el-table-column>
这里包含了前面说的 3 种情况:使用 render 函数、使用 format 函数,和直接显示
表格操作列的实现如下:
<el-table-column label="操作">
<template #default="{ row }">
<el-button
v-for="op of operations"
:key="op.id"
type="text"
:class="{ dangerous: op.isDangerous }"
@click="(e) => clickFn(e, row)"
>
{{ op.label }}
</el-button>
</template>
</el-table-column>
export default {
props: {
operations: {
type: Array,
default: () => [],
},
actionMenuClick: Function,
},
methods: {
clickFn({ e: menu, row }) {
if (typeof this.actionMenuClick !== "function") {
return;
}
this.actionMenuClick(menu, row);
},
},
};
为了简单,这里没有进一步实现 maxOperationNum
表格分页待实现
表格异步加载数据的实现如下:
export default {
props: {
data: {
type: Array,
default: () => [],
},
fetchData: Function,
},
data() {
return {
total: 0,
currentData: [],
loading: false,
};
},
watch: {
data(val) {
if (Array.isArray(val)) {
this.currentData = val;
}
},
},
mounted() {
this.initFn();
},
methods: {
async initFn() {
if (typeof this.fetchData === "function") {
this.loading = true;
try {
const { list, pagination } = await this.fetchData();
this.currentData = list.data;
this.total = pagination.total;
} catch (err) {
console.error(err);
} finally {
this.loading = false;
}
} else {
this.currentData = this.data;
this.total = this.data.length;
}
},
},
};
所有代码如下:
<template>
<div>
<el-table :data="currentData" v-loading="loading" stripe highlight-current-row style="width: 100%">
<el-table-column type="index" width="50"></el-table-column>
<el-table-column v-for="col of columns" :key="col.prop" :prop="col.prop" :label="col.label" :align="col.align">
<template #header="{ column }">{{ column.label }}</template>
<template #default="{ row }">
<template v-if="typeof col.render === 'function'">
<Vnodes :vnodes="col.render($createElement, col, row)" />
</template>
<template v-else-if="typeof col.format == 'function'">{{ col.format(row) }}</template>
<template v-else>{{ row[col.prop] }}</template>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button
v-for="op of operations"
:key="op.id"
type="text"
:class="{ dangerous: op.isDangerous }"
@click="(e) => clickFn(e, row)"
>
{{ op.label }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
:total="total"
:page-size="pagination.pageSize"
:current-page="pagination.currentPage"
:layout="pagination.layout"
@size-change="sizeChange"
@current-change="pageChange"
@prev-click="pageChange"
@next-click="pageChange"
></el-pagination>
</div>
</template>
export default {
props: {
data: {
type: Array,
default: () => [],
},
columns: {
type: Array,
default: () => [],
},
operations: {
type: Array,
default: () => [],
},
pagination: {
type: Object,
default: () => ({}),
},
actionMenuClick: Function,
fetchData: Function,
},
components: {
Vnodes: {
functional: true,
render: (h, ctx) => ctx.props.vnodes,
},
},
data() {
return {
total: 0,
currentData: [],
loading: false,
};
},
watch: {
data(val) {
if (Array.isArray(val)) {
this.currentData = val;
}
},
},
mounted() {
this.initFn();
},
methods: {
async initFn() {
if (typeof this.fetchData === 'function') {
this.loading = true;
try {
const { list, pagination } = await this.fetchData();
this.currentData = list.data;
this.total = pagination.total;
} catch (err) {
console.error(err);
} finally {
this.loading = false;
}
} else {
this.currentData = this.data;
this.total = this.data.length;
}
},
clickFn({ e: menu, row }) {
if (typeof this.actionMenuClick !== 'function') {
return;
}
this.actionMenuClick(menu, row);
},
sizeChange(size) {
this.$message.info(`每页条数:${size}`);
},
pageChange(currentPage) {
this.$message.info(`当前页:${currentPage}`);
},
},
};
测试
写了以下代码来构造动态表格
<dynamic-table
:data="data"
:columns="columns"
:operations="operations"
:fetch-data="fetchData"
:pagination="pagination"
:action-menu-click="actionMenuClick"
></dynamic-table>
import DynamicTable from "./DynamicTable.vue";
export default {
components: {
DynamicTable,
},
data() {
return {
data: [
{
date: "2016-05-02",
name: "王小虎",
province: "上海",
city: "普陀区",
address: "上海市普陀区金沙江路 1518 弄",
zip: 200333,
tag: "家",
},
{
date: "2016-05-04",
name: "王小虎",
province: "上海",
city: "普陀区",
address: "上海市普陀区金沙江路 1517 弄",
zip: 200333,
tag: "公司",
},
],
columns: [
{
prop: "name",
label: "姓名",
render() {
return <div>666</div>;
},
},
{
prop: "date",
label: "日期",
},
{
prop: "city",
label: "城市",
format(data) {
return `${data.province}-${data.city}`;
},
},
],
operations: [
{
id: "edit",
label: "编辑",
},
{
id: "delete",
label: "删除",
isDangerous: true,
},
],
pagination: {
layout: "total, sizes, prev, pager, next, jumper",
},
};
},
methods: {
fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
list: {
data: this.data,
total: this.data.length,
},
pagination: {
currentPage: 0,
pageSize: 20,
total: 30,
},
});
}, 3000);
});
},
actionMenuClick({ menu, row }) {
const map = {
edit() {
this.$message.info(`编辑`);
},
delete() {
this.$message.info(`删除`);
},
};
map[menu.id](row);
},
},
};
运行效果
下一步优化的方向
未完待续