之前用过antd.pro
框架来实现前端开发,不管是v4
还是v5
都封装的不错,比如今天要说到的表格。
在日常开发中,少不了增删改查,有了这些东西,就不得出现三个必要的组件
- 表格组件
- 查询组件
- 分页组件
而,antd.pro
怎封装了一套表格组件,包含上面三个组件。至于为什么不直接把antd.pro
中的表格组件直接拿来用呢?
在前段时间研究微前端
的时候,发现antd.pro
对微前端
的支持并不友好,为了以后方便管理前端项目,微前端
是迟早要用的。antd.pro
对微前端
支持不友好的原因是:微前端在管理子模块的时候,需要子模块在主入口文件中对外(微前端)暴露自己的生命周期函数,这样微前端框架才能获取到当前的应用生命周期
。
而antd.pro
的缺点就是封装的太彻底,以至于我们在使用的时候不需要关心他的主入口文件
,它会动态生成主入口文件
。这就导致我们无法找到应用的入口文件,也就无法对外暴露生命周期函数。
基于此,不得不更换为其他的框架。由于我们要做的项目跟权限管理有关,所以就采用了react-admin
。而react-admin
也封装了很多优秀的组件,但时预留了webpack
和主入口文件
的配置,所以以后往微前端
上集成的时候也比较方便。
然而,有一些通用的组件还是没有做好封装,比如对表格的分装的就不够好。那么在所有的页面上都会有大量重复性的表格出现,而且表格跟按钮组的联动也会重复出现在很多模块中,这就导致代码看起来非常的low。
- 查询表单
- button按钮组
- 表格
- 分页
这四部分如果你不进行封装,就可以想到每个模块都会出现大量重复性的代码。作为一个有洁癖
的程序员,奥不,应该说程序员都有洁癖。对于这样的代码是不能容忍的。
所以就着手做一下封装。
Q:什么叫组件封装呢?
A:其实封装跟抽象意思很相近,将有相同属性的事物统一封装到一个对象中或者组件中,以便在受益于大众。
Q:组件的思想是什么呢?
A:组件的核心应该是通用,而不是定制。
Q:什么是通用?什么是定制?有什么不一样么?
A:通用就是所有的人都能用,定制就是私人化。我们简单的抽象出一个人
组件,每个人都有鼻子有眼,能哭,能跑,能笑等,这些应该是个人都会有的。但拿一件事来说,你比如跑这个动作,既然你作为人组件的一个方法,那么针对不同的人你需要进行不同的操作,比如有的人跑得快,有的人跑的不快,有的人出国车祸截肢了,他不会跑。这些情况你都需要考虑到,但是你需要讲这些都在组件中进行处理么?
很显然不是!!!!
我们需要对外暴露组件的属性,然而通用组件的封装应该是有共性的,共性就是所有调用它的外部组件都有的属性,而不是针对每个组件去单独处理其特殊的地方。我们需要做的就是暴露出去共性,至于特性,那么交给调用组件者自己去处理这个问题。
起初我在做组件封装的时候一直困在table
和button按钮组
之间的联动问题上。因为不同的模块,按钮组是不同的,比如用户模块,他有增删改查禁,而日志模块只有查看和删除。而不同的按钮有不同的处理逻辑。
- 比如说删除按钮的逻辑应该是表单是否勾选,更复杂的可能是选中的数据是否被其他模块引用
- 比如启用/禁用按钮,只有当选中的数据是启用状态时才能禁用,禁用的数据也只能启用
- …
为了方便table和按钮组的联动问题,我一开始是将按钮组放到组件中,将所有的按钮种类罗列了一遍,然后根据父组件传递的权限爱控制按钮的显示隐藏。功能也能实现,但是看起来额外的别扭,因为不同的按钮有不同的处理逻辑,这样的话所有的处理逻辑都会在子组件中进行处理,那么这就成了我们前面所说的定制/特有
而不是通用
。所有想了再三,最后还是将button按钮组的定义放到了外面。
思考下,如果我们需要封装一个组件,那么我们需要怎么做?
我们封装一个表格组件,那么组件中包含:
- 能够生成搜索表单的属性
- 能够生成表格的属性
- 能够生成按钮组的属性
- 能够生成分页的属性
- 能够回调的函数
有了上面的思考,我们就有了想法。我们首先看一下表格,因为所有的操作都是依赖于表格的,不管是查询还是分页,不管是增删改,都离不开表格,所以只要我们需要把表格组件的属性搞明白,那么其他的部分就自然而然的小意思了。
由于我们还是基于react+antd
,所以最基本的还是要基于antd的table组件
。
一个table
的组件应当包含最基本的两部分:
- 数据源DataSource
- 列columns
数据源是通过接口向后台请求来的,column是根据后台返回数据的属性定义的。所以我们就需要定义这两个部分
const columns = [
{ title: '部门名称', dataIndex: 'name', type: 'input' },
{
title: '上级部门', dataIndex: 'parent_name', type: 'select-tree',
options: departments, style: { width: "250px" },
render: (value) => {
return value ? <div styleName="tags-style">{value}</div> : <Tag color="#f50">无</Tag>
}
},
{
title: '创建时间', dataIndex: 'createtime', type: 'date-range',
render: (value) => {
return dateFormat(value * 1000)
}
},
{
title: '修改时间', dataIndex: 'modifytime', type: 'date-range',
render: (value) => {
return dateFormat(value * 1000)
}
},
{
title: '状态', dataIndex: 'status', type: "radio-group",
options: [{ label: '启用', value: 'enabled' }, { label: '禁用', value: 'disabled' }],
render: (value) => {
if (value === "enabled") {
return <Tag color="#108ee9">启用</Tag>
} else {
return <Tag color="#f50">禁用</Tag>
}
}
},
{
title: '操作', dataIndex: 'operator',
render: (value, record) => {
const { id, name } = record;
const items = []
if (operations.detail) {
items.push({
label: '查看',
icon: 'eye',
onClick: (e) => {
e.stopPropagation();
this.setState({ visible: true, id: record.id, record, type: 'detail', drawerTitle: '部门详情' });
},
})
}
if (operations.modify) {
items.push({
label: '修改',
icon: 'form',
onClick: (e) => {
e.stopPropagation();
this.setState({ visible: true, id, record, type: 'edit', drawerTitle: '修改部门' });
},
})
}
if (operations.delete) {
items.push({
label: '删除',
color: 'red',
icon: 'delete',
confirm: {
title: `您确定删除"${name}"?`,
onConfirm: (e) => {
e.stopPropagation();
this.handleDelete(id);
},
},
})
}
return <Operator items={items} />;
},
},
];
那么一个表格的雏形应该是这样的
父组件:
<DynaroseTable
loading={loading}
columns={columns}
dataSource={dataSource}
total={total}
/>
子组件:
<Table
loading={loading}
className="components-table-demo-nested"
rowClassName={record => {
if (record.id === selectedRoleId) return 'role-table selected';
return 'role-table';
}}
rowSelection={{
selectedRowKeys,
onChange: (selectedRowKeys, selected) => {
setSelectedRowKeys(selectedRowKeys)
onSelected(selected)
}
}}
columns={columns}
dataSource={dataSource}
rowKey="id"
childrenColumnName="none"
/>
看起来table
组件的封装是很简单的。
由于react-admin
对搜索表单已经做了一层封装,但是不能为我们通用,需要再额外改造一下。
怎么改造呢? 上面我们定义了表格的组件,那么我们可以直接把table
组件中的columns
来过来使用。搜索表单无非是一个个的form.item
,我们可以遍历columns
动态生成搜索表单。
先看一下react-admin
是怎么包裹的formitem
:
<FormElement
{...formProps}
label={item.title}
name={item.dataIndex}
type={item.type}
options={item.options}
style={item.style}
/>
- formProps:表单的基本设置,比如长度设置
- label:列名
- name:实际搜索时的值
- type:类型,可以是input,select,select-tree,date-range…
- options:如果你是select类型的那么option就是select的options,如果是
select-tree
那么就是treedata
- style:样式
知道他们封装的form.item
,就可以对我们的columns
进行改造,下面是一个完整的列定义:
{
title: '上级部门', dataIndex: 'parent_name', type: 'select-tree',
options: departments, style: { width: "250px" },
render: (value) => {
return value ? <div styleName="tags-style">{value}</div> : <Tag color="#f50">无</Tag>
}
},
然后我们只需要在搜索表单中对columns
进行遍历生成表单项。
<QueryBar styleName={operations.list ? 'is-show' : 'is-hidden'}>
<Form onFinish={submitQuery} form={form} styleName={showQuery ? 'is-show' : 'is-hidden'}>
<FormRow>
{
queryCloumns && queryCloumns.map((item) => {
// 从列中扣除操作的部分,不作为查询条件
if (item.dataIndex === "operator") {
return ""
}
// 由于列中显示的是parent_name,但实际是需要根据parent_id查询,所以需要转换一下
if (item.dataIndex === "parent_name") {
item.dataIndex = "parent_id"
}
// 处理角色列表中的所属APP,列表中显示的是app,但是在查询的时候,我们需要根据app_id查询
if (item.dataIndex === "app") {
item.dataIndex = "app_id"
}
if (item.dataIndex === "errorMessage") {
return ""
}
return <FormElement
{...formProps}
label={item.title}
name={item.dataIndex}
type={item.type}
options={item.options}
style={item.style}
/>
})
}
<FormElement layout>
{ }} icon={<SearchOutlined />} />
form.resetFields()} icon={<RedoOutlined />} />
FormElement>
FormRow>
Form>
<div styleName={showQuery ? 'is-hidden' : 'is-show no-query'}>
该功能暂不支持查询。
div>
QueryBar>
这样的话就可以根据columns
生成搜索表单。需要注意的是:由于我们的列集合中包含了操作列,所以我们需要在遍历的时候将该列删除,要不然就会在搜索表单中出现操作项,这显然是与预期不符合的
。
还有就是,因为有的关联项会存id
不是name
,比如用户的属性中有所属部门的id
,那么我们在列表中显示的时候肯定不能直接显示id
,需要添加额外的属性去显示id
所对应的部门名称。但是在实际查询的时候又不会根据name
去查,所以需要将name
换成id
。
分页组件的封装还是很简单的,因为他依赖于表格,只要是表格有的数据,分页组件就可以直接拿来用。
const { columns, operations, headerTitle, dataSource, total, onClick, showPage, showQuery, loading, onSelected, tableBar } = props;
<Pagination
total={total}
current={current}
pagesize={pagesize}
onPageNumChange={current => {
setCurrent(current)
onClick({ values, current, pagesize })
}}
onPageSizeChange={pagesize => {
setCurrent(1)
setPagesize(pagesize)
onClick({ values, current, pagesize })
}}
/>
因为所有的数据都是父组件通过请求获取到的,所以当我们切换分页的时候需要回调父组件的callback函数去触发父组件的请求事件,然后刷新数据,所以这里需要顶一个onClick
,那显而易见需要回调父组件的方法,那必须要传进来,所以我们需要在父组件调用子组件的时候传递一个onClick
事件,类似这样:
<DynaroseTable
loading={loading}
columns={columns}
dataSource={dataSource}
total={total}
onClick={(arg) => this.clickEvent(arg)}
/>
前年也讲到为什么要封装按钮组以及按钮组如何封装的问题。目前两种方案:
- 在子组件中处理按钮组
- 在父组件中处理按钮组
第一种方式的有点就是,和表格能够更好地联动,不需要往父组件透传按钮相关的操作。缺点就是你需要预设所有的按钮种类,那么在以后出现其他功能的时候,你需要手动去添加新的按钮类型,不易与扩展。
第二种应该是说是完全符合我们的封装思想的。我子组件中不负责处理按钮组的任何逻辑,将按钮组所需要的数据通过callback的方式回传给父组件,由父组件去控制自己的按钮组。
第一种就是相当恶心了,说白了就是定义所有的按钮,然后父组件传递那些组件可见的标志operations
,根据标志来显示隐藏那些按钮.
<Tooltip title="添加" placement="bottom" type="primary" styleName={operations.create ? "is-show" : "is-hidden"}>
第二中的实现方式,我们先看一下,第二中就是父组件把你自己的按钮组传给子组件,子组件对按钮组进行遍历生成按钮组。这样子组件就不用关心你组件的实际状态,只负责显示就可以了,在所有模块都可以通用。以后即便移植到其他项目中,也可以直接copy使用,而不需要做修改。
{
map(tableBar, item => {
return <span styleName="button-margin">{item}</span>
})
}
所有父组件调用子组件的时候,还需要传递一个tableBar
属性:
<DynaroseTable
loading={loading}
columns={columns}
headerTitle="部门列表"
dataSource={dataSource}
operations={operations}
total={total}
showPage={true}
showQuery={true}
onClick={(arg) => this.clickEvent(arg)}
onSelected={this.onSelected}
tableBar={
[
<Tooltip title="添加" placement="bottom" type="primary" styleName={operations.create ? "is-show" : "is-hidden"}>
<Button type="primary" onClick={() => this.setState({ visible: true, id: null, type: 'edit', drawerTitle: "添加部门" })}><PlusOutlined /></Button>
</Tooltip>,
<Tooltip title="删除" placement="bottom" styleName={operations.delete ? "is-show" : "is-hidden"}>
<Button danger disabled={isSelected} onClick={() => this.onDelete()}><DeleteOutlined /></Button>
</Tooltip>,
<Tooltip title="启用" placement="bottom" type="primary" styleName={operations.status ? "is-show" : "is-hidden"}>
<Button type="primary" disabled={isSelected || isEnabled} onClick={() => this.onStatus('enabled')}><CheckCircleOutlined /></Button>
</Tooltip>,
<Tooltip title="禁用" placement="bottom" type="primary" styleName={operations.status ? "is-show" : "is-hidden"}>
<Button type="primary" disabled={isSelected || isDisabled} onClick={() => this.onStatus('disabled')}><StopOutlined /></Button>
</Tooltip>,
<Tooltip title="刷新" placement="bottom" type="primary" styleName={operations.list ? "is-show" : "is-hidden"}>
<Button type="primary" onClick={() => this.getData()} ><SyncOutlined /></Button>
</Tooltip>
]
}
/>
组件既然这样封装,那么按钮组要想跟子组件中的表格联动就需要子组件将数据透传给父组件,所以我们需要定义一个callback函数来接受子组件的数据,就是上面表格中的onSelected
,当触发表格的选中事件时,将选中的数据回传给父组件
子组件
<Table
loading={loading}
className="components-table-demo-nested"
rowClassName={record => {
if (record.id === selectedRoleId) return 'role-table selected';
return 'role-table';
}}
rowSelection={{
selectedRowKeys,
onChange: (selectedRowKeys, selected) => {
setSelectedRowKeys(selectedRowKeys)
onSelected(selected)
}
}}
columns={columns}
dataSource={dataSource}
rowKey="id"
childrenColumnName="none"
/>
父组件
/**
* 用于将选择的数据项返回,用于在本功能下校验按钮
* @param {*} arg callback回传的参数
* selectedRows
*/
onSelected = (selectedRows) => {
// 是否选择条目
const isSelected = !selectedRows?.length
// 处理enabled和disabled
const isEnabled = find(selectedRows, ["status", "enabled"]) ? true : false;
const isDisabled = find(selectedRows, ["status", "disabled"]) ? true : false;
const selectedRowKeys = map(selectedRows, "id")
this.setState({ isSelected, isEnabled, isDisabled, selectedRows, selectedRowKeys })
}
这样获取到数据就可以根据数据对按钮组进行处理了,比如是否disabled
等。
一个完整的表格组件应该是这样的:
父组件:
<DynaroseTable
loading={loading}
columns={columns}
headerTitle="部门列表"
dataSource={dataSource}
operations={operations}
total={total}
showPage={true}
showQuery={true}
onClick={(arg) => this.clickEvent(arg)}
onSelected={this.onSelected}
tableBar={
[
<Tooltip title="添加" placement="bottom" type="primary" styleName={operations.create ? "is-show" : "is-hidden"}>
<Button type="primary" onClick={() => this.setState({ visible: true, id: null, type: 'edit', drawerTitle: "添加部门" })}><PlusOutlined /></Button>
</Tooltip>,
<Tooltip title="删除" placement="bottom" styleName={operations.delete ? "is-show" : "is-hidden"}>
<Button danger disabled={isSelected} onClick={() => this.onDelete()}><DeleteOutlined /></Button>
</Tooltip>,
<Tooltip title="启用" placement="bottom" type="primary" styleName={operations.status ? "is-show" : "is-hidden"}>
<Button type="primary" disabled={isSelected || isEnabled} onClick={() => this.onStatus('enabled')}><CheckCircleOutlined /></Button>
</Tooltip>,
<Tooltip title="禁用" placement="bottom" type="primary" styleName={operations.status ? "is-show" : "is-hidden"}>
<Button type="primary" disabled={isSelected || isDisabled} onClick={() => this.onStatus('disabled')}><StopOutlined /></Button>
</Tooltip>,
<Tooltip title="刷新" placement="bottom" type="primary" styleName={operations.list ? "is-show" : "is-hidden"}>
<Button type="primary" onClick={() => this.getData()} ><SyncOutlined /></Button>
</Tooltip>
]
}
/>
- showPage:是否显示分页
- showQuery:是否显示查询
因为在有的模块中,坑你不需要分页,不需要查询,所以做了一下扩展。
子组件完整代码:
import React, { useEffect, useState } from 'react';
import { Form, Row, Col } from 'antd'
import {
SearchOutlined,
RedoOutlined,
} from '@ant-design/icons'
import { cloneDeep, map } from 'lodash'
import { dateToLinux } from 'src/utils/date-to-linux';
import ButtonTips from './buttontips'
import {
QueryBar,
FormRow,
FormElement,
Table,
Pagination
} from 'src/library/components';
import './style.less'
/**
* 封装的表格,其中包含搜索框
* 属性说明
* columns: 表格的列
* operations: 用户在该路由的操作权限结合[create:true,modify:false.....]
* headerTitle: 表头信息
* dataSource: 表格渲染数据
* total: 分页的总条数,由于存在分页操作,所以total不能直接从dataSource中获取,必须从list请求返回的total中获取,再传递过来
* onClick: 组件内的所有事件操作
* loading: 页面请求的loading操作
* showPage: 是否显示分页
* showQuery: 是否显示条件查询
* tableBar: 表格上的工具栏
* onSelected: callback回调,将选择的数据回传给父组件,父组件根据选择的数据进行按钮的可控操作
*/
export default function DynaroseTable(props) {
const { columns, operations, headerTitle, dataSource, total, onClick, showPage, showQuery, loading, onSelected, tableBar } = props;
const [current, setCurrent] = useState(1)
const [pagesize, setPagesize] = useState(20)
const [values, setValues] = useState({})
const [selectedRowKeys, setSelectedRowKeys] = useState([])
const [selectedRoleId] = useState([])
const [form] = Form.useForm();
const formProps = {
form,
width: "auto",
style: { paddingLeft: 16 },
};
// 该clone的列用于处理查询条件
const queryCloumns = cloneDeep(columns)
// 监听props中数据变化,当表格数据发生变化时,调用该生命周期,清空选项值
useEffect(() => {
clearValue()
}, [props.dataSource])
// 提交表单查询
const submitQuery = async (values) => {
if (form) {
values = await form.validateFields();
if (values.createtime) {
values.createtime_start = dateToLinux(values.createtime[0]._d)
values.createtime_end = dateToLinux(values.createtime[1]._d)
delete values.createtime
}
if (values.modifytime) {
values.modifytime_start = dateToLinux(values.modifytime[0]._d)
values.modifytime_end = dateToLinux(values.modifytime[1]._d)
delete values.modifytime
}
}
// 查询时设置默认值
setCurrent(1)
setValues(values)
clearValue()
props.onClick({ values, current, pagesize })
}
// 列表刷新,清空选中值
const clearValue = () => {
setSelectedRowKeys([])
}
return (
<div styleName="root">
<QueryBar styleName={operations.list ? 'is-show' : 'is-hidden'}>
<Form onFinish={submitQuery} form={form} styleName={showQuery ? 'is-show' : 'is-hidden'}>
<FormRow>
{
queryCloumns && queryCloumns.map((item) => {
// 从列中扣除操作的部分,不作为查询条件
if (item.dataIndex === "operator") {
return ""
}
// 由于列中显示的是parent_name,但实际是需要根据parent_id查询,所以需要转换一下
if (item.dataIndex === "parent_name") {
item.dataIndex = "parent_id"
}
// 处理角色列表中的所属APP,列表中显示的是app,但是在查询的时候,我们需要根据app_id查询
if (item.dataIndex === "app") {
item.dataIndex = "app_id"
}
if (item.dataIndex === "errorMessage") {
return ""
}
return <FormElement
{...formProps}
label={item.title}
name={item.dataIndex}
type={item.type}
options={item.options}
style={item.style}
/>
})
}
<FormElement layout>
<ButtonTips dshow={true} title="查询" type="primary" htmlType="submit" onClick={() => { }} icon={<SearchOutlined />} />
<ButtonTips dshow={true} title="重置" type="primary" onClick={() => form.resetFields()} icon={<RedoOutlined />} />
</FormElement>
</FormRow>
</Form>
<div styleName={showQuery ? 'is-hidden' : 'is-show no-query'}>
该功能暂不支持查询。
</div>
</QueryBar>
<Row styleName="button_group_wapper">
<Col span={4} styleName="title">
{headerTitle}
</Col>
<Col span={20} styleName="buttons-content">
{
map(tableBar, item => {
return <span styleName="button-margin">{item}</span>
})
}
</Col>
</Row>
<Table
loading={loading}
className="components-table-demo-nested"
rowClassName={record => {
if (record.id === selectedRoleId) return 'role-table selected';
return 'role-table';
}}
rowSelection={{
selectedRowKeys,
onChange: (selectedRowKeys, selected) => {
setSelectedRowKeys(selectedRowKeys)
onSelected(selected)
}
}}
columns={columns}
dataSource={dataSource}
rowKey="id"
childrenColumnName="none"
/>
<div styleName={showPage ? "is-show" : "is-hidden"}>
<Pagination
total={total}
current={current}
pagesize={pagesize}
onPageNumChange={current => {
setCurrent(current)
onClick({ values, current, pagesize })
}}
onPageSizeChange={pagesize => {
setCurrent(1)
setPagesize(pagesize)
onClick({ values, current, pagesize })
}}
/>
</div>
</div>
);
}