前奏
每次在实现后台功能时,几乎都会有一个数据列表作为该功能的入口页。
一般情况下,在编写这个数据列表页时,都需要写一堆 <el-table-column/>, 复杂一些的,还会用到 slot 去自定义列的内容。
这么做看似问题不大,但其实我们可以发现,当你的功能越来越复杂,或者类似这样的数据列表越来越多时,难免会出现这样或那样的问题。
本文主要讲解如何用配置化的方式去渲染出简单的 el-table,以及思考如何用更抽象的思维去分离组件。
篇幅文字较多,请耐心阅读 ~
📦 仓库地址:hoc-el-table,欢迎 ✨ star ✨ ~~
🎊 效果演示:Demo
🎉 更新
2022年01月01日,现已支持 Vue 3.0 🎉 🎉 🎉
如果有好的想法也可以留言或发 issue 提问啦 ~~
💭本文首发掘金: 实现可配置化的 element-ui 表格: hoc-el-table
场景 🎑
开始之前,先看几个场景,再慢慢开始想如何解决期间所遇到的问题。
场景一
开发前:
🐶:需要一个列表页展示本次上线的内容,原型发群里了
🐒:拿到原型开始研究,完后立刻在项目里找了一个之前写过的列表页,复制之,然后充当此次功能的页面,开始了编写...
场景二
功能上线后,又提了几个需求:
🐶:第二列的宽度增大下;把第三、四列的内容合成一列展示
🐒:找到对应
<el-table-column/>修改其width值;删掉第四列的代码,同时更改第三列为slot展示方式,然后填充原本三和四列的内容
场景三
功能再次上线后,又双叒提了几个需求:
🐶:第二列的内容需要根据这一条数据里面的xxx字段的值动态的去展示
内容1或内容2;🐒:找到第二列对应的
<el-table-column/>先更改成slot的展示方式,其次在里面通过v-if或computed等逻辑去区分内容1和内容2的展示
场景四
功能再次上线后,又双叒叕提了几个需求:
🐶 最后一列加个按钮,每点击一次就会
禁用/解除禁用这条数据,并且对应按钮的文字和颜色也要随着禁用的状态做出相应的反馈🐒:找到最后一列,依然通过
slot的方式去添加两个按钮(或者一个),用v-if的方式去控制它们的展示,用:class或者style去控制按钮的颜色...
问题发现 🔎
从刚才的场景中,我们可以发现以下几个问题:
产品老是改需求(逃- 每次都要写一堆
<el-table-column/>和slot代码,不能忍受这样没效率的事情,且违背了 DRY 原则 - 每当有一列的内容需要调整时,就得手动定位并修改对应的
<el-table-column/>或slot,有可能一不小心改坏其他地方,那么此次修改就变成侵入式的了 - 当需要展示复杂内容时,往往需要在
slot里面写,而且之后再添加新东西的话slot就会变得越来越臃肿;
思考 💭
问题有了,那么我们就可以思考:
- 能否不使用
<el-table-column/>和slot代码,而是通过配置化的方式来达到生成表格的目的呢? - 若表格列对应的键值需要做一些处理再展示,该怎么做呢?
- 表格列能否支持
任意的复杂数据展示呢? - 一般情况下最后一列会有一些操作的按钮,那么这些按钮和其对应的事件能否灵活的配置出来呢?
开始设计 🎈
有了前面的思考,那么该如何设计表格的配置项呢?
我们可以按照下面的步骤这样来做:
当然,如果你有更好的思路,欢迎评论区留言 ~
- 首先实现最简单的配置:仅提供
label和prop这两个列属性 attributes,就可以渲染出列表- 支持绑定其他的
attributes- 支持渲染对默认 prop 值做一些微处理的场景,其实就相当于支持渲染任意字符串了
- 支持渲染任意组件,针对渲染复杂数据的场景
- 支持渲染像最后一列那样的操作按钮
- 支持
pagination分页
实现 💡
我们将实现分成两块来做:
- 配置项的表示
- 通过配置项生成表格的逻辑(具体实现)
配置项的实现
通常情况下 配置项是由一串 json 构成的,而 table 的每一列又肯定是通过遍历渲染出来的,即配置项大致长这样:
[
{ /* 第一列 */ },
{ /* 第二列 */ },
{ /* 第三列 */ },
{ /* ... */ },
]
了解了大概的 json 结构,我们就可以开始了。
列属性
对于列属性的表示,先怎么简单怎么来
只需提供 label和prop就可以了,像下面这样:
[
{
label: '编号',
prop: 'id'
},
{
label: '姓名',
prop: 'name'
},
{
label: '生日',
prop: 'birth'
},
{
label: '评级',
prop: 'rate'
}
//...
]
TODO: 我们不可能挨个去绑定,所以需要用
v-bind来做
那么我想添加其他的列属性怎么办?
为了便于今后的维护,可以将他们集中起来管理,
所以这里“削微”改下格式,都放到了 attrs 里:
[
{
attrs: {
label: '编号',
prop: 'id',
width: 100,
fixed: true
}
},
{
attrs: {
label: '姓名',
prop: 'name',
width: 150,
align: 'center'
}
},
{
attrs: {
label: '生日',
prop: 'birth',
align: 'center'
}
},
{
attrs: {
label: '评级',
prop: 'rate'
}
}
//...
]
render
假如说,接口数据里返回的生日字段是这种格式的:
2000-01-12
但我们想看到的是这种:
2000年01月12日
也就是说需要在展示的时候做一些数据处理。
我们希望通过一个 render 函数来表达这种需求:
[
{
attrs: {
label: '编号',
prop: 'id',
width: 100,
fixed: true
}
},
{
attrs: {
label: '姓名',
prop: 'name',
width: 150,
align: 'center'
}
},
{
attrs: {
label: '生日',
prop: 'birth',
align: 'center'
},
render (birth) {
// 为方便展示,这里假装引入了日期转换库 moment.js
return moment(birth).format('YYYY年MM月DD日')
}
},
{
attrs: {
label: '评级',
prop: 'rate'
}
}
//...
]
有了 render 函数,我们就可以随意的对 prop 数据做一些处理再进行展示了
renderComponent
render 只能展示简单的字符串,对于复杂的情况,如:文本框、下拉框,表单、甚至是一个响应用户操作的模块,render 是做不到的。
于是就有了 renderComponent 函数 ,顾名思义,我们可以把复杂的逻辑抽到组件里,再通过函数的返回值去渲染
函数的返回值为一个数组,支持渲染多个组件
[
{
attrs: {
label: '编号',
prop: 'id',
width: 100,
fixed: true
}
},
{
attrs: {
label: '姓名',
prop: 'name',
width: 150,
align: 'center'
}
},
{
attrs: {
label: '生日',
prop: 'birth',
align: 'center'
},
render (birth) {
// 为方便展示,这里假装引入了日期转换库 moment.js
return moment(birth).format('YYYY年MM月DD日')
}
},
{
attrs: {
label: '评级',
prop: 'rate'
},
renderComponent (rate) {
return [
{ name: 'el-rate', data: rate },
{ name: 'componentA', data: this.xxx }
]
}
}
//...
]
值得注意的是,renderComponent绑定 data 的方式为 v-model,也就意味着渲染的组件内部默认会用 value 接收 props
当 data 为非引用类型时的场景需要注意,详见这个 issues
renderHTML
那么最后一列的操作按钮该如何渲染呢,到这有人就会说了:renderComponent 不是可以渲染任何组件么,把那些按钮抽成组件再渲染出来不就可以了么。
其实这样做是没问题的,但是编写按钮是不是也得写一堆 <el-button> ,略微有点麻烦对吧?
于是乎我们又加了个针对这种场景的 render 方法: renderHTML,其返回值也是个数组
[
{
attrs: {
label: '编号',
prop: 'id',
width: 100,
fixed: true
}
},
{
attrs: {
label: '姓名',
prop: 'name',
width: 150,
align: 'center'
}
},
{
attrs: {
label: '生日',
prop: 'birth',
align: 'center'
},
render (birth) {
// 为方便展示,这里假装引入了日期转换库 moment.js
return moment(birth).format('YYYY年MM月DD日')
}
},
{
attrs: {
label: '评级',
prop: 'rate'
},
renderComponent (rate) {
return [
{ name: 'el-rate', data: rate },
{ name: 'componentA', data: this.xxx }
]
}
},
{
attrs: {
label: '操作'
},
renderHTML (row) {
return [
{
attrs: {
label: '查看',
type: 'text',
size: 'medium'
},
el: 'button',
click () {
this.$message(JSON.stringify(row))
}
},
{
attrs: {
label: '编辑',
type: 'text',
size: 'medium'
},
el: 'button',
click () {
this.$router.push({ path: `/xxxxxx/${row.id}/edit` })
}
},
// 对于文章最前面说的「场景四」的写法,可以用三目运算符来表示
!row.isForbid ? {
attrs: {
label: '禁用',
type: 'text',
size: 'medium'
},
el: 'button',
click () {
this.setForbid(row)
}
} : {
attrs: {
label: '解除禁用',
type: 'text',
size: 'medium',
style: {
color: '#e6a23c'
}
},
el: 'button',
click () {
this.setForbid(row)
}
}
]
}
}
//...
]
由于当初设计的原因,renderHTML 只满足了
el-button,后续会跟进需求变化再做进一步调整。
小结
至此,对于配置项的表示方法我们已了解,主要由一些 attrs 属性和三个函数 render, renderComponent 和 renderHTML 去表示 table 的渲染结构。
template 的实现
配置项有了,那么如何通过配置项生成表格呢?
整体概览
我们希望最终用户使用的时候大概是这个样子的:
<hoc-el-table
:source="sourceList.data"
:pagination="sourceList.pagination"
:config="config"
:loading="loading"
@getList="getList"
>
</hoc-el-table>
解释下这几个参数:
- source 数据源
- pagination 分页的参数
- config 配置项
- loading 加载动画
- getList 获取数据源的回调方法
所以 hoc-el-table 可以先这么实现:
<div class="hoc-el-table-container">
<el-table
:data="source"
v-loading="loading"
v-bind="$attrs"
>
</el-table>
<el-row
justify="end"
type="flex"
>
<el-pagination
layout="total, sizes, prev, pager, next ,jumper"
:current-page="getPagination.currentPage"
:page-size="getPagination.pageSize"
:total="getPagination.total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</el-row>
</div>
export default {
computed: {
getPagination () {
const params = {
currentPage: 1,
pageSize: 10,
total: 0
}
return Object.assign({}, params, this.pagination)
}
},
methods: {
handlePageChange (val) {
this.$emit('getList', { page: val })
},
handleSizeChange (val) {
this.$emit('getList', { pageSize: val })
}
}
}
配置项的使用
现在我们已经用了 source、pagination、loading 和 getList 这几个 props,还差 config 配置项没有用到。
之前提过 config 是一个数组,所以需要将每一列遍历出来,像下面这样:
<el-table-column
v-for="(item, index) in config"
:key="index"
>
<template v-slot="scope"></template>
</el-table-column>
而 el-table-column 上绑定的属性我们用 v-bind 来实现:
<el-table-column
v-for="(item, index) in config"
:key="index"
v-bind="getAttrsValue(item)"
>
<template v-slot="scope"></template>
</el-table-column>
getAttrsValue()
由于用了 v-slot 来自定义列的内容,所以这边需要先过滤掉 prop
export default {
methods: {
getAttrsValue (item) {
const { attrs } = item
const result = {
...attrs
}
delete result.prop
return result
}
}
}
render 的加载
还记得我们之前说的三个渲染函数么:
- render
- renderComponent
- renderHTML
这里要注意, 只有 render 函数返回值是个字符串,其他两个返回的都是个数组(需要遍历),所以伪代码可以这么写:
<el-table-column
v-for="(item, index) in config"
:key="index"
v-bind="getAttrsValue(item)"
>
<template v-slot="scope">
<div v-if="不是 render 方法">
<div v-if="renderComponent">
<!-- v-for 遍历返回值 -->
</div>
<div v-else-if="renderHTML">
<!-- v-for 遍历返回值 -->
</div>
</div>
<div
v-else
>{{ 直接返回 render 的值 }}</div>
</template>
</el-table-column>
组件的下沉
看起来刚刚的代码嵌套的 v-if 和 v-for 貌似有点多,稍微优化下:
用 <component/> 组件分发 renderComponent 和 renderHTML
为了降低耦合度,再分别创建用于针对这两个的下沉组件, 同样是伪代码:
<el-table-column
v-for="(item, index) in config"
:key="index"
v-bind="getAttrsValue(item)"
>
<template v-slot="scope">
<div v-if="不是 render 方法">
<component
:cellList="函数的返回值"
:is="判断来自 renderComponent 还是 renderHTML"
:row="scope.row"
:parent="getParent"
/>
</div>
<div
v-else
>{{ 直接返回 render 的值 }}</div>
</template>
</el-table-column>
renderComponent 对应的下沉组件:
<template>
<div>
<component
v-for="(item, index) in cellList"
:is="item.name"
:key="index"
:row="row"
v-model="item.data"
/>
</div>
</template>
renderHTML 对应的下沉组件:
说下这里为什么用到了渲染函数(此渲染函数彼渲染函数),其实刚开始的时候用的是 template 渲染的
直到有一次需求是让动态的绑定指令,那么普通的 template 目前是无法做到动态的绑定指令的(jsx除外),所以改成了渲染函数的方式
export default {
render: function (createElement) {
return createElement('div',
this.getCellList.map((cellItem) => {
return createElement(
this.elementsMapping[cellItem.el],
{
on: {
click: cellItem.click.bind(this.parent, this.row)
},
domProps: {
innerHTML: this.getAttrsValue(cellItem).props.label
},
...this.getAttrsValue(cellItem)
}
)
})
)
}
}
可以看看老的实现方式是这样的:<!-- 已废弃 --> <template> <div> <component v-for="(item, index) in getCellList" :key="index" :is="elementsMapping[item.el]" v-bind="getAttrsValue(item)" @click="item.click.call(parent, row)" >{{ item.attrs.label }}</component> </div> </template>
总结
至此,我们的实现基本上就已经完成了,可以满足现有的简单的业务逻辑。而之后的需求一定会越来越复杂,有许多地方值得我们去思考、探索,同时希望能够给阅读完这篇文章的你提供一些思路。
完整的代码可以看下方的仓库,欢迎 ✨ star ✨ ~~
📦 仓库地址:hoc-el-table
🎊 效果演示:Demo
难免有错误和疏漏的地方,还望不吝赐教。