一个文笔一般,想到哪是哪的唯心论前端小白。
🧠 - 简介
给自己以前的文章引流一下:工具-Vue3+TS+ElementPlus-封装一个好用的CURD全功能页面
如上图所示,就是一个很常见的CURD页面了。
我的另一篇文章已经很详细的记录了一次我的制作思路,本文则是从另一个维度展开阐述一下 CURD 页面上需要注意的地方以及如何提高这个页面的可维护性。
👁️ - 分析
我先抛一下我在日常开发和维护过程中经常遇到的场景吧:
- 编辑场景下,弹框内部用的数据直接为表格的 row,导致两边数据会相互绑定。
- 新建数据时,有些值是内置的,利于创建人、所属部...这些数据存在各个地方。
- 同一个页面中多个CURD类别,通过切换tab的方式实现,导致切换tab时,search信息或者其他共用信息未清空。
- 表格数据脱敏问题
- 表格数据超出可视范围的规则
- 同一个页面多个表格时,多个操作导致相互冲突
- ...
总结一下,其实更多的问题是没有在一开始有一个明确的基调,每个操作都是开发人员自己想怎么写就怎么写,导致后台提供了接口但是不用,或者后台应该提供接口但是没有提供,导致前端再各种拼凑。
最后导致了代码的不可维护,和各种奇奇怪怪的bug。
🫀 - 拆解
操作
CURD 的操作类型主要涉及到两块:通用操作和特殊操作,但万变不理其宗:
- 增:新建一条或多条表格数据,包含新建按钮或者批量导入、版本升级;
- 删:删除一条数据或者多条表格数据,包含删除按钮或者批量删除;
- 改:修改一条数据,如:数据移交、修改状态、修改基础信息、修改依赖项等等,这个是使用场景最多的;
- 查:主要是根据表格中的某条数据查询相关的附属信息,或者可以理解为 改 的前置条件是 查;
这样一来就不得不提现在比较火的 restful 架构思路了。虽然现在的项目都没有遵循这个规范,但是不得不说这是一个很好的思路:
- get:获取数据
- post:编辑数据
- put:新增数据
- delete:删除数据
当然有些项目中只用post类型接口,所以就可以在模块后面增加确定的操作就行了!例如:/api/username/create、/api/username/delete、/api/username/modify 等等。
这样一来,接口及功能就能够很快的确定下来,那么前端的mock接口就很方便了。随即而来的就是具体前端如何去调度增删改查的操作了!
容器
通过查看我的另一篇关于 CURD 页面的
CURD 页面常用的容器有:
- 表单
- 搜索:
- 新增/编辑表单
- 表格
- 简单表格
- 高级表格
- 级联表格
- 可编辑表格
- 弹框/抽屉
- 标签页
- 分页
功能
CURD页面常用到的功能有:
- 上传/下载:常规上传/大文件上传下载
- 页面跳转:站内/站外跳转
- 详情预览:ppt/pdf文件预览
- 图片裁剪:固定宽高的图片处理
💪 - 落实
我的整体思路也很简单主要是两个原则:容器只负责数据的展示,不涉及任何业务的操作,所有对数据的操作都和页面进行绑定。
直观一点就是,所有的容器都是一个 block,所有的 block 都放在 page上,所有的 block 都会由 page 页面通过 prop 把数据传入进去,并且所有的block 都可以通过自身的 getValue 方法进行数据的获取,然后有page页面上的方法进行提交。
vue
<template>
<div class="page-main">
<searcher ref="searchForm" :form-items="searchItems" @handle-search="handleSearch" />
<page-tools>
<el-button type="primary" @click="handleOpenDialog('new')">
创建数据
</el-button>
<el-button type="primary" @click="handleOperateMore">
批量删除
</el-button>
<el-button type="primary" @click="handleOpenUpload('new')">
批量导入
</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" @confirm="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: Zhang Ziyi
* @Email: 15227974559@163.com
* @Date: 2022-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>
如上面案例代码所示,整个页面涉及到了5个组件:
- searchForm:搜索组件,通过一系列花里胡哨的操作之后,只对外暴露了一个方法:search。
- tableRef:表格组件,可见操作列的方法也是放在 page 里面的,唯一有耦合的是Switch列,这个是特殊列,都会有特定的声明方式,可以自己参考另一篇文章中 table 的封装思路。
- pageLoger:一个单纯的弹框组件,对外暴露了两个方法: cancel/confirm,且和内部的内容(tableFormRef)没有任何关联。并且,cancel和confirm方法均由page调用去触发。
- tableFormRef:表单组件,用来实现新增和编辑功能,它就对外暴露了 getValue 和 setValue 方法来保证数据的实时更新。
- uploadLoger:功能未完善暂不讨论
至于功能,则引入了多个 vue3 hooks,用来实现具体的功能。
看着 hooks 貌似很多,其实更多的是一些配置文件。简单放一下 useMockForm 的源码:
ts
// fomer.ts
import { DateItem, InputItem } from '@/components/common-form/common-form-type';
import { reactive, ref } from 'vue';
import { FormConfigType } from '@/components/common-form/common-form-type';
import VALIDATE from '@/utils/formValidator';
const adminUsernameItem: InputItem = {
name: 'input',
prop: 'username',
label: '用户名',
placeholder: '请输入用户名',
defaultValue: '',
// noEdit: true,
rules: [
VALIDATE.REQUIRED('用户名'),
],
};
const adminPhoneItem: InputItem = {
name: 'input',
prop: 'phone',
label: '手机号',
placeholder: '请输入手机号',
defaultValue: '',
rules: [
VALIDATE.REQUIRED('手机号'),
],
};
const adminEmailItem: InputItem = {
name: 'input',
prop: 'email',
label: '邮箱',
placeholder: '请输入邮箱',
defaultValue: '',
rules: [
VALIDATE.REQUIRED('邮箱'),
VALIDATE.TYPE('EMAIL', '邮箱')
],
};
const adminBirthItem: DateItem = {
name: 'date',
prop: 'birth',
label: '生日',
placeholder: '请输入生日',
defaultValue: '',
// disabledDate: (time: Date) => {
// return time.getTime() < Date.now();
// },
rules: [
VALIDATE.REQUIRED('生日'),
],
};
const adminTagsItem: InputItem = {
name: 'input',
prop: 'userTags',
label: '标签',
placeholder: '请输入标签,多个标签使用,分割',
defaultValue: '',
};
export const useMockForm = (tableRef: any, logerRef: any, formRef: any) => {
return {
formItems: ref([adminUsernameItem, adminPhoneItem, adminEmailItem, adminBirthItem, adminTagsItem]),
formConfig: reactive<FormConfigType>({
labelWidth: '80px',
}),
};
};
// loger.ts
import { BaseFormItem } from '@/components/common-form/common-form-type';
import { mockApi, useMockForm } from './index';
import { nextTick, ref } from 'vue';
import { Http } from '@/utils/http';
import Message from '@/utils/message';
type type_logerType = 'new' | 'edit' | 'show' | 'leadIn';
export const useMockLoger = (tableRef: any, logerRef: any, formRef: any) => {
// loger
const logerType = ref<type_logerType>('new');
// tools-打开弹框
const handleOpenDialog = (type: type_logerType, row?: any) => {
if (!type) {
console.error('打开弹框必须传入 type 字段,type 值可能为:', "'new' | 'edit' | 'show' | 'leadIn'");
}
const { formItems } = useMockForm(tableRef, logerRef, formRef);
logerType.value = type;
const title = (type === 'new' ? '新建' : type === 'edit' ? '编辑' : '查看') + '账号';
if (type === 'new') {
formItems.value.map((item: BaseFormItem) => {
item.disabled = false;
});
}
if (type === 'edit') {
formItems.value.map((item: BaseFormItem) => {
item.disabled = !!item.noEdit;
});
}
if (type === 'show') {
formItems.value.map((item: BaseFormItem) => {
item.disabled = true;
});
}
logerRef.value.open(title);
nextTick(() => {
row && formRef.value.setFormData(row);
});
};
// 弹框确定
const handleSubmit = async (cb: () => void) => {
const formData = await formRef.value.getValue();
console.log("🚀 ~ handleSubmit ~ formData:", JSON.stringify(formData))
if (formData) {
// 根据 type 进行判断操作
if (logerType.value === 'new') {
Http.post(mockApi.create, formData).then(() => {
Message.success('新建成功!');
tableRef.value.initTableData();
formRef.value.reset();
cb();
});
} else if (logerType.value === 'edit') {
Http.post(mockApi.update, formData).then(() => {
Message.success('更新成功!');
tableRef.value.initTableData();
formRef.value.reset();
cb();
});
}
}
};
// 弹框取消
const handelCancel = (cb: () => void) => {
formRef.value.reset();
cb();
};
return { logerType, handleOpenDialog, handleSubmit, handelCancel };
};
🛀 - 总结
其实在做这个模版的同时,我也在重新回顾 nestJS 的一些内容,原来前端负责前端的内容,后台负责后台的内容,有些东西确实从来没有考虑过。
最令印象深刻的其实就是类的概念,例如:博客管理页面。
其实博客就是一个类,他有很多个属性(title、content),也有很多个状态(上线状态、置顶状态),我们对其做的操作其实就是这个类的一些方法(增删改查置顶上线),其中也有些公共方法(增删改),这样就明确了一些事情。表格里面的数据是不是可以初始为一个个的 Blog 类呢?
然后,每次都通过直接调用这个 Blog 类的方法,就可以完成业务了。
同时,前后端可以共用一个 interface,唯一不一样的区别是,前端的增删改查是在调用后端提供的接口,后端的增删改查则是在直接操作数据。
我之所以没有朝着这个方向去努力一方面是因为前端业务变动太过于频繁,另一方面也是没有看到有人去这么操作过 ...
大家都在说面向对象编程,为什么不这么干呢?
推荐阅读
系列文章:
- 脚手架开发
- 模板项目初始化
- 模板项目开发规范与设计思路
- layout设计与开发
- login 设计与开发
- CURD页面的设计与开发
- 监控页面的设计与开发
- 富文本编辑器的使用与页面设开发设计
- 主题切换的设计与开发
- 水印切换的设计与开发
- 全屏与取消全屏
- 开发提效之一键生成模块(页面)