在封装Vue组件时,我依旧会交叉使用函数式组件的方式来实现。关于函数式组件,我们可以把它想像成组件里的一个函数,入参是渲染上下文(render context),返回值是渲染好的HTML(VNode)。它比较适用于外层组件仅仅是对内层组件的一次逻辑封装,而渲染的模板结构变化扩展不多的情况,且它一定是无状态、无实例的,无状态就意味着它没有created、mounted、updated等Vue的生命周期函数,无实例就意味着它没有响应式数据data和this上下文。
我们先来一个简单的Vue函数式组件的例子吧,然后照着这个例子来详细介绍一下。
export default {
functional: true,
props: {},
render(createElement, context) {
return createElement('span', 'hello world')
}
}
Vue提供了一个functional开关,设置为true后,就可以让组件变为无状态、无实例的函数式组件。因为只是函数,所以渲染的开销相对来说较小。
函数式组件中的Render函数,提供了两个参数createElement和context,我们先来了解下第一个参数createElement。
createElement说白了就是用来创建虚拟DOM节点VNode的。它接收三个参数,第一个参数可以是DOM节点字符串,也可以是一个Vue组件,还可以是一个返回字符串或Vue组件的函数;第二个参数是一个对象,这个参数是可选的,定义了渲染组件所需的参数;第三个参数是子级虚拟节点,可以是一个由createElement函数创建的组件,也可以是一个普通的字符串如:'hello world',还可以是一个数组,当然也可以是一个返回字符串或Vue组件的函数。
createElement有几点需要注意:
createElement第一个参数若是组件,则第三个参数可省略,即使写上去也无效;
render函数在on事件中可监听组件$emit发出的事件
在2.3.0之前的版本中,如果一个函数式组件想要接收prop,则props选项是必须的。在2.3.0或以上的版本中,你可以省略props选项,组件上所有的attribute都会被自动隐式解析为prop。
函数式组件中Render的第二个参数是context上下文,data、props、slots、children以及parent都可以通过context来访问。
在2.5.0及以上版本中,如果你使用了单文件组件,那么基于模板的函数式组件可以这样声明:<template functional></template>
, 但是如果Vue组件中的render函数存在,则Vue构造函数不会从template选项或通过el选项指定的挂载元素中提取出的HTML模板编译渲染函数,也就是说一个组件中templete和render函数不能共存,如果一个组件中有了templete,即使有了render函数,render函数也不会执行,因为template选项的优先级比render选项的优先级高。
到这里,Vue函数式组件介绍的就差不多了,我们就来看看Element的表格组件是如何通过函数式组件来实现封装的吧。
效果图:
1、所封装的table组件:
<template>
<el-table :data="config.data" style="width: 100%" v-on="cfg.on" v-bind="attrs" v-loading="config.loading">
<el-table-column v-if="cfg.hasCheckbox" v-bind="selectionAttrs" type="selection" width="55" label="xx" />
<el-table-column v-for="n in cfg.headers" :prop="n.prop" :label="n.name" :key="n.prop" v-bind="{...columnAttrs, ...n.attrs}">
<template slot-scope="{row}">
<Cell :config="n" :data="row" />
</template>
</el-table-column>
</el-table>
</template>
<script>
import Cell from './cell'
export default {
components: {
Cell,
},
props: {
config: Object,
},
data(){
return {
columnAttrs: {
align: 'left',
resizable: false,
},
cfg: {
on: this.getTableEvents(),
attrs: {
border: true,
stripe: true,
},
...this.config,
},
checked: [],
}
},
computed: {
selectionAttrs(){
let {selectable, reserveSelection = false} = this.config || {}, obj = {};
// checkBox是否可以被选中
if(selectable && typeof selectable == 'function'){
Object.assign(obj, {
selectable,
})
}
//reserve-selection仅对type=selection的列有效,类型为Boolean,为true则会在数据更新之后保留之前选中的数据(需指定 row-key)
if(reserveSelection){
Object.assign(obj, {
'reserve-selection': reserveSelection,
})
}
return obj;
},
attrs(){
let {config: {spanMethod, rowKey}, cfg: {attrs}} = this;
// 合并单元格 - spanMethod是父组件传过来的合并单元格的方法,请参照element合并单元格
if(spanMethod && typeof spanMethod == 'function'){
Object.assign(attrs, {
'span-method': spanMethod,
})
}
// 表格跨页选中,需要设置row-key和reserve-selection,reserve-selection只能且必须设置在type为selection的el-table-column上
if(rowKey && typeof rowKey == 'function'){
Object.assign(attrs, {
'row-key': rowKey,
})
}
return attrs;
},
},
methods: {
getTableEvents(){
let {hasCheckbox = false} = this.config, events = {}, _this = this;
if(hasCheckbox){
// 绑定事件
Object.assign(events, {
'selection-change'(v){
_this.checked = v;
},
});
}
return events;
},
getChecked(){
return this.checked;
},
},
}
</script>
2、分页实现pagination.vue:
<template>
<div>
<Table :config="cfg" />
<div class="pagination" v-if="showPage">
<el-pagination
layout="prev, pager, next"
background
:total="page.total"
:page-size="page.pageSize"
:current-page="page.pageIndex"
@current-change="loadPage"
/>
</div>
</div>
</template>
<script>
import Table from './index'
export default {
components: {
Table,
},
props: {
config: Object,
},
data(){
return {
cfg: {
...this.config,
data: [],
loading: true,
},
page: {
pageSize: this.config.pageSize || 10,
pageIndex: 1,
total: 0,
},
}
},
created(){
this.load();
},
computed: {
showPage(){
let {pageSize, total} = this.page;
return pageSize < total;
},
},
methods: {
load(p = {}){
let {pageSize, pageIndex} = this.page, {loadData = () => Promise.resolve({})} = this.config || {};
this.cfg.loading = true;
// 这里loadData的参数在初始化时只有分页所需的pageIndex和pageSize,至于接口需要的其他参数,是在父组件的config的loadData中传递,这里不再接收其他参数
loadData({...p, pageIndex, pageIndex}).then(({data, total}) => {
this.cfg.data = data;
this.page.pageIndex = index;
this.page.total = total;
this.cfg.loading = false;
});
},
loadPage(index){
this.page.pageIndex = index
this.load();
},
// 一般在点击查询按钮或局部刷新表格列表时,可调用此方法,如果不传参数,则默认从第一页开始
reload(p = {}){
this.page.pageIndex = 1
this.load(p);
},
},
}
</script>
<style scoped>
.pagination{
margin-top: 16px;
text-align: right;
}
</style>
3、汇总表格每一列的cell.js:
import * as Components from './components';
let empty = '-'
export default {
props: {
config: Object,
data: Object,
},
functional: true,
render: (h, c) => {
let {props: {config = {}, data = {}}} = c, {prop, type = 'Default'} = config, value = data[prop] || config.value, isEmpty = value === '' || value === undefined;
return isEmpty ? h(Components.Default, {props: {value: empty}}) : h(Components[type], {props: {value, empty, data, ...config}});
}
}
4、不同于封装React AntD的table表格组件时将表格的每一列的渲染都集中在了一个table.js中,本次封装将每一列的渲染单独分开成多个vue组件,最后再合并在一个components.js文件中一起进行匹配。
1)整合文件components.js:
import Date from './Date';
import Default from './Default';
import Currency from './Currency';
import Enum from './Enum';
import Action from './Action';
import Link from './Link';
import Format from './Format';
import Popover from './Popover';
export {
Default,
Date,
Currency,
Enum,
Action,
Link,
Format,
Popover,
}
2)日期列Date.vue
<template functional>
<span>{{props.value | date(props.format)}}</span>
</template>
3)默认列Default.vue
<template functional>
<span>{{props.value}}</span>
</template>
4)金额千分位列Currency.vue
<template functional>
<span>{{props.value | currency}}</span>
</template>
5)映射列Enum.js
let mapIdAndKey = list => list.reduce((c, i) => ({...c, [i.key]: i}), {});
let STATUS = {
order: mapIdAndKey([
{
id: 'draft',
key: 'CREATED',
val: '未提交',
},
{
id: 'pending',
key: 'IN_APPROVAL',
val: '审批中',
},
{
id: 'reject',
key: 'REJECT',
val: '审批驳回',
},
{
id: 'refuse',
key: 'REFUSE',
val: '审批拒绝',
},
{
id: 'sign',
key: 'CONTRACT_IN_SIGN',
val: '合同签署中',
},
{
id: 'signDone',
key: 'CONTRACT_SIGNED',
val: '合同签署成功',
},
{
id: 'lendDone',
key: 'LENDED',
val: '放款成功',
},
{
id: 'lendReject',
key: 'LOAN_REJECT',
val: '放款驳回',
},
{
id: 'cancel',
key: 'CANCEL',
val: '取消成功',
},
{
id: 'inLend',
key: 'IN_LOAN',
val: '放款审批中',
},
]),
monitor: mapIdAndKey([
{
key: '00',
val: '未监控',
},
{
key: '01',
val: '监控中',
},
]),
}
export default {
functional: true,
render(h, {props: {value, Enum, empty}, parent}){
let enums = Object.assign({}, STATUS, parent.$store.getters.dictionary),
{name = '', getVal = (values, v) => values[v]} = Enum, _value = getVal(enums[name], value);
if( _value === undefined) return h('span', _value === undefined ? empty : _value);
let {id, val} = _value;
return h('span', {staticClass: id}, [h('span', val)]);
}
}
6)操作列Action.js
const getAcitons = (h, value, data) => {
let result = value.filter(n => {
let {filter = () => true} = n;
return filter.call(n, data);
});
return result.map(a => h('span', {class: 'btn', on: {click: () => a.click(data)}, key: a.prop}, a.label))
}
export default {
functional: true,
render: (h, {props: {value, data}}) => {
return h('div', {class: 'action'}, getAcitons(h, value, data))
},
}
7)带有可跳转链接的列Link.vue
<template>
<router-link :to="{ path, query: params }">{{value}}</router-link>
</template>
<script>
export default {
props: {
data: Object,
value: String,
query: {
type: Function,
default: () => {
return {
path: '',
payload: {}
}
}
},
},
computed: {
// 路由path
path(){
const { path } = this.query(this.data)
return path
},
params(){
const { payload } = this.query(this.data)
return payload
},
},
}
</script>
8)自定义想要展示的数据格式Format.vue
<template functional>
<div v-html="props.format(props.value)" />
</template>
9)当内容过多需要省略并在鼠标移入后弹出一个提示窗显示全部内容的列Popover.vue
<template functional>
<el-popover
placement="top-start"
width="300"
trigger="hover"
popper-class="popover"
:content="props.value">
<span slot="reference" class="popover-txt">{{props.value}}</span>
</el-popover>
</template>
<style scoped>
.popover-txt{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
display: block;
cursor: pointer;
}
</style>
从以上代码中可以看出,我既使用了基于render函数类型的函数式组件也使用了基于模板的函数式组件,主要是为了在封装时的方便,毕竟使用render这个最接近编译器的函数还是有点麻烦的,不如基于模板的函数式组件来的方便。
5、使用封装后的表格table组件:
<template>
<div style="margin: 20px;">
<el-button type="primary" v-if="excelExport" @click="download">获取勾选的表格数据</el-button>
<Table :config="config" ref="table" />
</div>
</template>
<script>
import Table from '@/components/pagination'
export default {
components: {
Table,
},
data() {
return {
config: {
headers: [
{prop: 'contractCode', name: '业务编号', attrs: {width: 200, align: 'center'}},
{prop: 'payeeAcctName', name: '收款账户名', type: 'Link', query: row => this.query(row), attrs: {width: 260, align: 'right'}},
{prop: 'tradeAmt', name: '付款金额', type: 'Currency'},
{prop: 'status', name: '操作状态', type: 'Enum', Enum: {name: 'order'}},
{prop: 'statistic', name: '预警统计', type: 'Format', format: val => this.format(val)}, //自定义展示自己想要的数据格式
{prop: 'reason', name: '原因', type: 'Popover'},
{prop: 'payTime', name: '付款时间', type: "Date", format: 'yyyy-MM-dd hh:mm:ss'}, //不设置format的话,日期格式默认为yyyy/MM/dd
{prop: 'monitorStatus', name: '当前监控状态', type: 'Enum', Enum: {name: 'monitor'}},
].concat(this.getActions()),
//通过接口获取列表数据 - 这里的参数p就是子组件传过来的包含分页的参数
loadData: p => request.post('permission/list', {...this.setParams(), ...p}),
hasCheckbox: true,
selectable: this.selectable,
reserveSelection: false,
rowKey: row => row.id,
},
status: "01",
permission: ["handle", "pass", "refuse", "reApply", 'export']
}
},
computed: {
handle() {
return this.permission.some(n => n == "handle");
},
pass() {
return this.permission.some(n => n == "pass");
},
reject() {
return this.permission.some(n => n == "reject");
},
refuse() {
return this.permission.some(n => n == "refuse");
},
excelExport(){
return this.permission.some(n => n == "handle") && this.permission.some(n => n == "export");
},
},
methods: {
getActions(){
return {prop: 'action', name: '操作', type: "Action", value: [
{label: "查看", click: data => {console.log(data)}},
{label: "办理", click: data => {}, filter: ({status}) => status == 'CREATED' && this.handle},
{label: "通过", click: data => {}, filter: ({status}) => status == 'PASS' && this.pass},
{label: "驳回", click: data => {}, filter: ({status}) => status == 'REJECT' && this.reject},
{label: "拒绝", click: data => {}, filter: ({status}) => status == 'CREATED' && this.refuse},
]}
},
setParams(){
return {
name: '测试',
status: '01',
type: 'CREATED',
}
},
query(row){
return {
path: '/otherElTable', // 路由path
payload: {
id: row.id,
type: 'link'
}
}
},
format(val){
let str = '';
val.forEach(t => {
str += '<span style="margin-right:5px;">' + t.total + '</span>';
})
return str;
},
selectable({status}){
return status == "REFUSE" ? false : true
},
download(){
console.log(this.$refs.table.getChecked())
},
},
};
</script>
<style>
.action span{margin-right:10px;color:#359C67;cursor: pointer;}
</style>
关于金额千分位和时间戳格式化的实现,这里就不再贴代码了,可自行实现。
------------------------------------- 2020年11月17日更新 -------------------------------------
最近又想了一下封装的这个table组件,想着说在原来封装的基础上还有没有其他的实现方法,比如我不想在原来定义的headers数组后边再concat一个关于操作列的数组,再比如表格的某一列的数据处理方法不包含在我们之前所封装的那些方法当中,或者说作为第一次使用这个table组件的前端开发人员,我不太习惯你的那种写法,那我可不可以在你封装的基础上自己写一些处理方法呢,答案是可以的,当然我们说既然已经封装好了组件,那么大家就按照一个套路来,省时又省力,何乐而不为呢?但有一说一,我们本着学习的态度,本着艺多不压身的出发点来看的话,多学多思考多动手,总归是有益于进步的。只是在实际的开发过程中,我们尽量要选择一种封装方式,然后大家一起遵守这个约定就好了。
其实说了这么多废话,这次变更也是没有多大力度的,只是在原来封装的基础上增加了插槽而已。看过本篇博客的你一定还记得我封装的代码中有一段专门用来处理每一列数据的代码吧:
<Cell :config="n" :data="row" />
对,就是它。对于它,我不想再多说了,上边已经做了介绍了。本次变更,我们主要用到的是插槽。
插槽这个API,VUE的官网和网上的各种文章介绍已经讲的很清楚了,它大概分为:默认插槽(也有人管它叫匿名插槽)、具名插槽和作用域插槽。关于它们的介绍,请自行查阅官网或网上的各种文章资料。本次变更主要用到的就是具名插槽和作用域插槽。其实实现起来很简单,就是在<Cell :config="n" :data="row" />
的外边再包一层具名插槽就可以了。
<slot :name="n.prop" :row="row"><Cell :config="n" :data="row" /></slot>
就酱。
接下来,我们就可以回答上边我们提出的那些问题了。来看答案:
<Table :config="config" ref="table">
<template #payTime="{row}">
{{row.payTime | date('yyyy-MM-dd hh:mm:ss')}}
</template>
<template #customize="{row}">
{{customize(row.customize)}}
</template>
<template #opt="{row}">
<div class="action">
<span>查看</span>
<span v-if="row.status == 'CREATED' && handle">办理</span>
<span v-if="row.status == 'PASS' && pass">通过</span>
<span v-if="row.status == 'REJECT' && reject">驳回</span>
<span v-if="row.status == 'REFUSE' && refuse">拒绝</span>
</div>
</template>
</Table>
以上就是对某些特殊情况,而你又不想使用我最开始封装的那些方法来实现,那么可以,我就再为你提供一个其他的“特殊服务”。这里要注意,如果你使用插槽来自己渲染数据,那么在headers数组中,你需要提供表格头部的渲染,而不需要再加入type字段即可。
比如最开始渲染表格的日期列时我们是这么写的{prop: 'payTime', name: '付款时间', type: "Date", format: 'yyyy-MM-dd hh:mm:ss'}
那么如果你使用插槽来自己渲染数据,这里的写法就要变成了这样{prop: 'payTime', name: '付款时间'}
还有之前我们定义操作列是在headers数组的后边再concat了一个数组,如果你使用插槽来自己渲染数据,那么就不需要再concat一个数组了,而是在headers数组中再加一个{prop: 'opt', name: '操作'}
就可以了。
其实,这次变更说的是在原来的基础上重新包装了一层插槽,那么对于那些不需要我们自行处理数据,只需直接展示接口返回的数据的情况,我们在使用这个封装的table组件时也不需要进行什么特殊处理,更不需要像上边使用插槽那样去定义,只要还是跟之前一样在headers数组中正常定义就可以了。因为插槽嘛,你不定义具名插槽,也不定义默认插槽,那么插槽中显示的就是包裹在插槽标签slot中的<Cell :config="n" :data="row" />
明白了吧。
多说一句,你说我不想使用插槽去处理日期、金额千分位这些列,那么你依旧可以根据上边我介绍的插槽的原理,在headers数组中依旧这样定义就OK了:
{prop: 'tradeAmt', name: '付款金额', type: 'Currency'},
{prop: 'payTime', name: '付款时间', type: "Date"},
写到这里,其实我想说,即使加上了插槽,那么对之前的那些使用方法来说,基本没啥影响,你该怎么用还怎么用,我只是给你提供了更多的选择而已。