从头说起
在管理后台的项目里,最基础的需求就是增删改查,最最常见的页面结构就是一个表格,可以进行新增,删除,编辑。
这四个基础功能中查和删还好说,其中增和删,目前最常见的做法是,弹出一个窗口,窗口中有一个表单,然后点击确定提交,成功后关闭窗口,刷新表格。
这个东西使用的频率非常高,大部分时候结构都很类似,input select radio等等组成的表单,需要进行校验必填项,校验成功开始提交(网络请求)...在Element中大致是这样写的:
js
<template>
<div>
<el-button type="primary" @click="visible = true">新增</el-button>
<el-dialog v-model="visible" title="Title">
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleSubmit(formRef)">
<el-form-item label="Name" prop="name">
<el-input v-model="form.name" placeholder="请输入内容"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit">确 定</el-button>
<el-button @click="visible = false">取 消</el-button>
</el-form-item>
</el-form>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
const form = reactive({
name: ''
})
const visible = ref(false)
const rules = {
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' }
]
}
const formRef = ref(null)
const handleSubmit = async ref => {
try {
await ref.validate()
console.log('submit', form)
} catch (error) { }
}
</script>
这样就能写出一个最最简单的新增表单了
接下来还要写一个编辑表单,如果有需要的话,可能还要写一个查看表单,其实他们都大同小异,但是我不得不在每个页面的代码中书写大量雷同的Dialog和内容,要管理各个窗口的显示隐藏,然后在另一个页面复制粘贴,这样就延伸出来几个问题:
- 代码冗余且类似,总是写这样的东西一点提不起劲。
- 增加和编辑表单可能完全相同,可能略有区别,但是一旦将来需要调整,就要每个地方都改,增加风险和负担。
- 因为大部分情况结构类似,很多时候会增加和编辑共用一个窗口,然后或者根据点击的按钮来改变title,改变文字和提示等等,但是!如果业务简单还好说,一旦情况越来越复杂,需要查看,需要禁用某几项,需要。。。这个窗口组件就会越来越臃肿,越来越难以修改。
因此为了解决这些不爽的点,我详细说一下我封装FormModal(其实并没有那么准确,因为使用时必须要搭配一个useFormModal)的思路和过程。
封装思路
展示组件和容器组件
这个思路是已经被证明了的解决方案,这里我不详细说了,它大致是这样的:
展示组件只管如何展示,根据容器组件传递过来的数据来展示不同内容,展示组件不管提交逻辑,只抛出事件,让容器组件来决定点击确定时进行的不同网络请求,这样只需要根据需求写出一个个不同的容器组件(新增,编辑,查看),就能做到逻辑分离,不互相干扰,改一个容器组件的逻辑完全不会影响到另一个,如果表单展示有变化,修改展示组件即可。
那么代码大致应该是这样的:
js
// 展示组件
<template>
<el-dialog v-model="visible" :title="title">
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleSubmit(formRef)">
<el-form-item label="Name" prop="name">
<el-input v-model="form.name" placeholder="请输入内容"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit">{{ submitText }}</el-button>
<el-button @click="visible = false">取 消</el-button>
</el-form-item>
</el-form>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps(['rules', 'title', 'submitText'])
const emit = defineEmits(['finish'])
const formRef = ref(null)
const visible = defineModel('visible')
const form = defineModel('form')
const handleSubmit = async ref => {
try {
await ref.validate()
emit('finish')
} catch (error) { }
}
</script>
js
// 容器组件
<template>
<DemoView title="新增" v-model:visible="visible" v-model:form="form" submitText="新增" @finish="handleFinish" />
</template>
<script setup>
import DemoView from './DemoView.vue';
const emit = defineEmits(['finish'])
const visible = defineModel('visible')
const form = defineModel('form')
const handleFinish = () => {
setTimeout(() => {
// 新增成功
visible.value = false
emit('finish')
}, 1000);
}
</script>
js
// 页面
<template>
<div>
<el-button type="primary" @click="addVisible = true">新增</el-button>
<AddDemoView v-model:visible="addVisible" v-model:form="form" @finish="handleAdd" />
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import AddDemoView from './components/AddDemoView.vue';
const form = reactive({
name: ''
})
const addVisible = ref(false)
const handleAdd = () => {
console.log('refresh table');
}
</script>
这样与理想中的设计似乎有偏差,各种状态仍然需要页面来维护,本该写在容器组件中的新增或编辑后的逻辑仍然需要写到页面中,为什么呢?因为这是一个Modal承载的Form,需要在点击新增的时候改变visible,需要在提交请求结束时,刷新页面中的其他数据,还有不少细节要处理,但是虽然如此,看起来也挺像那么回事的了。。吗?
思考
到这里其实已经可以投入使用了,但是它其实并没有解决我不爽的点,那就是它 写起来实在太麻烦了!!,每一个页面都要创建至少三个文件(展示组件、新增容器、编辑容器),大家为什么总是在页面中写一个Dialog的原因之一,就是这个Form的复杂程度在很多时候其实也并不会很大,这个展示组件和容器组件的思路用来解决复杂的Form是合理的,但是很多情况下使用这种写法似乎有点大材小用,且相同的代码也不少,所以我也在想:
我希望的使用方式是什么呢?
我希望我既可以使用展示组件和容器组件的设计,又不用写大量重复的代码,只需要写一个展示组件,简化容器组件的部分,且页面中尽可能少和简单的使用这些FormModal组件,于是我先写下了一个我希望的使用方式:
js
<template>
<div>
<el-button type="primary" @click="openAdd()">新增</el-button>
<el-button type="primary" @click="openView()">查看</el-button>
<el-button type="primary" @click="openEdit({ name: 'foo' })">编辑</el-button>
</div>
<AddModal />
<ViewModal />
<EditModal />
</template>
<script setup>
import TemplateForm from './components/TemplateForm.vue';
import useFormModal from '@/hooks/useFormModal';
const [
{ component: AddModal, open: openAdd },
{ component: ViewModal, open: openView },
{ component: EditModal, open: openEdit },
] = useFormModal(TemplateForm, [
{ title: '新增', finish: handleAdd, successMsg: '新增成功' },
{ title: '查看', showFooter: false, templateData: { disabledit: true } },
{ title: '编辑', finish: handleEdit, successMsg: '编辑成功' },
])
</script>
TemplateForm是展示组件,我希望那里面没有需要重复书写的代码,useFormModal可以使用它和第二个参数来简化写容器组件的过程,然后它返回容器组件,可以直接放入页面中,还提供了一个打开Modal的方法open。
下面来一步一步实现吧。
我希望的展示组件
我希望它能有明确定义的数据来源,而不是有隐式的只有我自己才知道写法;它可以很自由,就像正常写在页面里最原始写Form一样;
那么它也许应该是这个样子:
js
<template>
<FormModal>
<template #default="{ form, templateData, resetFields }">
<el-form-item label="name" prop="name">
<el-input v-model="form.name" :disabled="templateData.disabledit" />
</el-form-item>
</template>
</FormModal>
</template>
<script setup>
import FormModal from '@/components/FormModal.vue';
</script>
在FormModal的默认插槽会传递来form和templateData,templateData就是之前在容器组件中定义的各种展示规则和数据,也就是useFormModal第二个参数中定义的数据,resetFields在简单form中用不太到,通常在需要几个表单项联动时可能会用到。
之后每个页面的某一个增、改、看等需求只需要写一个这个组件就可以了。
反向推导出FormModal
有了使用的需求后,其实可以发现,并不是我在封装FormModal组件,而是需求在告诉我那应该是什么样子,我觉得这个FormModal组件也许应该是这个样子:
js
<template>
<el-dialog :model-value="_visible" :title="title" @closed="resetFields()" :before-close="syncVisible">
<el-form :model="_form" :rules="rules" :label-width="labelWidth" ref="formRef"
@submit.prevent="handleSubmit(formRef)" v-loading="loading">
<slot :form="_form" :templateData="readonly(templateData)" :resetFields="resetFields" />
<el-form-item v-if="showFooter">
<slot name="footer" :resetFields="resetFields" :close="close">
<el-button v-if="showSubmit" type="primary" native-type="submit">{{ submitText }}</el-button>
<el-button v-if="showCancel" type="primary" @click="close">{{ cancelText }}</el-button>
</slot>
</el-form-item>
</el-form>
</el-dialog>
</template>
<script setup>
import { ref, readonly } from 'vue';
import useVModel from '@/hooks/useVModel';
const props = defineProps({
visible: Boolean,
title: String,
loading: Boolean,
labelWidth: {
type: String,
default: '130px'
},
form: {
type: Object,
default: () => ({})
},
rules: {
type: Object,
default: () => ({})
},
templateData: {
type: Object,
default: () => ({})
},
submitText: {
type: String,
default: '确定'
},
cancelText: {
type: String,
default: '取消'
},
showSubmit: {
type: Boolean,
default: true
},
showCancel: {
type: Boolean,
default: true
},
showFooter: {
type: Boolean,
default: true
},
})
const emit = defineEmits(['update:visible', 'update:form', 'finish'])
const _visible = useVModel(props, 'visible', emit)
const _form = useVModel(props, 'form', emit)
const formRef = ref(null)
const handleSubmit = async (ref) => {
try {
await ref.validate()
emit('finish')
} catch (error) { }
}
const syncVisible = (close) => {
_visible.value = false
close()
}
const resetFields = fields => {
formRef.value.resetFields(fields)
}
const close = () => {
_visible.value = false
}
</script>
这里面有一些需要注意的细节:
为什么visible要这样处理
在上面 展示组件和容器组件 那里,是这样处理visible的
js
<el-dialog v-model="visible">
</el-dialog>
const visible = defineModel('visible')
这里是使用了vue3.4之后的defineModel语法糖,它实际上等同于之前这样的写法
js
const props = defineProps({
visible: Boolean,
})
const emit = defineEmits(['update:visible'])
const _visible = computed({
get: () => props.visible,
set: val => emit('update:visible', val)
})
为了保证单向数据流,子组件是不能直接修改父组件传来的props的,遵循谁拥有数据谁修改数据的原则,由子组件抛出一个事件,父组件去修改,在3.4之前,为了省事,也会用vueuse中的useVModel来做这种操作: const _visible = useVModel(props, 'visible', emit)
但是这里有一个问题,那就是Element的Dialog组件,不管你是设置v-model="visible"
,还是设置:model-value="visible"
,在点击背景的遮罩区域或右上角的关闭按钮时,都会直接关闭这个dialog,没办法保证和父组件visible的一致,但是Dialog提供了一个参数before-close,可以用这个参数来拦截关闭Dialog的行为,所以可以间接的在这里去同步visible保持一致:
js
<el-dialog :model-value="_visible" :before-close="syncVisible"></el-dialog>
const syncVisible = (close) => {
_visible.value = false
close()
}
除此之外,我这里使用的import useVModel from '@/hooks/useVModel';
,并不是vueuse的useVModel, 具体下面说。
form和useVModel
同样的,之前也是使用了defineModel
来处理的form,这里似乎也一样可以使用计算属性抛出事件的方式来处理?不是的,form和visible的区别在于,visible是基本类型Boolean,form却是一个对象,如果直接写form = newForm
,那确实是会触发update:form,但是form.name = newName
却并不会,就像用const定义了一个对象const form = {}
,按理说他代表了不可被修改(form = {}
会报错),但是我们可以随意修改其中的属性form.foo = 'bar'
而不会有任何问题,在表单里会把form的属性用v-model绑定给input等组件,那么这些子组件就可以绕过父组件随便的修改父组件的数据了。所以为了维护单项数据流的原则,在之前使用计算属性的基础上,写了一个可以拦截对象的useVModel:
js
// useVModel
import { computed } from "vue";
export default function (props, propName, emit) {
return computed({
set: (value) => emit(`update:${propName}`, value),
get: () => {
if (typeof props[propName] !== 'object') {
return props[propName];
}
return new Proxy(props[propName], {
set(target, key, value) {
emit(`update:${propName}`, { ...target, [key]: value });
return true;
},
get(target, key) {
return Reflect.get(target, key);
},
})
}
})
}
其原理简单讲就是使用Proxy代理了访问的对象,在子组件修改数据时会调用form.foo = 'bar'
,那么就会被代理拦截,返回一个新的对象并抛出update事件,网上讲这个的有很多就不细说了。这里因为既然不使用defineModel了为了方便都使用useVModel,那就照顾到基本属性,在使用时如果不是对象类型的就直接返回。
PS :这里其实还有一个问题,那就是无法覆盖form[propName]
为数组的情况。
其他
为什么@closed="resetFields()"
?
如果不处理的话会有几个小问题,在新增时,如果在表单中输入了某些值,但是并未提交,而是关闭了窗口,那么再次打开时数据还会残留,并且当使用了数据校验rules时,触发了显示错误提示再关闭,点开还是会保留错误提示,所以直接用使用el-form的resetFields方法,在关闭窗口时既重置了form,又清除了错误提示。
为什么将templateData转换成readonly?
只是表明展示组件拿到的关于展示的数据是不应该被修改的,是一种含义上的声明,不这样功能也没什么区别。
其他的就是按照使用的方式,将form,templateData等绑定给插槽,让展示组件可以拿到数据和操作表单的方法。
useFormModal
之前说过,我希望的useFormModal的作用就是,帮助简化创建容器组件,所以我觉得它也许应该是这个样子:
js
import { ref, h, defineComponent, reactive, toRaw } from "vue";
import { ElMessage } from "element-plus";
export default function (Template, TemplateDatas = []) {
return TemplateDatas.map(({
title = 'Title',
finish = () => { },
templateData,
submitText,
cancelText,
successMsg = '操作成功',
successShowMsg = true,
showFooter = true,
showSubmit = true,
showCancel = true,
}) => {
const visible = ref(false);
const form = reactive({})
const loading = ref(false);
return {
component: defineComponent({
name: Template.__name + 'Container',
setup() {
return () => h(Template, {
visible: visible.value,
loading: loading.value,
title,
form,
templateData,
submitText,
cancelText,
successMsg,
showFooter,
showSubmit,
showCancel,
'onUpdate:visible': value => visible.value = value,
'onUpdate:form': value => Object.assign(form, value),
'onFinish': () => {
loading.value = true;
// 解构原始对象浅拷贝给finish作参数
const { ...formData } = toRaw(form);
finish(formData, () => {
if (successShowMsg) ElMessage.success(successMsg)
loading.value = false;
visible.value = false;
})
}
})
},
}),
open: (initForm = {}) => {
Object.assign(form, initForm);
visible.value = true;
}
}
})
}
根据传入的模板组件(展示组件)和容器组件属性数组(第二个参数),返回一个可以直接挂载在template中的组件,和打开弹窗的方法。
因为vue的模板本质上就是渲染函数的语法糖,用js本来也能创建组件,所以可以使用vue中的 h 函数去创建虚拟dom, 这里做的事情,就是之前单独在容器组件中所做的,由容器组件维护visible、form、loading,将展示逻辑和数据传给展示组件。
Template就是之前写的展示组件,h 的第二个参数可以将props传入给Template,这里需要说明的是,为什么明明Template组件中并没有定义这些props,也没有手动的将props传递给引用的FormModal,在创建容器组件时却可以将这些props传给FormModal?因为vue3中默认会有 Attributes 继承,它指的是当一个组件以单个元素为根作渲染时,透传的 attribute 会自动被添加到根元素上,这里展示组件Template里,根组件就是FormModal, 所以FormModal能直接得到props。
js
<template>
<FormModal>
...
</FormModal>
</template>
onFinish里,会调用容器组件属性中的finish函数,并且将form的原始对象返回用于调用提交的网络请求, 还返回了一个用于关闭窗口的方法,当点击FormModal的提交按钮时,会自动让form进入loading,在页面处理完提交请求调用关闭窗口的方法时,自动弹出成功提示(可以配置是否弹出,成功的文字),关闭loading并且关闭弹窗.
返回给页面使用的open方法,在调用时可以传入一个初始化form去改变容器组件的form,通常的使用方式就是:
js
<el-table>
<el-table-column label="操作">
<template #default="{ scope }">
<el-button type="primary" @click="openEdit(scope)">编辑</el-button>
</template>
</el-table-column>
</el-table>
注意 :因为是在open中传入初始化form,所以在<el-button @click="openAdd">新增</el-button>
中不能直接这样写,会将event传入,而是要写成@click="openAdd()"
,第二如果某个属性是数组类型,需要在open时为其初始化openAdd({ ids: [] })
思考
到这里为止封装过程就结束了,这套写法也许没有完全的遵守了展示组件和容器组件的理论,对于我而言却是最能偷懒的方式,既然页面中无论如何都要去处理提交的逻辑,那索性就全写在页面中,而把重复的处理loading等部分接管起来,所以在写提交的方法时,代码也不是很多:
js
const handleAdd = async (formData, close) => {
const res = await request(url, formData)
close()
// 执行刷新操作(例如table)
}
同时页面中也并不需要去处理各种弹窗显示的逻辑,挂载的Modal直接放在那里就行,不需要给它传入什么属性。其中的form部分新建一个模板组件,不用重复的写el-dialog和el-form,直接写form的组件即可,并且这个模板组件可以简单也可以处理一些复杂的逻辑。下面我将一些常用的业务需求写法展示一下。
用例
校验
js
// 1
<FormModal :rules="rules">
// 2 这种方式还可以根据templateData来进行rules的细分
<FormModal>
<el-form-item label="name" prop="name" :rules="[{ required: true, message: 'Please input name', trigger: 'blur' }]"></el-form-item>
</FormModal>
表单联动
js
<template #default="{ form, resetFields }">
<el-form-item label="a" prop="a">
<el-select v-model="form.a" @change="$event => handleChange($event, resetFields)">
<el-option label="1" :value="1" />
<el-option label="2" :value="2" />
</el-select>
</el-form-item>
<el-form-item v-if="form.a === 2" label="b" prop="b">
<el-input v-model="form.b" />
</el-form-item>
</template>
<script setup>
// ...
const handleChange = (value, resetFields) => {
if (value === 1) {
resetFields('b')
}
}
</script>
如果b没有校验,可以简单的在handleChange中将form.b = ''
,如果有的话就要用resetFields
加载数据
js
const options = ref([])
onMounted(() => {
// 模拟数据请求
setTimeout(() => {
options.value = [
{ value: 1, label: 'a' },
{ value: 2, label: 'b' },
{ value: 3, label: 'c' },
]
}, 1000);
})
const [
{ component: AddModal, open: openAdd },
] = useFormModal(TemplateForm, [
{ title: '新增', finish: handleAdd, templateData: { options } }, // options是响应式数据
])
// template
<el-form-item label="select" prop="id">
<el-select v-model="form.id">
<el-option v-for="item in templateData.options" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
远程搜索
js
// template
<el-form-item label="remote" prop="remoteId">
<el-select v-model="form.remoteId" placeholder="Select" filterable remote :remote-method="remoteMethod" :loading="loading">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<script setup>
// ...
const loading = ref(false)
const options = ref([])
// maybe debounce
const remoteMethod = async query => {
loading.value = true
const res = await request(url, query)
options.value = res.data
loading.value = false
}
</script>
动态表单
js
// template
<el-form-item v-for="(domain, index) in form.domains" :key="domain.key" :label="'Domain' + index" :prop="'domains.' + index + '.value'">
<el-input v-model="domain.value" />
<el-button @click.prevent="removeDomain(domain, form)">Delete</el-button>
</el-form-item>
<el-button @click="addDomain(form)">New domain</el-button>
// script
const removeDomain = (item, form) => {
const index = form.domains.indexOf(item)
if (index !== -1) {
form.domains.splice(index, 1)
}
}
const addDomain = (form) => {
if (!form.domains) {
form.domains = []
}
form.domains.push({
key: Date.now(),
value: '',
})
}
PS : 这里需要注意,上面也说过了,useVModel无法覆盖到form属性是数组的情况,所以会出现添加了动态表单项,关闭窗口却无法重置这种现象,这时候可以在打开弹窗的open方法中添加初始化form来解决openAdd({ domains: [] })
携带隐藏数据
js
// template
<el-form-item label="id" prop="id">
<el-select v-model="form.id" placeholder="Select" @change="$event => handleChange($event, form)">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="id2" prop="id2">
<el-select v-model="form.id2" placeholder="Select" @change="$event => handleChange2($event, form, templateData)">
<el-option v-for="item in templateData.options" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
// script
const options = [
{ value: 1, label: 'a' },
{ value: 2, label: 'b' },
{ value: 3, label: 'c' },
]
const handleChange = (value, form) => {
form.name = options.find(item => item.value === value).label
}
const handleChange2 = (value, form, templateData) => {
form.name2 = templateData.options.find(item => item.value === value).label
}