前言
表单在web应用中是使用频率最高的组件之一,好用,可复用的表单,可以提升实际工作的效率,我们使用Vue3+Vite+TS基于Element plus 二次封装表单组件,注册挂载为全局使用。封装好我们的表单组件,使用时就可以以配置的方式来使用表单啦!
一、前置知识
如果要自己手写实现一遍的话,建议先从这篇文章的基础项目搭建开始
首先表单中,校验规则是必须的,在软件应用开发中有这样一句话“永远不要相信用户提交的数据”
,其实就要通过校验限制用户的入手。一方面,从安全的角度
,防止黑客提交含有恶意脚本表单,破环系统的正常运行,另一方面,从数据存储的角度
,提交的数据是要干净的,例如一些字段要限制长度,一些图片和视频上传时要限制大小,减小服务器负担。
由此可见,表单在web 应用程序中的举足轻重。
二、功能介绍
- 可配置型的表单,通过json对象的方式自动生成表单
- 具备完善的功能, 表单验证, 动态删减表单,集成第三方插件
- 用法简单,扩展性好,便于维护
- 多场景,如: 弹框嵌套表单
三、准备阶段
- 分析
element-plus
表单能够用在哪些方面做优化 - 完善类型,支持ts
- 具备原有的
element-plus
表单功能 - 集成第三方插件: markdown编辑器,富文本编辑器等等。
四、所需资料
element-plus
使用的表单校验插件也是async-validator这个库,
async-validator地址 下面表单的rules.ts 文件就是之间拷贝这个校验规则文件的
五、开始实现
我们使用的时TypeScript 使用类型校验也是必须的,
0、表单组件目录和options类型文件
目录结构
表单的rules.ts
文件就是拷贝上面动图校验规则文件的
我们后面在页面使用表单使用options的时候需要的类型接口文件 types.ts
import { CSSProperties } from 'vue'
import { RuleItem } from './rules' // (1)这里导入了rules校验规则文件
import { ValidateFieldsError } from 'async-validator' // (2)安装element-plus的时候会下载这个依赖
interface Callback {
(isValid: boolean, invalidFields?: ValidateFieldsError): void
}
// 表单每一项的配置选项
export interface FormOptions {
// 表单项显示的元素
type: 'cascader' | 'checkbox' | 'checkbox-group' | 'checkbox-button' | 'color-picker' |
'date-picker' | 'input' | 'input-number' | 'radio' | 'radio-group' | 'radio-button' | 'rate' |
'select' | 'option' | 'slider' | 'switch' | 'time-picker' | 'time-select' |
'transfer' | 'upload' | 'editor',
value?: any, // 表单项的值
label?: string, // 表单项label
prop?: string, // 表单项的标识
rules?: RuleItem[], // 表单项验证规则
placeholder?: string, // 占位符
// 表单元素特有的属性
attrs?: {
clearable?: boolean,
showPassword?: boolean,
disabled?: boolean,
// CSS 样式属性
style?: CSSProperties,
}
// 表单项的子元素
children?: FormOptions[],
// 处理上传组件的属性和方法
uploadAttrs?: {
action: string,
header?: string,
method?: 'post' | 'put' | 'patch',
multiple?: boolean,
data?: any,
name?: string,
withCreadentials?: boolean,
howFileList?: boolean,
drag?: boolean,
accept?:string,
thumbnailMode?: boolean,
listType?: 'text' | 'picture' | 'picture-card',
autoUpload?: boolean,
limit?:number,
}
}
export interface ValidateFieldCallback {
(message: string, invalidFields?: ValidateFieldsError): void
}
export interface FormInstance {
registerLabelWidth(width: number, oldWidth: number): void,
deregisterLabelWidth(width: number): void,
autoLabelWidth: string | undefined,
emit: (evt: string, ...args: any[]) => void,
labelSuffix: string,
inline?: boolean,
model?: Record<string, unknown>,
size?: string,
showMessage?: boolean,
labelPosition?: string,
labelWidth?: string,
rules?: Record<string, unknown>,
statusIcon?: boolean,
hideRequiredAsterisk?: boolean,
disabled?: boolean,
validate: (callback?: Callback) => Promise<boolean>,
resetFields: () => void,
clearValidate: (props?: string | string[]) => void,
validateField: (props: string | string[], cb: ValidateFieldCallback) => void,
}
1、单一输入框组件处理(input)
这里的单一输入主要是input
组件的处理,不过upload上传组件
和editor富文本编辑器
也是单一的所以放在同一个template
进行 v-if判断处理
,根据每个itme
的类
型 判断渲染方式, 如: v-if="item.type !== 'upload'"
, 主要也是用到了动态组件component
<el-form-item
v-if="!item.children || !item.children!.length"
:prop="item.prop"
:label="item.label"
>
<component
v-if="item.type !== 'upload' && item.type !== 'editor'"
v-bind="item.attrs"
:is="`el-${item.type}`"
:placeholder="item.placeholder"
v-model="model[item.prop!]"
>
</component>
<el-upload
v-if="item.type === 'upload'"
v-bind="item.uploadAttrs"
:on-preview="onPreview"
:on-remove="onRemove"
:on-success="onSuccess"
:on-error="onError"
:on-progress="onProgress"
:on-change="onChange"
:before-upload="beforeUpload"
:before-remove="beforeRemove"
:http-request="httpRequest"
:on-exceed="onExceed"
>
<slot name="uploadArea"></slot>
<slot name="uploadTip"></slot>
</el-upload>
<div class="full-screen-container" >
<div id="toolbar" v-if="item.type === 'editor'" style="z-index: 101;"></div>
<div id="editor" v-if="item.type === 'editor'" style="height: 300px; z-index: 100;"></div>
</div>
</el-form-item>
2、多级嵌套框的处理(select、checkbox-group、radio-group)
有children属性,而且children有值的时候
判断是多级组件,这里边嵌套了两个component
组件 对应的 不同的组和子项
<el-form-item
v-if="item.children && item.children.length"
:prop="item.prop"
:label="item.label"
>
<component
v-bind="item.attrs"
:is="`el-${item.type}`"
:placeholder="item.placeholder"
v-model="model[item.prop!]"
>
<component
v-for="(child, i) in item.children"
:key="i"
:is="`el-${child.type}`"
:label='child.label'
:value='child.value'
>
</component>
</component>
</el-form-item>
3、上传组件的处理(upload)
<el-upload
v-if="item.type === 'upload'"
v-bind="item.uploadAttrs"
:on-preview="onPreview"
:on-remove="onRemove"
:on-success="onSuccess"
:on-error="onError"
:on-progress="onProgress"
:on-change="onChange"
:before-upload="beforeUpload"
:before-remove="beforeRemove"
:http-request="httpRequest"
:on-exceed="onExceed"
>
<slot name="uploadArea"></slot>
<slot name="uploadTip"></slot>
</el-upload>
4、富文本组件处理(第三方插件wangeditor)
<div class="full-screen-container" >
<div id="toolbar" v-if="item.type === 'editor'" style="z-index: 101;"></div>
<div id="editor" v-if="item.type === 'editor'" style="height: 300px; z-index: 100;"></div>
</div>
富文本这部分可以参考官网快速开始,按照步骤配置即可
5、表单的操作(取消与确认按钮)
使用slot具名插槽
的方式,将表单的实例form
和表单数据model
,传给父组件
<el-form-item>
<slot name="action" :form='form' :model='model'></slot>
</el-form-item>
6、表单组件的整体(方法和属性传递)
这里我们把v-for 循环放到 template
, options
是我们从父组件得到的配置对象数据。
重点知识
像这样,使用具名插槽
将表单的实例
和绑定的model 数据进行传递
<el-form-item>
<slot name="action" :form='form' :model='model'></slot>
</el-form-item>
使用vue3新特性 defineExpose
方法暴露出子组件方法到父组件实例中使用
// 使用vue3的新api 分发方法,它替代了之前的$children方法
defineExpose({
resetFields,
validate,
getFormData
})
父组件使用
//设置form 为组件实例, ref="form"获取组件实例
<w-form ref="form" />
let form = ref()
form.value.resetFields()
script
的业务逻辑部分
我们引入了 lodash 函数工具库
和 wangeditor 富文本编辑器
。
<script lang="ts" setup>
import { PropType, ref, onMounted, watch, nextTick } from 'vue'
import { FormOptions, FormInstance } from './types/types'
import cloneDeep from 'lodash/cloneDeep'
import '@wangeditor/editor/dist/css/style.css'
import { createEditor, createToolbar, IEditorConfig, IDomEditor } from '@wangeditor/editor'
let props = defineProps({
// 表单的配置项
options: {
type: Array as PropType<FormOptions[]>,
required: true
},
// 用户自定义上传方法
httpRequest: {
type: Function
}
})
let emits = defineEmits([
'on-preview', 'on-remove', 'on-success', 'on-error',
'on-progress', 'on_change', 'before-upload', 'before-remove','on-exceed'
])
let model = ref<any>(null)
let rules = ref<any>(null)
let edit = ref()
let form = ref<FormInstance | null>()
// 初始化表单方法
let initForm = () => {
if(props.options && props.options.length){
let m: any = {username: '', password: ''}
let r: any = {}
props.options.map((item: FormOptions) => {
m[item.prop!] = item.value
r[item.prop!] = item.rules
// 初始化富文本编辑器
if(item.type === 'editor'){
nextTick(()=> {
if(document.getElementById('editor') && document.getElementById('toolbar')){
const editorConfig: Partial<IEditorConfig> = {}
editorConfig.placeholder = item.placeholder!
editorConfig.onChange = (editor: IDomEditor) => {
// 当编辑器选区、内容变化时,即触发
// console.log('content', editor.children)
// console.log('html', editor.getHtml())
// console.log('text', editor.getText());
model.value[item.prop!] = editor.getHtml()
}
// 创建编辑器
const editor = createEditor({
selector: '#editor',
config: editorConfig,
mode: 'default' // 或 'simple' 参考下文
})
// 创建工具栏
const toolbar = createToolbar({
editor,
selector: '#toolbar',
mode: 'default' // 或 'simple' 参考下文
})
edit.value = editor
}
})
}
})
model.value = cloneDeep(m)
rules.value = cloneDeep(r)
}
}
onMounted(() => {
initForm()
})
// 组件重写表单重置的方法
let resetFields = () => {
// 1、重置element-plus 的表单
form.value!.resetFields()
// 2、重置富文本编辑器的内容
if(props.options && props.options.length){
// 清空富文本内容
edit.value.clear()
}
}
// 表单验证
let validate = () => {
return form.value!.validate
}
// 获取表单数据
let getFormData = () => {
return model.value
}
// 使用vue3的新api 分发方法,它替代了之前的$children方法
defineExpose({
resetFields,
validate,
getFormData
})
// 监听父组件传递过来的options
watch(() => props.options, () => {
initForm()
},{ deep: true })
// 上传组件的所以方法
let onPreview = (file: File) => {
emits('on-preview', file)
}
// 以下为up上传组件的回调方法
let onRemove = (file: File, fileList: FileList) => {
emits('on-remove', { file, fileList })
}
let onSuccess = (response: any, file: File, fileList: FileList) => {
// 上传图片成功, 给表单上传项赋值
let uploadItem = props.options.find(item => item.type === 'upload')!
model.value[uploadItem.prop!] = { response, file, fileList }
emits('on-success', { response, file, fileList})
}
let onError = (err: any, file: File, fileList: FileList) => {
emits('on-error', { err, file, fileList })
}
let onProgress = (event: any, file: File, fileList: FileList) => {
emits('on-progress', { event, file, fileList })
}
let onChange = (file: File, fileList: FileList) => {
emits('on-change', { file, fileList })
}
let beforeUpload = (file: File) => {
emits('before-upload', file)
}
let beforeRemove = (file: File, fileList: FileList) => {
emits('before-remove', { file, fileList})
}
let onExceed = (file: File, fileList: FileList) => {
emits('on-exceed', { file, fileList })
}
</script>
上面的方法主要是来于elememt-plus 的 upload上传组件的内容
当然也不要忘记暴露组件到全局使用
import { App } from 'vue'
import wForm from './src/index.vue';
export default {
install(app: App) {
app.component('w-form',wForm)
}
}
六 、使用表单组件
主要内容数据是options的对象数据,也可以写在外部,然后再导入这里。
1、在页面中使用表单
<template>
<div>
<w-form
ref="form"
label-width="100px"
:options='options'
@on-change="handleChange"
@before-upload="handleBeforeUpload"
@on-preview="handlePreview"
@on-remove="handleRemove"
@before-remove="beforeRemove"
@on-success="handleSuccess"
@on-exceed="handleExceed"
>
<template #uploadArea>
<el-button type="primary" size="small">点击上传</el-button>
</template>
<template #uploadTip>
<div style="color:#ccc;font-size:12px;">
jpg/png files with a size less than 500KB.
</div>
</template>
<template #action="scope">
<el-button type="primary" @click="submitForm(scope)">提 交</el-button>
<el-button @click='resetForm'>重 置</el-button>
</template>
</w-form>
</div>
</template>
<script lang="ts" setup>
import { FormOptions, FormInstance} from '../../components/base/form/src/types/types'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'
let form = ref()
interface Scope {
form: FormInstance,
model: any,
}
let options: FormOptions[] =[
{
type: 'input',
value: '',
label: '用户名',
prop: 'username',
rules: [
{
required: true,
message: '用户名不能为空',
trigger: 'blur'
},
{
min: 2,
max:6,
message: '用户名在2-6位置之间',
trigger: 'blur'
}
],
attrs: {
clearable: true,
}
},
{
type: 'input',
value: '',
label: '密码',
prop: 'password',
rules: [
{
required: true,
message: '密码不能为空',
trigger: 'blur'
},
{
min: 6,
max:10,
message: '密码长度6-10位置之间',
trigger: 'blur'
}
],
attrs: {
showPassword: true,
clearable: true
}
},
{
type: 'select',
value: '',
placeholder: '请选择职位',
prop: 'role',
label: '职位',
attrs:{
style: {
width: '100%',
},
},
rules: [
{
required: true,
message: '职位不能为空',
trigger: 'blur'
}
],
children: [
{
type: 'option',
label: '经理',
value: '1'
},
{
type: 'option',
label: '主管',
value: '2'
},
{
type: 'option',
label: '销售',
value: '3'
}
]
},
{
type: 'checkbox-group',
value: [],
prop: 'like',
label: '爱好',
rules: [
{
required: true,
message: '爱好不能为空',
trigger: 'blur'
}
],
children: [
{
type: 'checkbox',
label: '足球',
value: '1'
},
{
type: 'checkbox',
label: '篮球',
value: '2'
},
{
type: 'checkbox',
label: '排球',
value: '3'
}
]
},
{
type: 'radio-group',
value: '',
prop: 'gender',
label: '性别',
rules: [
{
required: true,
message: '性别不能为空',
trigger: 'blur'
}
],
children: [
{
type: 'radio',
label: '男',
value: '1'
},
{
type: 'radio',
label: '女',
value: '2'
},
{
type: 'radio',
label: '保密',
value: '3'
}
]
},
{
type: 'upload',
label: '上传',
prop: 'pic',
uploadAttrs:{
action: 'https://jsonplaceholder.typicode.com/posts/',
multiple: true,
limit:3
},
rules: [
{
required: true,
message: '哈哈哈',
trigger: 'blur'
}
]
},
{
type: 'editor',
value: '',
prop: 'desc',
placeholder: '请输入内容hh',
label: '描述',
rules: [
{
required: true,
message: '描述不能为空',
}
]
}
]
let submitForm = (scope: Scope) => {
console.log('提交');
scope.form.validate((valid) => {
if(valid){
console.log(scope.model);
ElMessage.success('提交成功')
resetForm()
} else {
ElMessage.error('表单填写错误')
}
});
}
let resetForm = () => {
console.log('重置');
form.value.resetFields()
}
let handleRemove = (file: any, fileList: any) => {
console.log('handleRemove')
console.log(file, fileList)
}
let handlePreview = (file: any) => {
console.log('handlePreview')
console.log(file)
}
let beforeRemove = (val: any) => {
console.log('beforeRemove')
return ElMessageBox.confirm(`Cancel the transfert of ${val.file.name} ?`)
}
let handleExceed = (val: any) => {
console.log('handleExceed', val)
ElMessage.warning(
`The limit is 3, you selected ${val.files.length
} files this time, add up to ${val.files.length + val.fileList.length} totally`
)
}
let handleSuccess = (val: any) => {
console.log('success')
console.log(val)
}
let handleChange = (val: any) => {
console.log('change')
console.log(val)
}
let handleBeforeUpload = (val: any) => {
console.log('handleBeforeUpload')
console.log(val)
}
</script>
<style lang="" scoped>
</style>
效果动图
2、在弹出框中使用表单
我们封装了弹出框组件,将表单放在弹出框组件里使用,然后在页面再使用弹出框组件时,其实是由了三层嵌套的组件关系。
嵌套关系如图
弹出框框组件的代码
在弹出框组件中使用了封装的表单组件
<template>
<el-dialog
v-model="dialogVisible"
v-bind="$attrs"
>
<template #default>
<w-form
:options="options"
label-width="80px"
ref="form"
@on-change="onChange"
@before-upload="beforeUpload"
@on-preview="onPreview"
@on-remove="onRemove"
@before-remove="beforeRemove"
@on-success="onSuccess"
@on-exceed="onExceed"
>
<template #uploadArea>
<slot name="uploadArea"></slot>
</template>
<template #uploadTip>
<slot name="uploadTip"></slot>
</template>
</w-form>
</template>
<template #footer>
<slot name="footer" :form="form"></slot>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, watch, PropType } from "vue"
import { FormOptions } from '../../form/src/types/types'
let props = defineProps({
visible: {
type: Boolean,
defalut: false
},
// 表单的配置项
options: {
type: Array as PropType<FormOptions[]>,
required: true
},
// 下面方法对应为处理上传事件
onChange: {
type: Function
},
beforeUpload: {
type: Function
},
onPreview: {
type: Function
},
onRemove: {
type: Function
},
beforeRemove: {
type: Function
},
onSuccess: {
type: Function
},
onExceed: {
type: Function
},
})
let emits = defineEmits(['update:visible'])
let dialogVisible = ref<boolean>(props.visible)
let form = ref()
watch(() => props.visible, (val: any) => {
dialogVisible.value = val
})
watch(() => dialogVisible.value, (val: any) => {
emits('update:visible', val)
})
</script>
全局注册弹出框组件
import { App } from 'vue'
import modelForm from './src/index.vue';
export default {
install(app: App) {
app.component('w-model-form',modelForm)
}
}
在页面中使用弹出框组件
<template>
<div>
<el-button type="primary" @click='open'>open</el-button>
<w-model-form
title="编辑"
v-model:visible="visible"
:options="options"
:on-change="handleChange"
:on-success="handleSuccess"
>
<template #uploadArea>
<el-button size="small" type="primary">点击上传</el-button>
</template>
<template #uploadTip>
<div style="color:#ccc;font-size:12px;">
jpg/png files with a size less than 500KB.
</div>
</template>
<template #footer="{ form }">
<span class="dialog-footer">
<el-button @click="Cancel">取 消</el-button>
<el-button type="primary" @click="Confirm(form)">确 认</el-button
>
</span>
</template>
</w-model-form>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue"
import { FormOptions } from '../../components/base/form/src/types/types'
import { ElMessage } from 'element-plus'
let visible = ref<boolean>(false)
let open = () => {
visible.value = true
}
let Cancel = () => {
console.log('取消');
// visible.value = false
}
let Confirm = (form: any) => {
let validate = form.validate()
let model = form.getFormData()
validate((valid: any) => {
if(valid){
ElMessage.success('验证成功')
console.log(model);
visible.value = false
} else {
ElMessage.error('验证失败')
}
})
}
let options: FormOptions[] = [...略] // 这部分这里略了,可以看上面的options 或下载源码
let handleSuccess = (val: any) => {
console.log('success')
console.log(val)
}
let handleChange = (val: any) => {
console.log('change')
console.log(val)
}
</script>
效果动图
七 、代码地址
上面的代码难免贴不全,想学习的话可以,到如下地址下载噢!,也可以star一下哈,后面还会更新
。
八、总结
本节为表单组件的封装和弹出框组件的封装,再将封装好的表单组件嵌入弹出框组件使用。我们引入了element-plus
同款表单校验规则的async-validator
的rules
,根据表单的数据项options
封装了对应的数据类型接口types.ts
,用是否含有children选项
区别了表单的单一项和多级嵌套项,使用了upload组件
,引入了第三方插件wangeditor富文本编辑器
和lodash 函数库
按需引入了cloneDeep
方法。
用到的vue3知识点主要有父组件传值的defineProps
,子组件事件发射的defineEmits
,<component :is='组件'></component>
动态组件, 使用富文本编辑器时,用了nextTick
来获取回调更新前的实例, 用了defineExpose
它替代了之前的$children方法。
如果文章对你有帮助,不妨点赞、评论、关注~
【待续……】