你好,我是杨文坚。
上节课,我们学习Vue.js 3.x自研组件库的“受控表单组件”,开发了“表单字段组件”来辅助提高开发表单的效率。但是,在实际的企业级项目中,业务需求总是“紧急”且“多变”的,表单类的开发需求,不能只靠一个“受控表单组件”的能力来提效。我们看个常见例子。
假设你接到一个业务需求,要开发一个“用户信息设置”的页面,让注册用户可以编辑自己的个人信息,常规开发步骤,我们一般是用表单组件来封装这个用户信息配置的功能。
但接下来业务需求变了,业务方要做用户类型的区分,不同类型的用户显示“不同个人信息配置”,比如这里就有“普通用户”、“多种等级会员用户”的信息配置,后续可能会新增其他维度类型的用户信息配置。这时候,你还能用常规的表单开发思路,来开发这个需求吗?
我们简单分析一下,如果按常规开发思路,每新增一个用户类型,就要重新开发一个用户信息表单来支持业务需求,工作量就是无底洞。所以问题就来了,是否有一种表单方案,能够通过简单的自定义配置,快速生成对应的表单功能呢?
答案是有的,就是“动态表单组件”。这节课,我们就来学习如何基于Vue.js 3.x,开发自研组件库里的“动态表单组件”。
什么是动态表单?
“动态表单”,顾名思义,就是能“动态”生产想要功能的“表单”。
在前端技术视角里,动态表单概念在十几年的jQuery时代就有了,可以用“简单配置”方式来“动态生成表单”,通过一个JSON的数据形式来描述表单格式,再通过JavaScript代码,根据数据描述渲染出表单。
可以看出,动态表单,核心就是通过自定义数据来动态生成自定义表单。也就是说,面对实际的开发需求时,每当新增一个表单类型的需求,我们只需要配置一下“数据”就能生成表单,不需要从零开始来开发。
这类技术方案在前端开发中经常用到,例如,开发后台页面场景时,不同类型用户身份,信息录入需要渲染多种表单;搭建页面场景时,动态生成调查问卷页面,让用户可以配置不同数据来生成不同问题内容的表单,这个过程无需投入额外的前端开发工作,用户可以自助配置数据生成问卷的表单。
那么,“动态表单”到底怎么实现呢?一步到位肯定不怎么现实,所以我们先从一个最简单的动态表单开始,先来看如何用Vue.js3.x实现一个最简单的动态表单。
如何用Vue.js3.x实现最简单的动态表单?
在动手实现之前,我们先分析一下,表单哪些内容可以统一进行动态管理。
上节课我们讲过,表单核心是由一个个表单字段组成,每个表单字段背后都是一个个表单数据组件。那么,动态表单,其实就是按照动态数据的内容,生成对应“表单数据组件”的各种“组合“的“结果”。实现一个最简单的表单组件,其实就是根据自定义数据,来自定义生成表单数据组件的各种组合。
实现步骤现在就很清晰了:
- 第一步,列举要用到的表单数据组件,例如 input,radio等;
- 第二步,定义描述动态表单的数据格式;
- 第三步,根据数据格式来渲染动态表单。
来看代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| <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修改一下,改成“会员用户信息”的数据,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| 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统一代码如下所示:
1 2 3 4 5 6 7 8 9 10 11
| const props = defineProps<{ value: string | any; options: Array<{ name: string; value: string }>; }>();
const emits = defineEmits<{ (e: 'change', value: string): void; }>();
|
第三点,支持自定义表单字段和校验规则。上节课我们说了,抽象表单里中最重要的复用逻辑就是“表单校验”,当时我演示了如何封装一个“表单字段组件”作为“表单数据组件”的外壳,统一管理字段校验规则。所以,自定义表单校验规则,对动态表单来讲也很重要,我们可以把上节课的表单字段组件,沿用到我们的动态表单组件里,统一管理表单校验。
第四点,支持根据统一API扩展自定义表单数据组件。
既然要实现动态表单组件,我们就不能只支持默认表单数据组件的表单生成。毕竟,如果不能扩展新的表单数据组件,以后有新的表单需求,要用到自定义的表单数据组件,动态表单组件就不能快速配置生成表单了,需要我们从零开发一个支持自定义数据组件的表单。这就失去动态表单原本提效的意义了。
所以,我们这里需要支持可扩展自定义表单数据组件,但有个前提,就是自定义表单数据组件要按照第一点要素的内容,封装统一的API。
好了,那么我们现在来根据四点要素,实现动态表单组件,看代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
| <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>
|
上述代码中,我设计了动态表单的数据格式,通过数组一一对应来描述表单字段内容的。每个表单字段的数据描述有:表单的标签名称、字段名称、字段类型、字段使用数据组件的类型和校验规则。
我们根据实现的动态表单组件,来生成一个可校验的“普通用户”信息编辑表单,代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
| <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) => { console.log('success =', e); };
const onFinishFail = (e: any) => { 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>
|
代码在浏览器里的演示效果如图:

我再改变一下自定义数据,生成一个可校验的“会员用户”信息编辑表单,代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| 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,也就是说,我们还需要先封装好自定义组件,再把自定义表单数据组件,给注册到统一的动态表单里。
实现的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| <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,封装好默认支持的表单数据组件;
- 第二步,定义动态表单的配置数据格式,并且做好可以扩展的数据格式设计;
- 第三步,根据配置数据来渲染描述的表单数据组件,以及用表单字段组件进行统一管理;
- 第四步,开发自定义表单数据组件的的注册能力;
思考题
动态表单能实现多种表单数据组件的渲染,那么不同表单数据组件能否做联动操作的功能配置?
欢迎积极参与讨论,如果有疑问,也欢迎在留言区留言,我会尽快回复。下一讲见。