内容摘要:
- 需求分析
- 定义 interface
- 定义 json 文件
- 定义列表控件的 props
- 基于 el-table 封装,实现依赖 json 渲染
- 实现内置功能:选择行(单选、多选),格式化、锁定等。
- 使用 slot 实现自定义扩展
- 做个工具维护 json 文件(下篇介绍)
管理后台里面,列表是一个常用的功能,UI库提供了列表组件和分页组件实现功能。虽然功能强大,也很灵活,只是还不能称为低代码,不过没关系,我们可以写点代码让UI库变为摸鱼神器!
本篇介绍列表的设计思路和封装方式。
需求分析
如果基于原生HTML来实现显示数据列表的功能的话,那么需考虑如何创建 table,如何设置css等。
如果直接使用UI库的话,那么可以简单很多,只需要设置各种属性,然后绑定数据即可。
以 el-table 为例:
<el-table :data="tableData" border stripe style="width: 100%" > <el-table-column prop="date" label="Date" width="180" /> <el-table-column prop="name" label="Name" width="180" /> <el-table-column prop="address" label="Address" /> </el-table>
设置好属性、记录集合,然后设置列(el-table-column)即可。
这样一个列表就搞定了,再加上 el-pagination 分页组件,编写一些代码即可实现分页的功能。
如果只是一个列表的话,这种方式没啥问题,但是管理后台项目,往往需要n个列表,而每个列表都大同小异,如果要一个一个手撸出来,那就有点麻烦了。
那么如何解决呢?我们可以参考低代码,基于 el-talbe 封装一个列表控件,
实现依赖 json 动态渲染列表,同时支持自定义扩展。
定义 interface
最近开始学习 Typescript,发现了一个现象,如果可以先定义好类型,那么代码就可以更清晰的展现出来。
另外 Vue3 的最新文档,也采用了通过 interface 来介绍API功能的方式,所以我们也可以借鉴一下。
依据 el-table 的属性,定义列表控件属性的 interface。
Vue3 的 props 有一套约束方式,这个似乎和TS的方式有点冲突,没想出了更好的方法(option API 和 script setup两种定义props的方式,都有不足 ),所以只好做两个 interface,一个用于定义组件的 props ,一个用于取值。
- IGridPropsComp:定义组件的 props
/** * 列表控件的属性的描述,基于el-table */ export interface IGridPropsComp { /** * 模块ID,number | string */ moduleId: IPropsValidation, /** * 主键字段的名称 String,对应 row-key */ idName: IPropsValidation, /** * table的高度, Number */ height: IPropsValidation, /** * 列(字段)显示的顺序 Array<number|string> */ colOrder: IPropsValidation, /** * 斑马纹,Boolean */ stripe: IPropsValidation, /** * 纵向边框,Boolean */ border: IPropsValidation, /** * 列的宽度是否自撑开,Boolean */ fit: IPropsValidation, /** * 要高亮当前行,Boolean */ highlightCurrentRow: IPropsValidation, /** * 锁定的列数 Number,设置到 el-table-column 的 fixed */ fixedIndex: IPropsValidation, /** * table的列的 IGridItem * * id: number | string, * * colName: string, 字段名称 * * label: string, 列的标签、标题 * * width: number, 列的宽度 * * align: EAlign, 内容对齐方式 * * headerAlign: EAlign 列标题对齐方式 */ itemMeta: IPropsValidation, // /** * 记录选择的行:IGridSelection * * dataId: '', 单选ID number 、string * * row: {}, 单选的数据对象 {} * * dataIds: [], 多选ID [] * * rows: [] 多选的数据对象 [] */ selection: IPropsValidation, /** * 绑定的数据 Array, 对应 data */ dataList: IPropsValidation // 其他扩展属性 [propName: string]: IPropsValidation }
- moduleId:模块ID,一个模块菜单只能有一个列表,菜单可以嵌套。
- itemMeta:列的属性集合,记录列表的列的属性。
- selection:记录列表的单选、多选的 row。
- dataList:显示的数据,对应 el-table 的 data
- 其他:对应 el-table 的属性
IGridPropsComp 的作用是,约束列表控件需要设置哪些属性,属性的具体类型,就无法在这里约束了。
- IPropsValidation (不知道vue内部有没有这样的 interface)
/** * vue 的 props 的验证的类型约束 */ export interface IPropsValidation { /** * 属性的类型,比较灵活,可以是 String、Number 等,也可以是数组、class等 */ type: Array<any> | any, /** * 是否必须传递属性 */ required?: boolean, /** * 自定义类型校验函数(箭头函数),value:属性值 */ validator?: (value: any) => boolean, /** * 默认值,可以是值,也可以是函数(箭头函数) */ default?: any }
取 props 用的 interface
IGridPropsComp 无法约束属性的具体类型,所以只好再做一个 interface。
- IGridProps
/** * 列表控件的属性的类型,基于el-table */ export interface IGridProps { /** * 模块ID,number | string */ moduleId: number | string, /** * 主键字段的名称 String,对应 row-key */ idName: String, /** * table的高度, Number */ height: number, /** * 列(字段)显示的顺序 Array<number|string> */ colOrder: Array<number|string>, /** * 斑马纹,Boolean */ stripe: boolean, /** * 纵向边框,Boolean */ border: boolean, /** * 列的宽度是否自撑开,Boolean */ fit: boolean, /** * 要高亮当前行,Boolean */ highlightCurrentRow: boolean, /** * 锁定的列数 Number,设置到 el-table-column 的 fixed */ fixedIndex: number, /** * table的列的 Object< IGridItem > * * id: number | string, * * colName: string, 字段名称 * * label: string, 列的标签、标题 * * width: number, 列的宽度 * * align: EAlign, 内容对齐方式 * * headerAlign: EAlign 列标题对齐方式 */ itemMeta: { [key:string | number]: IGridItem }, // /** * 选择行的情况:IGridSelection * * dataId: '', 单选ID number 、string * * row: {}, 单选的数据对象 {} * * dataIds: [], 多选ID [] * * rows: [] 多选的数据对象 [] */ selection: IGridSelection, /** * 绑定的数据 Array, 对应 data */ dataList: Array<any> // 其他扩展属性 [propName: string]: any }
对比一下就会发现,属性的类型不一样。因为定义 props 需要使用一套特定的对象格式,而使用 props 的时候需要的是属性自己的类型。
理想情况下,应该可以在 script setup 里面,引入外部文件 定义的 interface ,然后设置给组件的 props,但是到目前为止还不支持,只能在( script setup方式的)组件内部定义 props。希望早日支持,支持了就不会这么纠结和痛苦了。
依据 el-table-column 定义列属性的 interface。
- IGridItem:列表里面列的属性
/** * 列的属性,基于 el-table-column */ export interface IGridItem { /** * 字段ID、列ID */ id: number | string, /** * 字段名称 */ colName: string, /** * 列的标签、标题 */ label: string, /** * 列的宽度 */ width: number, /** * 内容对齐方式 EAlign */ align: EAlign, /** * 列标题对齐方式 */ headerAlign: EAlign, // 其他扩展属性 [propName: string]: any }
还是需要扩展属性的,因为这里只是列出来目前需要的属性,el-table-column 的其他属性、方法还有很多,而且以后也可能会新增。
这个属性不是直接设置给组件的 props,所以不用定义两套了。
对齐方式的枚举
枚举可以理解为常量,定义之后可以避免低级错误,避免手滑。
- EAlign
export const enum EAlign { left = 'left', center = 'center', right = 'right' }
选择记录的 interface。
列表可以单选也可以多选,el-table 在默认情况下似乎是二选一,觉得有点不方便,为啥不能都要?
- 单选:鼠标单一任意一行就是单选;(清空其他已选项)
- 多选:单击第一列的(多个)复选框,就是多选;
这样用户就可以愉快的想单选就单选,想多选就多选了。
- IGridSelection
/** * 列表里选择的数据 */ export interface IGridSelection { /** * 单选ID number 、string */ dataId: number | string, /** * 单选的数据对象 {} */ row: any, /** * 多选ID [] */ dataIds: Array<number | string>, /** * 多选的数据对象 [] */ rows: Array<any> }
其实我觉得只记录ID即可,不过既然 el-talble 提供的 row,那么还是都记录下来吧。
定义 json 文件
接口定义好之后,我们可以依据 interface 编写 json 文件:
{ "moduleId": 142, "height": 400, "idName": "ID", "colOrder": [ 90, 100, 101 ], "stripe": true, "border": true, "fit": true, "highlightCurrentRow": true, "highlight-current-row": true, "itemMeta": { "90": { "id": 90, "colName": "kind", "label": "分类", "width": 140, "title": "分类", "align": "center", "header-align": "center" }, "100": { "id": 100, "colName": "area", "label": "多行文本", "width": 140, "title": "多行文本", "align": "center", "header-align": "center" }, "101": { "id": 101, "colName": "text", "label": "文本", "width": 140, "title": "文本", "align": "center", "header-align": "center" } } }
-
为什么直接设置 json 文件而不是 js 对象呢?
因为对象会比较长,如果是代码形式的话,那还不如直接使用UI库组件来的方便呢。 -
你可能又会问了,既然直接用 json文件,为啥还要设计 interface 呢?
当然是为了明确各种类型,interface 可以当做文档使用,另外封装UI库的组件的时候,也可以用到这些 interface。使用列表控件的时候也可以使用这些 interface。
其实json文件不用手动编写,而是通过工具来编写和维护。
定义列表控件的 props
封装组件之前需要先定义一下组件需要的 props:
- props-grid.ts
import type { PropType } from 'vue' import type { IGridProps, IGridItem, IGridSelection } from '../types/50-grid' /** * 表单控件需要的属性propsForm */ export const gridProps: IGridProps = { /** * 模块ID,number | string */ moduleId: { type: Number, required: true }, /** * 主键字段的名称 */ idName: { type: String, default: 'ID' }, /** * 字段显示的顺序 */ colOrder: { type: Array as PropType<Array<number | string>>, default: () => [] }, /** * 锁定的列数 */ fixedIndex: { type: Number, default: 0 }, /** * table的列的 meta */ itemMeta: { type: Object as PropType<{ [key:string | number]: IGridItem }> }, /** * 选择的情况 IGridSelection */ selection: { type: Object as PropType<IGridSelection>, default: () => { return { dataId: '', // 单选ID number 、string row: {}, // 单选的数据对象 {} dataIds: [], // 多选ID [] rows: [] // 多选的数据对象 [] } } }, /** * 绑定的数据 */ dataList: { type: Array as PropType<Array<any>>, default: () => [] }, 其他略。。。 }
按照 Option API 的方式设置 props 的定义,这样便于共用属性的定义(好吧似乎也没有需要共用的地方,不过我还是喜欢把 props 的定义写在一个单独的文件里)。
封装列表控件
定义好 json 、props之后,我们基于 el-table 封装列表控件:
- template 模板
<el-table ref="gridRef" v-bind="$attrs" :data="dataList" :height="height" :stripe="stripe" :border="border" :fit="fit" :highlight-current-row="highlightCurrentRow" :row-key="idName" @selection-change="selectionChange" @current-change="currentChange" > <!--多选框,实现多选功能--> <el-table-column type="selection" width="55" align="center" header-align="center" @click="clickCheck" > </el-table-column> <!--依据 json 渲染的字段列表--> <el-table-column v-for="(id, index) in colOrder" :key="'grid_list_' + index + '_' + id" v-bind="itemMeta[id]" :column-key="'col_' + id" :fixed="index < fixedIndex" :prop="itemMeta[id].colName" > </el-table-column> </el-table>
设置 type="selection"
列,实现多选的功能。
使用 v-for 的方式,遍历出动态列。
设置 :fixed="index < fixedIndex"
,实现锁定左面列的功能。
- js 代码
import { defineComponent, ref } from 'vue' // 列表控件的属性 import { gridProps } from '../map' /** * 普通列表控件 */ export default defineComponent({ name: 'nf-elp-grid-list', inheritAttrs: false, props: { ...gridProps // 解构共用属性 }, setup (props, ctx) { // 获取 el-table const gridRef = ref(null) return { gridRef } } })
把 props 的定义放在单独的 ts文件 里面,组件内部的代码就可以简洁很多。
实现内置功能
可以按照自己的喜好,设置一些内部功能,比如单选/多选的功能,格式化的功能等。
- 定义控制函数 controller.ts
import type { ElTable } from 'element-plus' // 列表控件的属性 import type { IGridProps } from '../map' export interface IRow { [key: string | number]: any } /** * 列表的单选和多选的事件 * @param props 列表组件的 props * @param gridRef el-table 的 $ref */ export default function choiceManage<T extends IGridProps, V extends typeof ElTable>(props: T, gridRef: V) { // 是否单选触发 let isCurrenting = false // 是否多选触发 let isMoring = false // 单选 const currentChange = (row: IRow) => { if (isMoring) return // 多选代码触发 if (!row) return // 清空 if (gridRef.value) { isCurrenting = true gridRef.value.clearSelection() // 清空多选 gridRef.value.toggleRowSelection(row) // 设置复选框 gridRef.value.setCurrentRow(row) // 设置单选 // 记录 props.selection.dataId = row[props.idName] props.selection.dataIds = [ row[props.idName] ] props.selection.row = row props.selection.rows = [ row ] isCurrenting = false } } // 多选 const selectionChange = (rows: Array<IRow>) => { if (isCurrenting) return // 记录 if (typeof props.selection.dataIds === 'undefined') { props.selection.dataIds = [] } props.selection.dataIds.length = 0 // 清空 // 设置多选 rows.forEach((item: IRow) => { if (typeof item !== 'undefined' && item !== null) { props.selection.dataIds.push(item[props.idName]) } }) props.selection.rows = rows // 设置单选 switch (rows.length) { case 0: // 清掉单选 gridRef.value.setCurrentRow() props.selection.dataId = '' props.selection.row = {} break case 1: isMoring = true // 设置新单选 gridRef.value.setCurrentRow(rows[0]) isMoring = false props.selection.row = rows[0] props.selection.dataId = rows[0][props.idName] break default: // 去掉单选 gridRef.value.setCurrentRow() props.selection.row = rows[rows.length - 1] props.selection.dataId = props.selection.row[props.idName] } } return { currentChange, // 单选 selectionChange // 多选 } }
- 列表控件的 setup 里调用
setup (props, ctx) { // 获取 el-table const gridRef = ref<InstanceType<typeof ElTable>>() // 列表选项的事件 const { currentChange, // 单选 selectionChange // 多选 } = choiceManage(props, gridRef) return { selectionChange, // 多选 currentChange, // 单选 gridRef // table 的 dom } }
这里有一个“度”的问题:
-
el-table 完全通过 slot 的方式实现各种功能,这种方法的特点是:非常灵活,可以各种组合;缺点是比较繁琐。
而我们需要寻找到一个适合的“折中点”,显然这个折中点很难统一,这也是过渡封装带来的问题。 -
不能遇到新的需求,就增加内部功能,这样就陷入了《人月神话》里说的“焦油坑”,进去了就很难出来。
这也是低代码被诟病的因素。
支持扩展
那么如何找到这个折中点呢?可以按照 “开闭原则”,按照不同的需求,设置多个不同功能的列表控件,使用 slot 实现扩展功能。或者干脆改为直接使用 el-table 的方式。(要灵活,不要一刀切)
比如简单需求,不需要扩展功能的情况,设置一个基础列表控件:nf-grid。
需要扩展列的情况,设置一个可以扩展的列表控件:nf-grid-slot。
如果需要多表头、树形数据等需求,可以设置一个新的列表控件,不过需要先想想,是不是直接用 el-table 更方便。
要不要新增一个控件,不要惯性思维,而要多方面全局考虑。
这里介绍一下支持 slot 扩展的列表控件的封装方式:
<el-table ref="gridDom" v-bind="$attrs" size="mini" style="width: 100%" :data="dataList" :height="height" :stripe="stripe" :border="border" :fit="fit" :highlight-current-row="highlightCurrentRow" :current-row-key="idName" :row-key="idName" @selection-change="selectionChange" @current-change="currentChange" > <!--显示选择框--> <el-table-column type="selection" width="55"> </el-table-column> <!--显示字段列表--> <template v-for="(id, index) in colOrder" :key="'grid_list_' + index + '_' + id" > <!--检查插槽里是否包含 字段名,作为判断依据--> <!--不带插槽的列--> <el-table-column v-if="!(slotsKey.includes(itemMeta[id].colName))" :fixed="index < fixedIndex" v-bind="itemMeta[id]" :prop="itemMeta[id].colName" :min-width="50" > </el-table-column> <!--带插槽的列--> <el-table-column v-else v-bind="itemMeta[id]" > <template #default="scope"> <!--读取外部插槽内容,并且传递 scope --> <slot :name="itemMeta[id].colName" v-bind="scope"></slot> </template> </el-table-column> </template> </el-table>
模板部分,首先判断一下是否需要使用 slot,做一个分支。
需要使用 slot 的列,通过 <template #default="scope">
设置 slot。
- 代码部分
import { defineComponent, ref } from 'vue' // 表单控件的属性 import { gridProps } from '../map' import choiceManage from './controller' export default defineComponent({ name: 'nf-elp-grid-slot', inheritAttrs: false, props: { ...gridProps }, setup (props, ctx) { // 记录插槽 的 名称 const slots = ctx.slots const slotsKey = Object.keys(slots) // 列表选项的事件 const { currentChange, // 单选 selectionChange // 多选 } = choiceManage(props, gridRef) return { slotsKey, selectionChange, // 多选 currentChange // 单选 } } })
一般列表的使用方法
封装之后,使用起来就很方便了,引入 json文件,设置属性即可。
- template
<nf-grid v-bind="gridMeta" :dataList="dataList" :selection="selection" size="small" />
是不是简单多了。
- 代码部分
import { defineComponent, reactive } from 'vue' import { nfGrid, createDataList } from '../../../../lib-elp/main' import _gridMeta from '../../grid/grid.json' import _formMeta from '../../form/form.json' export default defineComponent({ name: 'nf-elp-grid-page', components: { nfGrid }, setup(props) { // 不需要动态改变的话,可以不使用 reactive。 const gridMeta = reactive(_gridMeta) // 设置选择的行 const selection = reactive({ dataId: '', // 单选ID number 、string row: {}, // 单选的数据对象 {} dataIds: [], // 多选ID [] rows: [] // 多选的数据对象 [] }) // 设置记录集。 const dataList = reactive(_dataList) return { dataList, selection, gridMeta } } })
控件可以做成全局组件的形式。
- 看看效果
扩展列表的使用方法
首先还是依据 json 渲染列表,然后根据需要设置插槽即可,设置插槽后会替换默认的列。
- template
可以使用 slot 自定义扩展列 <br> <!--表单控件--> <nf-grid v-grid-drag="gridMeta" v-bind="gridMeta" :dataList="dataList" :selection="selection" size="small" > <!--普通字段,用字段名作为插槽的名称--> <template #text="scope"> <div style="display: flex; align-items: center"> <el-icon><timer /></el-icon> <span style="margin-left: 10px">扩展:{{ scope.row.text }}</span> </div> </template> <!--普通字段--> <template #week="scope"> <span style="margin-left: 10px">{{ scope.row.week.replace('-w','年 第') + '周' }}</span> </template> <!--操作按钮--> <template #option="scope"> <el-button size="small" @click="handleEdit(scope.$index, scope.row)">修改</el-button> <el-button size="small" type="danger" @click="handleDelete(scope.$index, scope.row)">删除</el-button> </template> </nf-grid>
通过 slot 扩展列,可以按照 Table-column 的匿名插槽的方式进行设置。
列的先后顺序还是由 colOrder 控制,和插槽的先后顺序无关。
- 代码部分
import { defineComponent, reactive } from 'vue' // 使用 图标 import { Timer } from '@element-plus/icons-vue' import { nfGridSlot, createDataList } from '../../../../lib-elp/main' import _gridMeta from '../../grid/grid.json' import _formMeta from '../../form/form.json' import { EAlign } from '../../../../lib/types/enum' import type { IGridSelection, IGridItem } from '../../../../lib/types/50-grid' export default defineComponent({ name: 'nf-elp-grid-slot-page', components: { Timer, nfGrid: nfGridSlot }, props: { moduleID: { // 模块ID type: [Number, String], default: 1 } }, setup(props) { const gridMeta = reactive(_gridMeta) // 设置列的先后顺序和是否显示 gridMeta.colOrder = [90, 100, 101, 102, 105, 113, 115, 116, 120,121,150, 2000] // 设置一个操作按钮列 const optionCol: IGridItem = { id: 2000, colName: "option", label: "操作", width: 180, fixed: EAlign.right, align: EAlign.center, // 使用枚举 headerAlign: EAlign.center } gridMeta.itemMeta['2000'] = optionCol // 设置操作列,也可以直接在json文件里设置。 const dataList = reactive(_dataList) const handleEdit = (index: number, row: any) => { console.log(index, row) } const handleDelete = (index: number, row: any) => { console.log(index, row) } return { handleEdit, handleDelete, dataList, gridMeta } } })
使用字段名称作为插槽的名称,可以把任意字段变成插槽的形式。
如果要添加操作按钮这类的列,可以给 itemMeta 添加对应的列属性。
- 看看效果:
管理 json
其实,前面介绍的那些大家可能都会想到,也许早就实践过了,然后发现虽然看着挺好,但是其实没有解决根本问题!只是把 template 里的问题转移到 json 里面。
虽然不需要设置模板,但是需要设置 json,还不是一样,有啥本质区别吗?
其实不一样的,管理 json 的难度明显比管理模板要简单得多。
比如我们可以做一个维护 json 的小工具:
- 首先从数据库文档生成基础的 json(毛坯房);
- 然后使用可视化+拖拽的方式设置格子细节(精装修)。
这样就可以很方便的维护 json 了。具体实现方式,将在下一篇再介绍。