大家好,我是全栈开发鹏飞,在一家 IT 人力外包公司待了十多年的老码农,主要负责前后端架构上的维护。目前正在为某知名保险平台做核心数据中台的前端架构上的升级,正好借此机会,跟大家分享下动态表单这块我的实现心得。
一说到数据维护平台这类后台管理系统,很多前端开发小伙伴都有类似的经历,要开发许多表单页面,涉及复杂的布局或交互。手写页面维护比较麻烦,有些公司会考虑一些低代码工具,或者自己封装表单配置方式。对于低代码,往往找不到合适的产品,因为这类平台往往提供云服务,要求客户系统将数据也迁过去,且升级维护都要依赖第三方。因此较多客户技术团队会采用自己封装动态表单的形式,来一定程度上简化表单的开发。
而对于我这边负责的数据中后台的前端,自然我会考虑自研一个基于 Vue3 原生机制驱动的动态表单渲染引擎,目前已经初始完成了一个支持复杂表单内字段联动的版本。本篇主要给大家介绍下动态表单中如何配置弹框组件,这里以保险项目中一个最基本的投保人、被保人和受益人信息维护为例,来介绍表单的配置用法。
表单示例
如下是一个比较简单的投保单填写表单示例,这里咱们将表单信息分了几个部分,视图和数据模型都有所体现,且视图和数据都是同步的,同投保人 开关用于控制被保人信息的显隐藏联动,受益人列表 可增删,页面效果如下:
对如上这样表单包括后续迭代的复杂需求,咱们将使用自研的 JuanForm 表单引擎,仅仅通过 schema 的配置和扩展形式就能实现复杂的表单(甚至整个路由页面)需求。下面咱们就来看下页面配置这块。
页面配置方式
目前页面引入和使用表单引擎组件,再加上 ts 的 schema 配置即可,非常简单。
<script setup lang="ts">
import JuanForm from '@/form/JuanForm.vue'
import { formSchema } from '@/views/formSchema.ts'
</script>
<template>
<JuanForm :schema="formSchema" />
</template>
我们着重关注配置这块,这里咱们单独用一个 ts 文件来维护下,整体的配置结构如下,咱们简单理解下这里的配置:
FormSchema 最外层为:{ ... },这里的配置对象有一个层(Layer)的概念,类似于 Vue 作用域所在的层一样。可以在层配置对象上配置 dependsOn 来对这一层数据进行监听处理,页面上渲染的字段具有行列布局,位于 children 配置项下。
栅格布局
这里的栅格布局我们设计的很灵活,一行上的列宽按照行总列数的占比来计算,总列数做到行级可配置,默认为 16 栅格,也可以自行指定,比如:15、18,列(字段)上除了指定具体列数的占比,也可以设置为 auto 来自动分配剩余的宽度,而一行上都按数字来分配后发现超出栅格总列数,则自动换到下一行,来满足流式布局。
schema 结构
继续回到配置,items 中可以包含具体配置项类型,包括:与字段对应的基础控件类型、Group 组类型、Dialog 弹框类型甚至用户自定义扩展等等。
import ...
export const formSchema: FormSchema = {
children: [
{
items: [
{
prop: 'applicant',
label: '投保人',
type: 'Group',
col: 'auto',
children: [
{
cols: 18,
items: [
{prop: 'name'...},
{prop: 'phone'...},
{prop: 'age'...},
],
},
{
items: [
{prop: 'address'...},
],
},
],
},
],
},
{
items: [
{
prop: 'insured',
label: '被保人',
type: 'Group',
col: 'auto',
children: [
{
cols: 18,
items: [
{prop: 'sameAsApplicant'...},
{prop: 'name'...},
{prop: 'age'...},
],
},
],
},
],
},
{
items: [
{
prop: 'beneficiaries',
label: '受益人',
type: 'List',
col: 'auto',
key: 'id',
children: [ ... ],
},
{label: '添加受益人'...},
],
},
],
}
内置 Dialog 类型
本章主要给大家介绍弹框类型,其他特性的配置后续会有单独的章节来做介绍。这在市面上一些开源的表单引擎中,schema 多半只覆盖输入控件和校验,弹框通常要在页面上再包一层组件,和表单配置是分开维护的。咱们这边把 Dialog 做成和 Group、List 同级的配置项,直接写在行的 items 里,打开、关闭也由这一层来负责,不必再单独做一套弹框页面。
封装形式
先了解下内置的 Dialog 组件类型是如何封装的,这里咱们提供了一个类似于 FormKit表单引擎 的 createInput 方法来封装任何用于表单配置的控件类型,不管是组件的模板还是属性、方法,咱们都提供了类的封装、继承、扩展方式,如下是 Dialog 控件类型的封装代码:
createInput('Dialog', {
component: defineComponent({
props: {
field: { type: Object as PropType<DialogConfig>, required: true },
layer: { type: Object as PropType<JuanLayer>, required: true },
},
render() {
return h(
ElDialog,
{
modelValue: true,
title: this.field.label,
onClose: () => this.close(),
},
this.paint({
default: () => h(JuanLayerView, { layer: this.layer, rows: this.field.children }),
footer: () =>
h(
ElButton,
{
type: 'primary',
nativeType: 'button',
onClick: () => {
this.flushData()
this.close()
},
},
() => '确定',
),
}),
)
},
}),
methods: {
close() {
this.layer.closeDialog(this.field.name)
},
flushData() {
this.layer.flushDataDialog(this.field.name)
},
},
create() {
console.log('init')
},
})
这里可以看到,视图这块咱们用的 Vue 的 defineComponent 形式来构造组件,模板采用 render 与 h 渲染函数,UI 直接使用 ElementPlus 的 ElDialog 组件。props 上声明了 field 和 layer,配置和这一层的数据都从这里取。插槽通过 this.paint({ default, footer }) 收集进来,default 里用 JuanLayerView 去渲染弹框自己的 children 所对应的 body 内容,footer 里是 确定 按钮,点击后先 flushData 把弹框中的数据修改同步到目标层,再 close 关闭弹框。
整体定义形式类似 Vue 的 SFC(单文件组件)结构,方法写在外面的 methods 里,初始化写在 create 里,this 上能直接调用这些方法,这就是类的形式。模板、插槽、属性、方法都在 createInput 中封装,后续扩展自己的控件类型也是这一套语法糖配置,对于存在的类型,通过继承机制,可以实现模板插槽、属性以及方法的复用、覆盖和灵活扩展。
弹框示例
再回到表单示例,现在咱们要实现投保人和受益人信息的弹框修改方式,对这样的需求无需手写页面接入弹层以及处理数据同步,咱们的 JuanForm 表单引擎已内置了这块支持,仅需页面表单配置做如下更新:
import ...
export const formSchema: FormSchema = {
children: [
{
items: [
{
...
children: [
...
{
items: [
{
label: '修改投保人',
type: 'Button',
col: 6,
props: {
nativeType: 'button',
onClick: (scope: ScopeState) => {
scope.$open!('editApplicant')
},
},
},
],
},
],
},
{
name: 'editApplicant',
type: 'Dialog',
label: '修改投保人',
children: [
{
cols: 18,
items: [
{prop: 'name'...},
{prop: 'phone'...},
{prop: 'age'...},
],
},
{
items: [
{prop: 'address'...},
],
},
],
},
],
},
...
{
items: [
{
...
children: [
{
items: [
...
{
label: '修改',
type: 'Button',
col: 2,
props: {
nativeType: 'button',
onClick: (scope: ScopeState) => {
scope.$open!('editBeneficiary')
},
},
},
...
],
},
],
},
...
{
name: 'editBeneficiary',
type: 'Dialog',
label: '修改受益人',
children: [
{
cols: 18,
items: [
{prop: 'name'...},
{prop: 'relation'...},
{prop: 'share'...},
],
},
],
},
],
},
],
}
按照上面配置更新 schema 后,页面效果立马显现了,一起来看下,修改按钮都渲染出来了:
点击 修改投保人 按钮,会把对应 group 中的数据刷到弹框的控件中,注意这仅是拷贝,点 确定 关闭弹层,数据才会写回来
同样,修改受益人信息
再回到 schema 配置。Dialog 和 Group 差不多,都是 label 加上 children,弹框里的字段仍然按行来写。不同的是弹框不用 prop 去绑数据,而是用 name 来标识,页面上通过 $open 传入这个名字即可打开。这里调用弹框的按钮和要写回的数据就在同一层,所以 $open 不必再传第二个参数来指定要写回哪一层。
扩展 Dialog
除了内置弹框组件这种默认编辑并刷回数据的通用功能,还可以由用户按照实际需要,用同样的 createInput 去继承 Dialog,做出自己的弹框类型。比如投保人信息编辑这块支持把一段文本识别成姓名、手机和地址,这就不是内置弹框直接能覆盖的场景了,咱们扩展一个 Ocr 类型就可以:
createInput('Ocr', {
extends: 'Dialog',
slots: {
default: {
$el: 'div',
attrs: { class: 'w-full' },
children: [
[
{
prop: 'ocrText',
label: '粘贴文本',
type: 'Textarea',
col: 'auto',
props: { placeholder: '粘贴姓名、手机、地址,例如:张三 13800138000 北京市朝阳区' },
},
],
],
},
footer: {
$el: 'div',
children: [
[
{
label: '清空识别',
type: 'Button',
col: 6,
props: {
nativeType: 'button',
onClick: (scope: ScopeState) => {
scope.ocrText = ''
scope.name = ''
scope.phone = ''
scope.address = ''
},
},
},
],
{ $base: 'footer' },
],
},
},
methods: {
flushData() {
const text = (this.state.ocrText as string).trim()
const phone = text.match(/1\d{10}/)?.[0]
const parts = (phone ? text.replace(phone, ' ') : text).trim().split(/\s+/)
this.state.name = parts[0]
this.state.phone = phone
this.state.address = parts.slice(1).join(' ')
this.parent.flushData!()
},
},
})
可以看到,Ocr 通过 extends: 'Dialog' 继承了内置弹框,模板这块用 slots 来覆盖。主体对应 default 插槽,这里整段换成识别用的文本框。底部对应 footer 插槽,咱们自己加了 清空识别 按钮,同时又用 $base: 'footer' 把父类型原来的确定按钮接回来,自己扩展的按钮和原先的底部拼在一起,这就是灵活组装,不是把底部整段完全覆盖。
方法上也是子类继承父类。flushData 在子类里重写,先把文本拆到姓名、手机、地址上,再通过 this.parent.flushData() 调用父类原来的写回,识别逻辑放在子类,刷回仍然走父类,这就实现了子类扩展父类方法的目的。
页面上的用法和内置弹框一样,给这个扩展类型起个 name,用 $open 打开。弹框体在扩展里已经带好了,children 可以先空着:
{
items: [
{
label: '修改投保人',
...
},
{
label: '识别填写',
type: 'Button',
col: 6,
props: {
nativeType: 'button',
onClick: (scope: ScopeState) => {
scope.$open!('ocr')
},
},
},
],
},
{
name: 'ocr',
type: 'Ocr',
label: '识别填写',
children: [],
},
按钮仍然写在投保人这一层里,所以 $open 同样只传弹框的名字。这样点 识别填写,贴上文本再确定,投保人对应字段就会被填上。
写在最后
通过以上的实践,咱们再来总结和展望动态表单的自研,核心是配置形式和扩展方式。尤其弹框这一能力,很多表单渲染组件尚不具备,咱们已经具备,并且有很好的扩展机制。弹框能够配置、能够扩展之后,表单就不再停留于字段排列,复杂交互也可以纳入同一套配置之中。
有了这种能力,就可以很容易地封装和扩展其它复杂的、可定制的组件,从而完善表单引擎,更好地服务于复杂表单,乃至整个路由页面的配置能力。后续无论是新增业务控件,还是将整个页面交由配置来表达,都建立在同一套能力之上,表单引擎才能逐步完善。
再借助于低代码拖拽以及 AI 生成平台,进一步往无码方向推进。配置和扩展使能力保留在自研引擎之中,拖拽和 AI 才具备编排与生成的基础;无码并不是更换一套云平台,而是让自研动态表单具备生成和拖拽的条件,从而更好地支撑数据中后台中数量多、结构复杂的页面。