一个文笔一般,想到哪是哪的唯心论前端小白。
前言
vue3 出来好久了吧!最近重构了一个项目,也算是有了实际项目经验了。汗颜!!!
然而,重构的新项目中大部分被封装过的组件,都是已经有人准备好了,而且工期比较紧,没顾上去思考自己站在一个主力的视角上如何去设计相关的业务组件。
因为现在主要的业务方向都是B端业务,所以 CURD 页面是必须要关注的。最近一段时间没事就琢磨了一下,以此为切入点,全面的将 vue3 的一些常用 API 进行组合,封装出一个可以快速开发的 CURD 组件。
先预览一下效果吧!
目前版本主要是分享一下思路,核心代码还不足以支撑更实际的业务场景,众所周知,一个可以被流行的组件是要经过多个项目的考验的。
思路
一个 CURD 页面其实是每个前端都没法跨过的坎!
我的思路里,一个 CURD 包含如下几块:
- 搜索条:UI风格上保持一行3个或者四个,按钮在最后一行的最右侧,存在默认两个按钮:【重置】和【查询】。主体部分是一个表单,表单可能包含【输入框】【下拉菜单(可远程搜索)】【时间范围】等等项,一般来说没默认值为空。生成方式根据vue2的开发经验来说,使用json进行配置是一个不错的选择,核心点在于如何和table进行交互,并且要注意,搜索的时候分页组件要切回第一页。
- 工具条:主要职责是进行对整个表格进行操作的一些交互功能,例如【批量下线】【批量上线】【批量删除】【创建条目】【导入】【导出】等等。主体就是一系列的按钮,会和【弹框/抽屉】交互,会刷新表格。
- 表格/列表:每一行一般都会包含一个操作列,操作列里面是针对当前条目进行操作的,会将当前行 row 返回,用来编辑或者进行其他业务的实现。表格会出现序号和多选框,每一列支持各种形式的配置,根据vue2的开发经验来说,我还是选择了 json 的方式进行配置。表格一般只会和【弹框/抽屉】有交互,而且会进行页面跳转。最后,我将分页和表格绑定在了一起,并支持靠左靠右显示。
- 弹框/抽屉:作为两个最常用的和表格交互的悬浮组件,一般是用来数据的输入和输出,以及展示表格行无法展示的更详细的数据,内容不确定,但是风格可以统一。为了方便使用,我将两个按钮进行了简单的控制,会触发两个方法: submit 和 cancel。
- 表单:表单使用的场景很多,而且有些很复杂的场景(多个表单项关联/支持新建的下拉菜单/一个label对应多个input等等),综合考虑,也为了降低学习成本,我只在考虑封装简单的表单,即前面括号里面的那些奇怪的东西不考虑,后期可以考虑使用插槽的方式插进去。
如上,大概就是一个常规的CURD会用到的几个特性组件了。
在封装这一套东西的时候遇到几个拐点,每个拐点都会面临不止两个的岔路,需要有所取舍,所以也会删删改改来回折腾。
拐点一:
表单是所有数据的开端,需要考虑的问题:
- 表单要支持三个功能:新建、编辑、查看。这就需要关注每个表单项的禁用状态,以及初始值如何导入进去。最关键的要保持和最外层 page 数据的实时响应。
- 表单和表单校验是离不开的,这就要保证校验规则,以及如何获取校验状态。
- 通信,如何和其他组件进行通信?表格、弹框…
- 获取数据并初始化数据?
- 配置项是使用 :bind 的方式传入,还是使用 ts 规定好支持的字段,分开每个字段进行控制?
拐点二:
表格是整个页面的核心,需要考虑的问题:
- 如何满足UI的随心所欲,支持各种花里胡哨的展示方式?
- 操作列里面的按钮的点击事件也是各种各样的,如何和其他组件进行通信?
- 外层组件要调用到表格的刷新事件,刷新的时候会带入搜索信息?
- 有些数据不请求接口,要不要支持初始化tableData?
- pagenation 重置,显式的使用字段控制还是隐式的默认去触发?
- 多选框的数据如何被外层组件调用,并实时响应?
- 是否要支持高级表格?(跨行/跨列/折叠/可编辑…)
拐点三:
弹框将表单和表格进行了有效的关联,需要考虑的问题:
- 解耦,表单内容和弹框如何解耦?
- 操作按钮如何自定义?包括点击事件和文案内容。
但现在很懊悔没有提前去把这些东西都考虑进去,导致有些问题是已经做好了,才发现考虑不周,导致大批量的更改设计。
开发
开发过程其实就是以上几个组件的封装思路,分为:表格(base-table)、弹框(loger)、表单(former)、搜索(searcher)、工具条(page-tools)。
先看一下最终使用方法吧,我自信这段代码是不需要增加任何注释:
<template>
<div class="page-main">
<searcher ref="searchForm" :form-items="searchItems" @handleSearch="handleSearch" />
<page-tools>
<el-button @click="handleOpenDialog('new')" type="primary">创建数据</el-button>
<el-button @click="handleOperateMore" type="primary">批量删除</el-button>
<el-button @click="handleOpenUpload('new')" type="primary">批量导入</el-button>
</page-tools>
<base-table ref="tableRef" :table-config="tableConfig" :table-columns="tableColumns">
<template #tools="{ scope }">
<el-button size="small" link style="color: red" @click="operate.handleDelete(scope.row.id)">删除</el-button>
<el-button size="small" link @click="handleOpenDialog('edit', scope.row)">修改</el-button>
<el-dropdown @command="(val:'disabled' | 'download' | 'createAdmin') => operate.handleCommand(val, scope.row)">
<el-button size="small" link>
更多<el-icon class="el-icon--right"><arrow-down /> </el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="download">下载</el-dropdown-item>
<el-dropdown-item command="disabled">停用</el-dropdown-item>
<el-dropdown-item command="createAdmin">创建管理员</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</base-table>
<Loger ref="pageLoger" size="small" :type="logerType" @submit="handleSubmit" @cancel="handelCancel">
<Former ref="tableFormRef" :form-items="formItems" :form-config="formConfig" />
</Loger>
<Loger ref="uploadLoger" size="small" :type="uploadLogerType" no-submit @cancel="handelCancelUpload">
<el-upload class="upload-demo" drag action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15">
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> Drop file here or <em>click to upload</em> </div>
<template #tip>
<div class="el-upload__tip"> jpg/png files with a size less than 500kb </div>
</template>
</el-upload>
</Loger>
</div>
</template>
<script lang="ts" setup>
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* @Name: curd-all
* @Author: Ziyi
* @Email: --@163.com
* @Date: 2023-06-11 22:57
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
import { useMockTable, useMockForm, useMockLoger, useMockTools, useUploadLoger, useSearcher } from '../utils/';
import { ref } from 'vue';
import { ArrowDown, UploadFilled } from '@element-plus/icons-vue';
const pageLoger = ref();
const uploadLoger = ref();
const tableRef = ref();
const tableFormRef = ref();
const searchForm = ref();
const { tableColumns, tableConfig, operate } = useMockTable(tableRef, pageLoger, tableFormRef);
const { formItems, formConfig } = useMockForm(tableRef, pageLoger, tableFormRef);
const { logerType, handleOpenDialog, handleSubmit, handelCancel } = useMockLoger(tableRef, pageLoger, tableFormRef);
const { uploadLogerType, handleOpenUpload, handelCancelUpload } = useUploadLoger(tableRef, uploadLoger);
const { handleOperateMore } = useMockTools(tableRef, pageLoger, tableFormRef);
const { searchItems, handleSearch } = useSearcher(tableRef, searchForm);
</script>
<style lang="scss" scoped></style>
再来一个众所周知,所有表面看起来很简洁的代码的背后只是一座冰山露在水面上面的部分。
接下来就是拆解藏在水下的各个的部分了!
表单(former)
首先解答上面关于表单提出的问题最后采取的方案:
- 表单要支持三个功能:新建、编辑、查看。这就需要关注每个表单项的禁用状态,以及初始值如何导入进去。最关键的要保持和最外层 page 数据的实时响应。
禁用状态对应的字段是 disabled,表单配置项 formItems 使用ref包裹一层,在弹框打开的时候会有一个 dialogType 来控制弹框的 title,同时根据每个表单项配置 no-edit 字段,声明不可被编辑,这样一来,新建时都可以被编辑,在打开弹框或者初始化表单的时候根据 no-edit 字段修改 disabled 值,可以实现实时响应。
- 表单和表单校验是离不开的,这就要保证校验规则,以及如何获取校验状态。
经调研,校验规则可以直接通过 rule 字段传入,且绑定在 form-item 上,即可生效。
- 通信,如何和其他组件进行通信?表格、弹框…
通信主要有两个功能:
获取数据
使用 defineExpose 方法,对外暴露 formData 和 getValue() 两种方法,都可以获取表单数据,区别是 getValue() 会执行表单校验,而直接读 formData 属性则只是返回当前表单数据。初始化数据
初始化数据分两种情况,一种是清空,一种是编辑时赋值。所以也使用 defineExpose 方法向外暴露了两个方法 reset() 和 setValue() - 获取数据并初始化数据?
这个在上面已经包含到了。
- 配置项是使用 :bind 的方式传入,还是使用 ts 规定好支持的字段,分开每个字段进行控制?
这个就是仁者见仁,智者见智了,为了方便使用者能够简化使用流程,也是因为我的 ts 水平有限,所以显式的规定了几个字段,给开发人员使用,如果不够再加。
所以最终的 Former.vue 文件长这个样子:
<template>
<el-form ref="ruleFormRef" :model="formData" :rules="rules" :label-width="formConfig.labelWidth" :size="formConfig.size">
<el-form-item :label="item.label + ':'" :prop="item.prop" v-for="item in formItems" :key="item.prop">
<component
:is="ItemMap[item.name]"
:item-config="item"
:item-value="formData[item.prop]"
:corr-value="item.corrProp ? formData[item.corrProp] : null"
:ref="item.prop"
@update="handleUpdate"
/>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { PropType, toRefs, reactive, ref } from 'vue';
import { BaseFormItem, FormConfigType } from './common-form-type';
import { ItemMap } from './items';
// 声明 类型
interface FD {
fd: any;
rules: any;
}
// 预处理方法 - 初始化 formData 和 rules
const initFormData = (arr: Array<BaseFormItem>): FD => {
let fd: any = {};
let rules: any = {};
arr.forEach((item) => {
fd[item.prop] = item.defaultValue;
rules[item.prop] = item.rules;
});
return { fd, rules };
};
// 接收子组件回调 - 更新数据
const handleUpdate = (c: any): void => {
formData[c.prop] = c.val;
};
// 获取 props
const props = defineProps({
formItems: {
type: Array as PropType<Array<BaseFormItem>>,
default: () => [{ name: 'input', prop: 'username', label: '用户名', placeholder: '请输入用户名' }],
},
formConfig: { type: Object as PropType<FormConfigType>, default: () => ({}) },
});
// 初始化data
const inited = ref(true);
const ruleFormRef = ref(); // 表单 Ref
const { formItems, formConfig } = toRefs(props);
const formData = reactive(initFormData(formItems.value).fd); // 绑定 formData
const rules = reactive(initFormData(formItems.value).rules); // 绑定 rules
const getValue = () => {
let validateRusult = false;
ruleFormRef.value &&
ruleFormRef.value.validate((v: boolean) => {
if (v) {
console.warn('submit');
validateRusult = true;
} else {
console.error('submit fiald!', formData);
validateRusult = false;
}
});
return validateRusult ? formData : false;
};
// 为表单赋值
const setFormData = (val: any) => {
console.log('? ~ file: Former.vue ~ line 76 ~ setFormData ~ val', val);
inited.value = false;
for (let key in val) {
console.log('? ~ file: Former.vue ~ line 80 ~ setFormData ~ key', key);
formData[key] = val[key];
}
inited.value = true;
};
const reset = () => {
ruleFormRef.value.resetFields();
};
defineExpose({ getValue, formData, reset, setFormData });
</script>
代码中使用到了 component 组件,可以根据组件 name 进行渲染。
关键点是 ItemMap,也可能是我当时封装 former 时候 vue3 的版本还比较低,ts setup
的开发方式中没法声明 name 属性。从百度上搜了一下 component 的使用方式做了如下配置,最新版本中是这么用的 [vue3 component]
// item/index.ts
import { Component } from 'vue';
import Input from './Input.vue';
import Select from './Select.vue';
import DateRange from './DateRange.vue';
import SelectCorr from './SelectCorr.vue';
import AsyncSelect from './async-select.vue';
import DateItem from './DateItem.vue';
interface Item {
[key: string]: Component;
}
export const ItemMap: Item = {
// 输入框
input: Input, // 普通输入框
// 下拉框
select: Select, // 普通
'async-select': AsyncSelect, // 异步options
'select-corr': SelectCorr, // 关联上一级(位置可以任意)
// 时间选择器
daterange: DateRange, // 时间范围
date: DateItem,
};
而表单项的设计是这样的,以 input
为例:
<template>
<el-input
v-model="input"
:type="itemConfig.type || 'text'"
:placeholder="itemConfig.placeholder"
:disabled="itemConfig.disabled"
:show-password="itemConfig.type === 'password'"
@input="handleUpdate"
@change="handleUpdate"
/>
</template>
<script lang="ts" setup>
import { PropType, toRefs, ref, watch } from 'vue';
import { excempleInput } from '../dataExcemple';
import { InputItem } from '../common-form-type';
const props = defineProps({
itemConfig: {
type: Object as PropType<InputItem>,
default: (): InputItem => excempleInput,
},
itemValue: {
type: [Number, String, Boolean],
default: '',
},
});
const { itemConfig, itemValue } = toRefs(props);
const input = ref(itemValue.value);
watch(itemValue, (v) => {
input.value = v;
});
const emit = defineEmits({
update: null,
});
const handleUpdate = () => {
emit('update', { val: input.value, prop: itemConfig.value.prop });
};
</script>
如代码所示:
传入itemConfig
、itemValue
两个字段,分别对应的表单项的配置信息和表单项的值。
通过 emit.update
来上报数据变化,并且使用了一个 watch
事件来监听外部传入的数据来覆盖表单项的值。
配套的 ts
export interface FormConfigType {
labelWidth: string;
size?: number | string;
}
export interface SaverConfigType {
title: string;
width: string | number;
appendToBody?: boolean;
submitMethod?(formData: { [key: string]: any }, $dialog?: any): void; // 提交回调事件
}
export interface BaseFormItem {
name: string;
prop: string;
label: string;
defaultValue: string | Array<any> | number | boolean | object;
disabled?: boolean;
noEdit?: boolean;
type?: string; // 输入框时使用
span?: number; // 只有在search里面需要填,不然不生效
placeholder?: string;
rules?: Array<any>;
style?: string | object;
corrProp?: string;
}
export type InputItem = BaseFormItem;
export interface OptionType {
label: string;
value: number | boolean | string;
}
export interface DateItem extends BaseFormItem {
disabledDate?(time: Date): boolean;
}
export interface SelectItem extends BaseFormItem {
options(): OptionType[];
}
export interface AsyncSelectItem extends BaseFormItem {
url: string;
formatter?(arr: any[]): OptionType[];
}
export interface SelectCorrItem extends BaseFormItem {
options(val: string | number | boolean): OptionType[];
}
export interface DataRangeItem extends BaseFormItem {
startPlaceholder: string;
endPlaceholder: string;
rangeSeparator: string;
}
ts 那些高级语法是实在有点用不来哇!!!就先这么着吧,以后再优化!
表格(base-table)
依旧先扫雷,目前阶段采用的一些方案:
- 如何满足UI的随心所欲,支持各种花里胡哨的展示方式?
和表单类似依旧使用了 component 的方式来定义各种各样的列,同时也支持使用 handler 字段来传入操作的方法。
- 操作列里面的按钮的点击事件也是各种各样的,如何和其他组件进行通信?
操作列的按钮,单独提出来,只保留具名插槽 tools,即按钮在 page 里面写,这样的话就省去了麻烦嵌套问题,同时也便于统一控制 page-tools 里面的按钮和操作列中的按钮的权限。
- 外层组件要调用到表格的刷新事件,刷新的时候会带入搜索信息?
base-table 组件对外暴露了一个 initTableData(query?) 的方法,query字段用来传入搜索信息,如果 query不为 undefined,则将分页重置到第一页。
- 有些数据不请求接口,要不要支持初始化tableData?
目前不考虑这种场景,如果要的话,修改一下 initTableData 方法,再增加一个入参即可。
- pagenation 重置,显式的使用字段控制还是隐式的默认去触发?
默认触发,通过是否在搜索即 query 判断。
- 多选框的数据如何被外层组件调用,并实时响应?
使用 defineExpose 对外暴露 multipleSelection 字段,可以被外侧读取。
- 是否要支持高级表格?(跨行/跨列/折叠/可编辑…)
不支持。高级表格会再开发一个 high-table 满足使用需求。
今天才发现,这个组件是有问题的,先粘上吧!
<template>
<el-table ref="multipleTableRef" stripe :data="tableData" style="width: 100%" :border="tableConfig.border" @selection-change="handleSelectionChange">
<el-table-column v-if="tableConfig.check" type="selection" width="55" />
<el-table-column v-if="tableConfig.index" label="序号" :width="60" type="index" align="center" :index="indexMethod" />
<component :is="ItemMap[item.name]" v-for="item in tableColumns" :item="item" :key="item.prop" @handle="handle" />
<el-table-column v-if="tableConfig.tools" label="操作" :width="tableConfig.toolsWidth || 120" :fixed="tableConfig.toolsFixed || 'right'">
<template #default="scope">
<slot :scope="scope" name="tools"></slot>
</template>
</el-table-column>
<template #empty>
<el-empty />
</template>
</el-table>
<!-- v-if="page.total > page.size" -->
<div style="margin-top: 16px; overflow: hidden">
<el-pagination
v-model:currentPage="page.num"
v-model:page-size="page.size"
:page-sizes="[10, 20, 30, 40, 50]"
layout="total, sizes, prev, pager, next"
style="float: right"
:total="page.total"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script lang="ts" setup>
import { PropType, toRefs, ref, reactive, onMounted } from 'vue';
import { BaseColumnType, TableConfigType } from '@/components/common-table/common-table-type';
import { ItemMap } from './columns';
import type { ElTable } from 'element-plus';
import { Http } from '@/utils/http';
import Message from '@/utils/message';
// 随便定义一个 表格数据类型
interface TypeTableData {
[key: string]: any;
}
interface resType {
total: number;
list: TypeTableData[];
}
// props 接收数据并转换为双向绑定
const props = defineProps({
tableConfig: {
type: Object as PropType<TableConfigType>,
default: () => ({ border: true, index: false, check: false }),
},
tableColumns: {
type: Array as PropType<BaseColumnType[]>,
default: () => [],
},
});
const { tableConfig, tableColumns } = toRefs(props);
// 声明多选结果
const multipleSelection = ref([]);
const multipleTableRef = ref<InstanceType<typeof ElTable>>();
const tableData = ref<{}>([]);
//分页组件数据
let page = reactive({
total: 0,
num: 1,
size: 10,
});
// 更新列表
const initTableData = (query?: TypeTableData) => {
let { size, num } = page;
if (query) {
num = 1;
}
const params = {
size,
num,
query,
};
// TODO: takeTableData
Http.post(tableConfig.value.url, params).then((res) => {
if (!res) {
return false;
}
Message.success('更新列表成功!');
tableData.value = (res as unknown as resType).list;
page.total = (res as unknown as resType).total;
});
};
const resetTable = (query?: TypeTableData) => {
page.num = 1;
initTableData(query);
};
// 序号方法
const indexMethod = (i: number) => (page.num - 1) * page.size + i + 1;
// 表格方法 - 多选勾选
const handleSelectionChange = (val: any) => {
multipleSelection.value = val;
console.log(multipleSelection.value);
};
// 分页组件方法
const handleSizeChange = (val: number) => {
page.size = val;
initTableData();
};
const handleCurrentChange = (val: number) => {
page.num = val;
initTableData();
};
// emit
const emits = defineEmits({
handleMethod: null,
});
const handle = (o: any) => {
console.log('baseTable :: ', o);
emits('handleMethod', o);
};
// 生命周期 mounted
onMounted(() => {
initTableData();
});
// 对外暴露属性
defineExpose({
multipleSelection,
page,
initTableData,
resetTable,
});
</script>
<style scoped lang="scss">
.el-pagenation {
--el-pagination-hover-color: red !important;
}
</style>
可见注释中,有一个分歧就是,当没有数据的时候或者数据只有不到一页的时候,要不要显示分页?后来决定,分页默认一直显示。
为什么呢?例如:有99条数据,分页最大是 100条/页
,这样的话就没法切换每页条数了。
然后是具体列,以点击列展示:
<template>
<el-table-column :prop="item.prop" :label="item.label" :width="item.width" show-overflow-tooltip>
<template #default="{ row }">
<span class="click-item" @click="item.handler(row)">{{ row[item.prop] }}</span>
</template>
</el-table-column>
</template>
<script setup lang="ts">
import { PropType, toRefs } from 'vue';
import { ClickColumnType } from '../common-table-type';
const props = defineProps({
item: {
type: Object as PropType<ClickColumnType>,
default: () => ({}),
},
});
const { item } = toRefs(props);
</script>
<style scoped lang="scss">
.click-item {
color: rgb(36, 36, 248);
cursor: pointer;
&:hover {
color: rgb(124, 124, 253);
}
}
</style>
如代码所示,使用 item.handler(row)
直接触发配置的 handler 方法。其他操作列也是类似的实现方式。
最后附上 ts:
export interface TableConfigType {
url: string;
query?: { [key: string]: any };
border?: boolean;
index?: boolean;
check?: boolean;
toolsWidth?: number;
toolsFixed?: string;
tools?: boolean;
}
export interface BaseColumnType {
name: string;
prop: string;
label: string;
align: string;
fixed?: string;
width?: string | number;
minWidth?: string | number;
formatter?(val: any): any;
}
export interface ClickColumnType extends BaseColumnType {
handler(row: any): void;
}
export interface SwitchColumnType extends BaseColumnType {
acriveColor?: string;
inactiveColor?: string;
handler(row: any): void;
}
export interface JumpColumnType extends BaseColumnType {
jumpType: string;
jumpPath: string;
}
export interface TagOption {
color?: string;
type?: 'success' | 'info' | 'warning' | 'danger' | '';
size?: 'large' | 'default' | 'small' | '';
hit?: boolean;
effect?: 'dark' | 'light' | 'plain';
round?: boolean;
}
export interface TagsColumnType extends BaseColumnType {
beforeFormatter?(val: string): string[]; // 渲染之前预处理
formatter?(val: any): any;
tagOption?: TagOption;
}
弹框(loger)
扫雷:
- 解耦,表单内容和弹框如何解耦?
其实弹框内容和弹框本身本来就没有任何耦合度,弹框只是一个展示的容器。所以就把它当做容器使就好啦!
- 操作按钮如何自定义?包括点击事件和文案内容。
目前只支持 submit 和 cancel,并使用 no-submit 字段来控制 submit 是否显示,如果有必要就将按钮修改一下,将按钮一字摆开传进去,不过这样会麻烦。
几乎没有什么难点:
<template>
<el-dialog v-model="dialogVisible" :title="title" :width="dialogWidth" lock-scroll :show-close="false" :close-on-click-modal="false">
<div class="loger-body">
<slot></slot>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="cancel" v-if="type !== 'show'">取消</el-button>
<el-button type="primary" @click="submit" v-if="type !== 'show' && !noSubmit">确定</el-button>
<el-button type="primary" @click="cancel" v-if="type === 'show'">关闭</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref, toRefs } from 'vue';
const dialogVisible = ref(false);
const props = defineProps({
size: {
type: String,
default: 'small',
},
type: {
type: String,
default: 'new',
},
noSubmit: {
type: Boolean,
default: false,
},
});
const emits = defineEmits({
submit: null,
cancel: null,
});
const { size, type } = toRefs(props);
const dialogWidth = computed(() => (size.value === 'big' ? 960 : size.value === 'middle' ? 720 : 480));
const title = ref<string>('弹框');
// 工具方法
const open = (str: string) => {
title.value = str;
dialogVisible.value = true;
};
const close = () => {
dialogVisible.value = false;
};
const submit = () => {
emits('submit', close);
};
const cancel = () => {
emits('cancel', close);
};
defineExpose({
open,
close,
submit,
cancel,
});
</script>
<style lang="scss" scoped>
.loger-body {
max-height: 500px;
overflow: auto;
}
</style>
需要注意的是,我把弹框分成了三个常见的大小:
- big: 960
- middle: 720
- small: 480
且默认值为 small。
搜索(searcher)
搜索的思路其实就是 former 的思路,只是搜索多了默认按钮,并且布局发生了改变,并传入了一个 span 字段来配合 el-col 使用。
<template>
<el-form ref="ruleFormRef" :model="formData" label-width="80px" class="demo-ruleForm" :size="formSize">
<el-row :gutter="24">
<el-col :span="item.span" v-for="item in formItems" :key="item.prop">
<el-form-item :label="item.label + ':'" :prop="item.prop">
<component
:is="ItemMap[item.name]"
:item-config="item"
:item-value="formData[item.prop]"
:corr-value="item.corrProp ? formData[item.corrProp] : null"
:ref="item.prop"
@update="handleUpdate"
/>
</el-form-item>
</el-col>
<el-col :span="24 - toolSpan">
<div style="text-align: right">
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="primary" @click="handleReset">重置</el-button>
</div>
</el-col>
</el-row>
</el-form>
</template>
<script lang="ts" setup>
import { reactive, ref, watch, toRefs, PropType } from 'vue';
import { BaseFormItem } from './common-form-type';
import { ItemMap } from './items';
interface FD {
fd: any;
rules?: any;
}
const getFormData = (arr: Array<BaseFormItem>): FD => {
let fd: any = {};
arr.forEach((item) => {
fd[item.prop] = item.defaultValue;
});
return { fd };
};
const takeToolSpan = (arr: Array<BaseFormItem>): number => {
let sum = arr.reduce((total, curr) => {
return total + (curr.span ? curr.span : 0);
}, 0);
return sum % 24 === 0 ? 24 : arr[0].span ? arr[0].span : 6;
};
const props = defineProps({
formItems: { type: Array as PropType<Array<BaseFormItem>>, default: () => [] },
});
const { formItems } = toRefs(props);
const formData = reactive(getFormData(formItems.value).fd);
const formSize = ref('');
const toolSpan = ref(takeToolSpan(formItems.value));
watch(formData, (v, o) => {
console.log('v=', v, ',o=', o);
});
const handleUpdate = (c: any): void => {
formData[c.prop] = c.val;
};
const emits = defineEmits({
handleSearch: null,
});
const handleSearch = (): void => {
emits('handleSearch', formData);
};
const handleReset = (): void => {
formItems.value.forEach((item: any) => {
formData[item.prop] = item.defaultValue;
});
emits('handleSearch', formData);
};
defineExpose({ formData });
</script>
工具条(page-tools)
工具条就是提供了一个样式,依旧使用了一个匿名插槽,让所有的按钮都显示在page页面上。
<template>
<div class="common-page-tools" :style="{ 'text-align': align, 'padding-bottom': divider ? 0 : '16px' }">
<slot></slot>
<el-divider v-if="divider" />
</div>
</template>
<script lang="ts" setup>
import { toRefs } from 'vue';
const props = defineProps({
align: { type: String, default: 'left' },
divider: { type: Boolean, default: false },
});
const { align, divider } = toRefs(props);
</script>
<style lang="scss" scoped>
.common-page-tools {
display: block;
padding-top: 8px;
}
</style>
如上就是各个组件的简单思路了,有异议的可讨论一下。但是不要引战就好了!
代码分享
其实代码分享已经没有什么了,主要是一个组合式API的思路,在 setup 中已经没有了 this 的观念,所以如何将 this.$ref 拿到就成了一个很关键的问题。这也是我一开始一直没有想明白的地方,毕竟重构的项目中,并没有做这么高度封装的东西。
后来无聊之余扫了一眼 Composition API,豁然开朗。
所以就把整个页面做成了如开头那样的代码。而也得益于此,我回看 vue2 代码中,一个4000+行代码的页面让我感到很崩溃。这样整理以后的代码,哪怕1w行我觉得都可以接受。
既然目录上有代码分享,就简单分享一下。
首先是模块的目录:
首先看到的是pages 目录下所有的文件夹都是由三个部分组成的,router、menu、views,router 是当前模块的 路由,menu 也是当前模块下的 菜单,veiw 则是用来存放所有的页面的。至于 utils 则是这一个模块特有的实现方法,里面的各个文件的作用:
api.ts 保存接口地址
form.ts 表单的配置文件
index.ts 导出 utils 所有的信息
loger.ts 弹框的配置项
searcher.ts 搜索条的配置项
table.ts 表格的配置项
tools.ts 工具条的配置项
得益于此所以就可以实现:
import { useMockTable, useMockForm, useMockLoger, useMockTools, useUploadLoger, useSearcher } from '../utils/';
const { tableColumns, tableConfig, operate } = useMockTable(tableRef, pageLoger, tableFormRef);
const { formItems, formConfig } = useMockForm(tableRef, pageLoger, tableFormRef);
const { logerType, handleOpenDialog, handleSubmit, handelCancel } = useMockLoger(tableRef, pageLoger, tableFormRef);
const { uploadLogerType, handleOpenUpload, handelCancelUpload } = useUploadLoger(tableRef, uploadLoger);
const { handleOperateMore } = useMockTools(tableRef, pageLoger, tableFormRef);
const { searchItems, handleSearch } = useSearcher(tableRef, searchForm);
这样的使用方式了。
可以看到所有 useXXX 接口都传入了 几个 ref,这样就实现了 this.$ref['xxx']
的效果,以table为例吧!
import { reactive, ref } from 'vue';
import { createTimeColumn } from '@/common/table-columns';
import { BaseColumnType, TagsColumnType, ClickColumnType, TableConfigType } from '@/components/common-table/common-table-type';
import { Http } from '@/utils/http';
import Message from '@/utils/message';
import { ElMessageBox } from 'element-plus';
import { useMockLoger, mockApi } from './';
const phoneColumn: BaseColumnType = {
name: 'base-column',
prop: 'phone',
label: '手机号',
width: 120,
align: 'center',
};
const emailColumn: BaseColumnType = {
name: 'base-column',
prop: 'email',
label: '邮箱',
width: 200,
align: 'center',
};
const birthColumn: BaseColumnType = {
name: 'base-column',
prop: 'birth',
label: '生日',
width: 140,
align: 'center',
formatter: ({ birth }) => {
const vs = birth.split('-');
return `${vs[0]}年${vs[1]}月${vs[2]}日`;
},
};
const TagsColumn: TagsColumnType = {
name: 'tags-column',
prop: 'userTags',
label: '标签',
align: 'center',
tagOption: {
type: 'info',
},
beforeFormatter: (v) => {
return v ? v.split(',') : [];
},
};
export const useMockTable = (tableRef: any, logerRef: any, formRef: any) => {
const { handleOpenDialog } = useMockLoger(tableRef, logerRef, formRef);
const usernameColumn: ClickColumnType = {
name: 'click-column',
prop: 'username',
label: '用户名',
align: 'center',
width: 160,
handler: (row: any) => {
handleOpenDialog('show', row);
},
};
const isDisabledColumn = {
name: 'switch-column',
prop: 'isDisabled',
label: '禁用状态',
width: 120,
align: 'center',
handler: (row: any) => {
Http.post(mockApi.update, row).then(() => {
Message.success('更新成功!');
tableRef.value.initTableData();
});
},
};
// 表格删除
const handleDelete = (id: { id: number }) => {
ElMessageBox.confirm('您确定要删除该企业吗?', '提示', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
.then(() => {
Http.post(mockApi.deleteOne, { id }).then(() => {
Message.success('删除成功!');
tableRef.value.initTableData();
});
})
.catch(() => {
Message.info('您点击了取消');
});
};
// 表格更多
const handleCommand = (type: 'download' | 'disabled' | 'createAdmin', row: any) => {
if (type === 'download') {
// return handleDownload(row);
}
if (type === 'disabled') {
return console.log('? ~ file: company.vue ~ line 143 ~ handleDisabled ~ id', row.id);
}
if (type === 'createAdmin') {
return console.log('createAdmin');
}
};
return {
tableColumns: ref([usernameColumn, phoneColumn, emailColumn, birthColumn, createTimeColumn, TagsColumn, isDisabledColumn]),
tableConfig: reactive<TableConfigType>({
url: mockApi.list,
border: true,
index: true,
check: true,
tools: true,
toolsWidth: 160,
}),
operate: { handleDelete, handleCommand },
};
};
后记
这就是这次分享的全部内容了!
有人不喜欢封装这样的东西,因为会为后来维护者带来学习成本!
有人喜欢封装这样的东西,因为可以简化开发流程,并且提高可维护性!
有人喜欢封装这样的东西但是不喜欢自己用,因为自己封装的东西自己也看不上!
有人喜欢用封装的东西但是不喜欢自己去封装,因为自己知道自己封装的东西自己也看不上!
有人喜欢封装一些东西给自己用,因为我开心、我乐意!!!
汇总一下所得吧:
- 得到一个开箱即用的 curd 页面,以及 composition API 的打开方式。
- 对 elementPLUS 的几个常用组件有了一些熟练度。
- 对 vue3 的几个常用 API 进行了一次深度不太深的调研。
- component 组件的使用。
- CURD 页面虽然简单,但是这样系统的将所有节点都考虑进去汇总的表达出来原来也还没有这么干过。
- 在习惯养成的路上又前进了一步。
最后的最后,这个文章后续还得更新,写这篇文章的过程中居然发现了 bug . . .