13 动态表单组件:怎么优雅地动态渲染表单?
你好,我是杨文坚。
上节课,我们学习Vue.js 3.x自研组件库的“受控表单组件”,开发了“表单字段组件”来辅助提高开发表单的效率。但是,在实际的企业级项目中,业务需求总是“紧急”且“多变”的,表单类的开发需求,不能只靠一个“受控表单组件”的能力来提效。我们看个常见例子。
假设你接到一个业务需求,要开发一个“用户信息设置”的页面,让注册用户可以编辑自己的个人信息,常规开发步骤,我们一般是用表单组件来封装这个用户信息配置的功能。
但接下来业务需求变了,业务方要做用户类型的区分,不同类型的用户显示“不同个人信息配置”,比如这里就有“普通用户”、“多种等级会员用户”的信息配置,后续可能会新增其他维度类型的用户信息配置。这时候,你还能用常规的表单开发思路,来开发这个需求吗?
我们简单分析一下,如果按常规开发思路,每新增一个用户类型,就要重新开发一个用户信息表单来支持业务需求,工作量就是无底洞。所以问题就来了,是否有一种表单方案,能够通过简单的自定义配置,快速生成对应的表单功能呢?
答案是有的,就是“动态表单组件”。这节课,我们就来学习如何基于Vue.js 3.x,开发自研组件库里的“动态表单组件”。
什么是动态表单?
“动态表单”,顾名思义,就是能“动态”生产想要功能的“表单”。
在前端技术视角里,动态表单概念在十几年的jQuery时代就有了,可以用“简单配置”方式来“动态生成表单”,通过一个JSON的数据形式来描述表单格式,再通过JavaScript代码,根据数据描述渲染出表单。
可以看出,动态表单,核心就是通过自定义数据来动态生成自定义表单。也就是说,面对实际的开发需求时,每当新增一个表单类型的需求,我们只需要配置一下“数据”就能生成表单,不需要从零开始来开发。
这类技术方案在前端开发中经常用到,例如,开发后台页面场景时,不同类型用户身份,信息录入需要渲染多种表单;搭建页面场景时,动态生成调查问卷页面,让用户可以配置不同数据来生成不同问题内容的表单,这个过程无需投入额外的前端开发工作,用户可以自助配置数据生成问卷的表单。
那么,“动态表单”到底怎么实现呢?一步到位肯定不怎么现实,所以我们先从一个最简单的动态表单开始,先来看如何用Vue.js3.x实现一个最简单的动态表单。
如何用Vue.js3.x实现最简单的动态表单?
在动手实现之前,我们先分析一下,表单哪些内容可以统一进行动态管理。
上节课我们讲过,表单核心是由一个个表单字段组成,每个表单字段背后都是一个个表单数据组件。那么,动态表单,其实就是按照动态数据的内容,生成对应“表单数据组件”的各种“组合“的“结果”。实现一个最简单的表单组件,其实就是根据自定义数据,来自定义生成表单数据组件的各种组合。
实现步骤现在就很清晰了:
- 第一步,列举要用到的表单数据组件,例如 input,radio等;
- 第二步,定义描述动态表单的数据格式;
- 第三步,根据数据格式来渲染动态表单。
来看代码:
<template>
<form class="dynamic-form" @submit="onSubmit">
<div class="dynamic-form-title">{{ schema.title }}</div>
<div
class="dynamic-form-field"
v-for="(field, index) in schema.fieldList"
v-bind:key="index"
>
<div class="dynamic-form-label">{{ field.label }}:</div>
<div v-if="field.fieldType === 'input'" class="dynamic-form-item">
<input v-model="model[field.name]" />
</div>
<div v-else-if="field.fieldType === 'radio'" class="dynamic-form-item">
<span
v-for="(option, index) in field.options"
v-bind:key="index"
class="dynamic-form-option"
>
<input
type="radio"
:id="option.value"
:name="field.name"
:value="option.value"
:checked="model[field.name] === option.value"
@change="
onRadioChange({ fieldName: field.name, value: option.value })
"
/>
<label :for="option.value">{{ option.name }}</label>
</span>
</div>
<div v-else class="dynamic-form-item"></div>
</div>
<div>
<button class="dynamic-form-btn" type="submit">提交</button>
</div>
</form>
</template>
<script setup lang="ts">
import { reactive, toRaw } from 'vue';
const schema = {
title: '普通用户信息',
filedList: [
{
label: '用户名称',
name: 'usename',
fieldType: 'input'
},
{
label: '手机号码',
name: 'phone',
fieldType: 'input'
},
{
label: '收货地址',
name: 'address',
fieldType: 'input'
}
]
};
const model = reactive<{ [key: string]: unknown }>({});
schema.fieldList.forEach((field) => {
model[field.name] = '';
});
const onRadioChange = (data: { fieldName: string; value: string }) => {
model[data.fieldName] = data.value;
};
const onSubmit = (e: Event) => {
e.preventDefault();
window.alert(JSON.stringify(data));
};
</script>
<style> /* 样式代码省略,请看代码案例 */ </style>
我先定义了描述动态表单的数据格式 schema,然后,把动态表单的描述数据,按照数组形式进行管理,根据描述数据在数组里的排列形式,按需渲染出表单内容。
这里,schema定义的动态表单数据,是一个“普通用户”的数据,表单的动态渲染结果如下图所示:
这个简单的动态表单,内部支持了两种表单数据组件,input输入框和radio单项选择,现在我们把schema修改一下,改成“会员用户信息”的数据,代码如下:
const schema = {
title: '会员用户信息',
fieldList: [
{
label: '用户名称',
name: 'usename',
fieldType: 'input'
},
{
label: '手机号码',
name: 'phone',
fieldType: 'input'
},
{
label: '优惠服务',
name: 'service',
fieldType: 'radio',
options: [
{ name: '免运费', value: 'service001' },
{ name: '9折优惠', value: 'service002' },
{ name: '满80减10', value: 'service003' }
]
},
{
label: '收货地址',
name: 'address',
fieldType: 'input'
}
]
}
这里的“会员用户”表单描述数据,比“普通用户”的多了一个“优惠服务”的表单描述字段,动态表单渲染效果如下:
这两个表单功能,其实出于同一个动态表单的代码,我们只是将表单描述数据schema做对应差异修改,就能直接渲染出对应不同的表单功能。
通过,这样一个简单的Vue.js 3.x动态表单实现,我们可以总结出动态表单实现的三个核心要素:
- 第一点,梳理要用到的表单数据组件;
- 第二点,根据表单数据组件种类制定数据格式;
- 第三点,根据数据格式的内容,来渲染表单数据组件的自定义组合,这个自定义组合就是所需要的表单结果。
我们用Vue.js实现了一个简单的动态表单,实际业务需要的动态表单功能可不只这些,我们还要考虑表单校验逻辑、扩展新的表单数据组件等等,这需要一个更完善的动态表单组件。
如何设计和实现完善的动态表单组件?
在具体实现之前,我们先设计动态表单组件的规范。这个规范除了能满足现有的功能需要,还需要有前瞻性的设计,保证能扩展“新的表单动态组件”,不能局限于一开始约定的表单数据组件。
按照要求,再结合实现最简单的动态表单的核心要素,我重新梳理了四点实现要素:
- 定义表单数据组件的统一API;
- 定义默认支持的数据表单组件;
- 支持自定义表单字段的校验规则;
- 支持根据统一API扩展自定义表单数据组件。
我们来具体分析一下每一点要素。
第一点,定义表单数据组件的统一API。由于动态表单核心是由各种“表单数据组件”的组成,但是,每个表单数据组件,都有各自原生的API使用方式,这些API的差异会降低兼容性。所以,我们需要约定好统一的表单数据组件的API,对不同表单数据组件做API统一封装。
第二点,定义默认支持的数据表单组件。常用的表单数据组件要在动态表单内默认支持,所以我们要用统一的API进行二次封装,并内置到动态表单组件内。这里表单数据组件的API统一代码如下所示:
const props = defineProps<{
// 传入的数据值
value: string | any;
// 组件内部选项参数,例如多选框,单选框,下拉框的选项数据
options: Array<{ name: string; value: string }>;
}>();
const emits = defineEmits<{
// 监听组件内部数据变化事件
(e: 'change', value: string): void;
}>();
第三点,支持自定义表单字段和校验规则。上节课我们说了,抽象表单里中最重要的复用逻辑就是“表单校验”,当时我演示了如何封装一个“表单字段组件”作为“表单数据组件”的外壳,统一管理字段校验规则。所以,自定义表单校验规则,对动态表单来讲也很重要,我们可以把上节课的表单字段组件,沿用到我们的动态表单组件里,统一管理表单校验。
第四点,支持根据统一API扩展自定义表单数据组件。
既然要实现动态表单组件,我们就不能只支持默认表单数据组件的表单生成。毕竟,如果不能扩展新的表单数据组件,以后有新的表单需求,要用到自定义的表单数据组件,动态表单组件就不能快速配置生成表单了,需要我们从零开发一个支持自定义数据组件的表单。这就失去动态表单原本提效的意义了。
所以,我们这里需要支持可扩展自定义表单数据组件,但有个前提,就是自定义表单数据组件要按照第一点要素的内容,封装统一的API。
好了,那么我们现在来根据四点要素,实现动态表单组件,看代码:
<template>
<div :class="{ [baseClassName]: true }">
<Form>
<Form
ref="formRef"
:model="internalModel"
@finish="onFinish"
@finishFail="onFinishFail"
>
<FormItem
v-for="(field, index) in fieldList"
:key="index"
:label="field.label"
:name="field.name"
:rule="field.rule"
>
<component
:is="registerComponentMap[field.fieldType]"
:value="internalModel[field.name]"
:options="field.options || []"
@change="(value: unknown) => { onFieldChange({ name: field.name, value }) }"
/>
</FormItem>
<Row v-if="$slots.default">
<slot></slot>
</Row>
</Form>
</Form>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { prefixName } from '../theme';
import Row from '../row';
import Form from '../form';
import Input from '../input';
import RadioList from '../radio-list';
import type { Component } from 'vue';
import type { DynamicFormField } from './common';
// 内置支持的表单数据组件
const registerComponentMap: { [key: string]: Component } = {
Input: Input,
RadioList: RadioList
};
const props = withDefaults(
defineProps<{
fieldList?: DynamicFormField[];
model?: { [name: string]: unknown };
}>(),
{}
);
const internalModel = reactive<{ [name: string]: unknown }>(props?.model || {});
const FormItem = Form.FormItem;
const baseClassName = `${prefixName}-dynamic-form`;
const onFieldChange = (event: { name: string; value: string | unknown }) => {
internalModel[event.name] = event.value;
};
const emits = defineEmits<{
(event: 'finish', e: unknown): void;
(event: 'finishFail', e: unknown): void;
}>();
const onFinish = (e: unknown) => {
emits('finish', e);
};
const onFinishFail = (e: unknown) => {
emits('finishFail', e);
};
</script>
上述代码中,我设计了动态表单的数据格式,通过数组一一对应来描述表单字段内容的。每个表单字段的数据描述有:表单的标签名称、字段名称、字段类型、字段使用数据组件的类型和校验规则。
我们根据实现的动态表单组件,来生成一个可校验的“普通用户”信息编辑表单,代码如下所示:
<template>
<div class="example">
<DynamicForm
:model="model"
:fieldList="fieldList"
@finish="onFinish"
@finishFail="onFinishFail"
>
<Button type="primary">提交信息</Button>
</DynamicForm>
</div>
</template>
<script setup lang="ts">
import { Button, DynamicForm } from '../src';
import type { DynamicFormField } from '../src';
const model = {
username: 'Hello',
phone: '123456',
address: '',
service: ''
};
const fieldList: DynamicFormField[] = [
{
label: '用户名称',
name: 'username',
fieldType: 'Input',
rule: {
validator: (val: unknown) => {
const hasError = /^[a-z]{1,}$/gi.test(`${val || ''}`) !== true;
return {
hasError,
message: hasError ? '仅支持a-z的大小写字母' : ''
};
}
}
},
{
label: '手机号码',
name: 'phone',
fieldType: 'Input',
rule: {
validator: (val: unknown) => {
const hasError = /^[0-9]{1,}$/gi.test(`${val || ''}`) !== true;
return {
hasError,
message: hasError ? '仅支持0-9的数字' : ''
};
}
}
},
{
label: '快递地址',
name: 'address',
fieldType: 'Input',
rule: {
validator: (val: unknown) => {
const hasError = `${val}`?.length === 0;
return {
hasError,
message: hasError ? '地址不能为空' : ''
};
}
}
}
];
const onFinish = (e: any) => {
// eslint-disable-next-line no-console
console.log('success =', e);
};
const onFinishFail = (e: any) => {
// eslint-disable-next-line no-console
console.log('fail =', e);
};
</script>
<style>
html,
body {
height: 100%;
width: 100%;
}
.example {
width: 400px;
padding: 16px;
margin: 20px auto;
box-sizing: border-box;
border-radius: 4px;
border: 1px solid #999999;
font-size: 14px;
}
.btn {
height: 32px;
padding: 0 20px;
min-width: 100px;
}
</style>
代码在浏览器里的演示效果如图:
我再改变一下自定义数据,生成一个可校验的“会员用户”信息编辑表单,代码如下所示:
const fieldList: DynamicFormField[] = [
{
label: '用户名称',
name: 'username',
fieldType: 'Input',
rule: {
validator: (val: unknown) => {
const hasError = /^[a-z]{1,}$/gi.test(`${val || ''}`) !== true;
return {
hasError,
message: hasError ? '仅支持a-z的大小写字母' : ''
};
}
}
},
{
label: '手机号码',
name: 'phone',
fieldType: 'Input',
rule: {
validator: (val: unknown) => {
const hasError = /^[0-9]{1,}$/gi.test(`${val || ''}`) !== true;
return {
hasError,
message: hasError ? '仅支持0-9的数字' : ''
};
}
}
},
{
label: '快递地址',
name: 'address',
fieldType: 'Input',
rule: {
validator: (val: unknown) => {
const hasError = `${val}`?.length === 0;
return {
hasError,
message: hasError ? '地址不能为空' : ''
};
}
}
},
{
label: '会员服务',
name: 'service',
fieldType: 'RadioList',
options: [
{ name: '免运费', value: 'service001' },
{ name: '9折优惠', value: 'service002' },
{ name: '满80减10', value: 'service003' }
],
rule: {
validator: (val: unknown) => {
const hasError = `${val}`?.length === 0;
return {
hasError,
message: hasError ? '优惠不能为空' : ''
};
}
}
}
];
上述使用代码的动态表单渲染如下图所示:
这两个不同的表单内容,是通过输入不同的表单配置得来的。
第一个表单是在动态表单组件里输入“普通用户”的表单配置数据(用户名称、手机号码和快递地址),渲染了普通用户的表单,也实现了对应表单的校验功能。
第二个表单,输入“会员用户”的表单配置数据(用户名称、手机号码、快递地址和会员服务),其中“会员服务”的配置数据是一个“单选表单数据组件”,附带了可选择的数据,渲染了一个与输入框不一样的表单数据组件。同时,所有表单的字段也配置了校验数据,自动地实现动态校验功能。
好,到这里,我们已经通过动态数据,大致实现了动态渲染表单和动态校验的功能。
但是,在完善动态表单的要素中,我们说的最后一点就是,要支持自定义表单数据组件的扩展,那么要怎么基于现在完善的动态表单组件,实现可扩展的动态表单组件呢?
如何实现可扩展的动态表单
从前面完善的表单组件可以看出,默认支持的表单数据组件,都是内部定义好的,存放在内部的一个对象里,这就意味着,要扩展其他自定义表单数据组件,把相应的组件配置进去就可以了。
这时候,我们需要一个“配置”的过程,一般称为“注册”,首先就是自定义表单数据组件的注册。而表单数据组件需要统一使用的API,也就是说,我们还需要先封装好自定义组件,再把自定义表单数据组件,给注册到统一的动态表单里。
实现的代码如下:
<template>
<div :class="{ [baseClassName]: true }">
<Form>
<Form
ref="formRef"
:model="internalModel"
@finish="onFinish"
@finishFail="onFinishFail"
>
<FormItem
v-for="(field, index) in fieldList"
:key="index"
:label="field.label"
:name="field.name"
:rule="field.rule"
>
<component
:is="registerComponentMap[field.fieldType]"
:value="internalModel[field.name]"
:options="field.options || []"
@change="(value: unknown) => { onFieldChange({ name: field.name, value }) }"
/>
</FormItem>
<Row v-if="$slots.default">
<slot></slot>
</Row>
</Form>
</Form>
</div>
</template>
<script setup lang="ts">
// 中间省略上述演示过的代码 ....
// 内置支持的表单数据组件
const registerComponentMap: { [key: string]: Component } = {
Input: Input,
RadioList: RadioList
};
// 中间省略上述演示过的代码 ....
// 注册自定义表单数据组件方法
const registerFieldComponent = (name: string, component: Component) => {
registerComponentMap[name] = component;
};
// 暴露可以注册自定义表单数据组件
defineExpose<{
registerFieldComponent: typeof registerFieldComponent;
}>({
registerFieldComponent
});
</script>
代码中,我给动态表单组件添加了一个“外用方法”registerFieldComponent,把子自定义表单组件,注册到动态表单里。你可以从代码的注释中看出,registerFieldComponent 注册组件方法和内置组件缓存变量registerComponentMap的关系。
通过registerFieldComponent方法,我们可以把自定义组件缓存到registerComponentMap变量里,等待后续渲染表单时候使用。也就是说,外部组件,可以直接通过这个方法来操控动态表单,将自定义组件注入到表单中。后续,只要动态的配置数据里用到了这个自定义组件,就会自动渲染出来。
至此,我们就从API设计到组件扩展层面,实现了一个完整的动态表单组件。
总结
通过这节课的学习,相信你应该已经理解了什么是动态表单,以及如何基于Vue.js 3.x开发自研组件库里的动态表单组件。我们总结几个重要的概念和技术实现。
动态表单的要素:
- 能通过自定义数据来配置生成表单渲染;
- 能支持扩展其他表单数据组件来扩展表单能力;
“动态表单组件”的核心技术实现就是,通过数据来动态渲染所需的表单数据组件,以及可以自定义其他数据组件。具体的实现步骤:
- 第一步,需要定义好统一的表单数据组件的API,封装好默认支持的表单数据组件;
- 第二步,定义动态表单的配置数据格式,并且做好可以扩展的数据格式设计;
- 第三步,根据配置数据来渲染描述的表单数据组件,以及用表单字段组件进行统一管理;
- 第四步,开发自定义表单数据组件的的注册能力;
思考题
动态表单能实现多种表单数据组件的渲染,那么不同表单数据组件能否做联动操作的功能配置?
欢迎积极参与讨论,如果有疑问,也欢迎在留言区留言,我会尽快回复。下一讲见。
完整的代码在这里
- Akili 👍(2) 💬(1)
动态表单 联动怎么做呢
2023-02-17 - ifelse 👍(0) 💬(0)
学习打卡
2024-09-10