你好,我是杨文坚。今天我们为自研组件库增加表单组件。
表单技术,日常业务运用非常广泛,除了常见的用户注册和登录场景,信息填写操作、信息编辑操作和上传文件图片等操作都是基于表单技术的。所以很多前端组件库都会实现相关的表单组件,提供给开发者使用,尽量减少表单的开发工作量。
而表单组件的实现,都是基于“受控组件”的技术概念来实现的。那什么是受控组件和非受控组件呢?
什么是受控组件和非受控组件?
“受控组件”和“非受控组件”,是谁最先提出来的,目前无从得知,比较流行的描述来自React官方官网。
受控组件,按照React官网的描述,就是用React.js 内部state来管理HTML表单的数据状态,同时也控制用户操作表单时的数据输入。这类被 React.js 以这种方式控制取值的表单输入元素就叫做“受控组件”。
非受控组件,React 官网的描述是这样的,“在大多数情况下,我们推荐使用受控组件来处理表单数据。在一个受控组件中,表单数据是由 React 组件来管理的。另一种替代方案是使用非受控组件,这时表单数据将交由 DOM 节点来处理”。
从描述可以得知,“受控组件”和“非受控组件”的技术概念,跟React.js等框架的关系耦合不大。简单来讲,“受控组件”就是通过内置统一状态或者数据管理来控制表单操作,而“非受控组件”就是使用原生HTML的表单特性来实现表操作。
因为这个技术概念,主要应用场景是表单场景,所以为了统一语义,避免产生歧义,我们对“受控组件”和“非受控组件”统一称为“受控表单组件”和“非受控表单组件”。而“受控组件”的理念,可以更优雅地设计实现表单组件的功能,也是业界Vue.js和React.js开发表单组件的首选技术方案,所以今天要实现的“表单组件”都是“受控表单组件”类型。
知道了两种表单组件的技术概念,那么两种组件各有什么优劣呢?在日常工作开发中又要怎么选择使用呢?
受控表单组件和非受控表单组件各有什么优劣?
我们直接看代码案例,用 Vue.js 3.x 实现一个注册表单的功能代码,做个实际的效果对比。
先用 Vue.js 3.x 基于非受控表单组件的概念实现一个注册表单:
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
| <template> <h1>这是一个非受控表单组件</h1> <form @submit="onSubmit"> <div> <span>用户名称:</span> <input ref="inputUserName" name="username" type="text" /> </div> <div> <span>密码:</span> <input ref="inputPassword" name="password" type="password" /> </div> <div> <span>确认密码:</span> <input ref="inputConfirmPassword" name="confirmPassword" type="password" /> </div> <div> <button>提交注册</button> </div> </form> </template>
<script setup lang="ts"> import { ref } from 'vue'; const inputUserName = ref<HTMLInputElement>(); const inputPassword = ref<HTMLInputElement>(); const inputConfirmPassword = ref<HTMLInputElement>();
const onSubmit = (e: Event) => { e.preventDefault(); const formData = { userName: inputUserName?.value?.value, password: inputPassword?.value?.value, confirmPassword: inputConfirmPassword?.value?.value }; window.alert(`提交数据:${JSON.stringify(formData)}`); }; </script>
|
然后再用 Vue.js 3.x 基于受控表单组件概念,实现一个注册表单:
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
| <template> <h1>这是一个受控表单组件</h1> <form @submit="onSubmit"> <div> <span>用户名称:</span> <input v-model="state.userName" type="text" /> </div> <div> <span>密码:</span> <input v-model="state.password" type="password" /> </div> <div> <span>确认密码:</span> <input v-model="state.confirmPassword" type="password" /> </div> <div> <button>提交注册</button> </div> </form> </template>
<script setup lang="ts"> import { reactive, toRaw } from 'vue'; const state = reactive({ userName: '', password: '', confirmPassword: '' });
const onSubmit = (e: Event) => { e.preventDefault(); const formData = toRaw(state); window.alert(`提交数据:${JSON.stringify(formData)}`); }; </script>
|
以上就是“非受控表单组件”和“受控表单组件”这两种表单组件的Vue.js 3.x代码实现方式,我们简单对比一下代码实现的复杂度和代码量:

如果我们在Vue.js 3.x环境里,再加上“数据监听”和“数据校验”这两个功能维度,会是怎样呢?

实现同样的注册表单的功能代码,受控表单组件的实现方式,明显比非受控表单组件更简单,而且更加方便数据字段的扩展。所以一般在开发组件库里的表单组件时,我们都基于“受控组件”的技术理念来开发“受控表单组件”。
但是,开发组件库的表单组件,使用“受控组件”的概念是远远不够的,组件库,一个很重要的优势是复用性,实际工作开发需求时,表单的功能需求是多种多样的。
举个例子,注册表单需要账号密码,输入框的表单要实现字段布局、数据定义和数据校验的操作,如果再加上一个协议同意的选择框,是不是表单布局需要改一下?表单校验函数也需要改一下?以此类推,每添加一个表单字段内容,都需要重复做一堆开发琐碎工作。
所以想要开发组件库里的表单组件,我们需要将“重复工作”抽象成组件库里的组件,提升表单开发的复用性和易用性,也就是要抽象表单通用逻辑,封装成统一受控的表单组件。那么,如何封装Vue.js 3.x的受控表单组件呢?
怎么封装Vue.js3.x的受控表单组件?
首先,我们要明白,传统表单是由一个个数据字段组成的,承载这些数据的显示和输入的是HTML里的<input>、<select>和<option>等元素。
这些元素,在组件库里承载表单字段里数据操作,通常有很多种称呼,例如“表单数据组件”“数据输入组件”“数据录入组件”,为了避免歧义,表单里类似<input>、<select>等承载表单数据功能的组件,我们就统一称为“表单数据组件”。同时,承载表单提交的能力的组件,比如HTML里<form>标签类似功能组件,我们就称为“表单组件”。
统一好组件库里各类组件的称呼,接下来,我们就开始分析如何封装Vue.js 3.x的受控表单组件,主要为三步。
**第一步,梳理出表单操作中可复用的逻辑。**表单操作可以复用的逻辑是什么呢?
我们先来看看表单的主要操作,“数据显示”“数据输入”“数据校验”和“数据提交”这四个操作逻辑。
数据显示和输入,不同表单数据组件的操作会有差异。比如,<input>是输入内容,类似“填空题”,<select>是选择数据内容,类似“选择题”。这相当于两种不同类型“题目”,它们的“答题卷”都是分开的,无法统一。但,我们可以从这两种操作中抽象出一个共性,定义数据的字段名称,也就是在表单数据里定义这个数据的名称。
因此,这里我们就梳理出了第一个可复用逻辑,数据字段名称。
接下来是表单里的“数据校验”操作逻辑。数据校验的核心就是数据在“改变”和“提交”这两个时间点做处理,不受不同类型表单数据组件的用户使用方式差异的影响。那么我们可以再抽象出一个复用逻辑,就是数据校验。
小结一下,表单场景里,我们可以复用的逻辑是“数据字段名称”和“数据校验”,那接下来就要实现这个逻辑复用的实际功能组件了。
第二步,把可以复用的逻辑功能封装成通用表单逻辑功能组件。“数据字段名称”和“数据校验”操作,都是跟着作用于每个表单里的“数据字段”的。通常在英文语境里,我们称呼这个表单的数据字段为“Field”,在此我们就统一用中文描述为“表单字段”。
现在要做什么就清晰了,我们要实现一个通用的“表单字段组件”,来统一管理“表单数据组件”里的“字段名称”和“数据校验”,同时也要“辅助”支持“表单组件”提交数据时候做统一的数据字段收集和统一的提交前校验,这个“辅助操作”我们后面再详讲。
**第三步,也就是实现代码的阶段。**我们先画图设计一下实现流程,“表单字段组件”其实就是“表单数据组件”和“表单组件”之间的“桥梁”:

那我们再进一步细化“表单字段组件”发挥的具体的桥梁作用:

表单字段组件,给表单数据组件传递“字段名称”和“字段校验规则”,给表单组件也是传递“字段名称”和“字段校验规则”。
通过两张图,我们可以总结出一个实现代码的要素,需要一个“上下文”来给整个表单共享“字段名称”和“字段校验规则”的内容,而“表单字段组件”只是作为一个入口,在使用时帮忙把“字段名称”和“字段校验规则”注册到这个共享内容的“上下文”里。
那么,要在Vue.js 3.x里实现跨组件的数据共享,有哪些方式呢?
还记得前面我们讲过的内容不?在Vue.js 3.x里实现跨组件的数据共享,有Props结合Emits组件间数据通信、有用共享响应式数据文件方式,还有引入Pinia这个数据状态管理库等多种方式,但是实现起来太麻烦了。
我这里选择了Vue.js 3.x 的新特性, provide和inject。我们可以在父级组件用provide定义一个“共享数据”及其名称,在子组件里用inject,通过这个数据名称拿到这个父级组件的“共享数据”。如果这个“共享数据”是“响应式数据”类型,我们在子组件里修改这个“共享数据”就可以触发响应式特性,影响到父组件和其他子组件的操作。反之,如果“共享数据”是“普通变量数据”,子组件里是无法修改影响父组件的。
好,我们现在就来实现具体的代码。
先定义各种数据类型:
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
| export interface FormInstance { addField(field: FormItemContext): void; }
export interface FormItemInstance { validateField(): Promise<ValidateResult>; }
export interface FormItemContext extends FormItemInstance { label?: string; name?: string; rule?: ValidateRule; }
export interface FormContext extends FormInstance { model?: { [key: string]: unknown; }; formInstance?: FormInstance; }
export interface ValidateResult { hasError: boolean; name?: string; value?: unknown; message?: string; }
export interface ValidateRule { validator?: (value: unknown) => ValidateResult | Promise<ValidateResult>; }
|
然后实现表单组件的代码,也是父级组件,用provide提供了一个“共享数据”:
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
| <template> <form :class="{ [className]: true }"> <slot /> </form> </template>
<script lang="ts" setup> import { reactive, provide } from 'vue'; import { prefixName } from '../theme'; import { FORM_CONTEXT_KEY } from './common'; import type { FormInstance, FormContext, FormItemContext } from './types'; const className = `${prefixName}-form`;
const props = defineProps<{ model?: FormContext['model'] }>();
const fieldList: FormItemContext[] = [];
const addField = (field: FormItemContext) => { fieldList.push(field); };
const formContext = reactive<FormContext>({ model: props.model, addField });
provide<FormContext>(FORM_CONTEXT_KEY, formContext);
defineExpose<FormInstance>({ addField }); </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
| <template> <Row :class="{ [baseClassName]: true }"> <Row :class="labelClassName"> <Col :span="labelCol"> <span>{{ props.label }}</span> </Col> <Col :span="wrapperCol"><slot /></Col> </Row> <Row :class="wrapperClassName" v-if="props.name"> <Col :span="labelCol"></Col> <Col :span="wrapperCol"> <span v-if="errorTip" :style="{ fontSize: 12, color: 'red' }">{{ errorTip }}</span> </Col> </Row> </Row> </template>
<script lang="ts" setup> import { inject, onMounted, ref, toRaw, watch } from 'vue'; import Col from '../col'; import Row from '../row'; import { prefixName } from '../theme'; import { FORM_CONTEXT_KEY } from './common'; import type { FormItemInstance, FormContext, ValidateRule, ValidateResult } from './types';
const labelCol = 8; const wrapperCol = 16;
const baseClassName = `${prefixName}-form-item`; const labelClassName = `${baseClassName}-label`; const wrapperClassName = `${baseClassName}-wrapper`;
const errorTip = ref<string>('');
const formContext: FormContext | undefined = inject<FormContext>(FORM_CONTEXT_KEY);
const props = defineProps<{ name?: string; label?: string; rule?: ValidateRule; }>();
async function validateFieldValue(val: unknown): Promise<ValidateResult> { if (props.rule?.validator) { const result = await props.rule?.validator?.(val); if (result.hasError && result.message) { errorTip.value = result.message; } else { errorTip.value = ''; } return { ...result, ...{ name: props.name, value: toRaw(val) } }; } return { hasError: false }; }
async function validateField(): Promise<ValidateResult> { if (props.rule?.validator && props.name) { const result = await validateFieldValue(formContext?.model?.[props?.name]); if (result.hasError && result.message) { errorTip.value = result.message; } else { errorTip.value; } return result; } return { hasError: false }; }
onMounted(() => { if (formContext?.model && props.name && formContext?.model?.[props?.name]) { formContext?.addField({ name: props.name, rule: props.rule, validateField });
watch([() => formContext?.model?.[props.name as string]], ([newValue]) => { validateFieldValue(newValue); }); } });
defineExpose<FormItemInstance>({ validateField }); </script>
|
可以看出,这个表单字段组件,作为表单里子组件的身份使用时,将“字段名称”和“字段校验规则”存入统一上下文的“共享数据” (formContext)。
表单字段组件里,能根据字段名称,从共享数据里拿到当前管理的“字段数据”,就是包裹在表单数据组件里的数据。拿到这个字段数据和字段数据校验规则,我们可以在表单字段组件内部,监听这个数据变化,实现实时校验和提醒用户是否数据填写正确。
现在我们实现一个表单字段校验功能效果:
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
| <template> <div class="example"> <Form ref="formRef" :model="model"> <FormItem label="数据1(数字校验)" name="data1" :rule="rule1"> <input v-model="model.data1" /> </FormItem> <FormItem label="数据2(字母校验)" name="data2" :rule="rule2"> <input v-model="model.data2" /> </FormItem> </Form> </div> </template>
<script setup lang="ts"> import { ref, reactive } from 'vue'; import { Form } from '../src'; import { FormInstance } from '../src';
const { FormItem } = Form; const formRef = ref<FormInstance>(); const model = reactive<{ data1: string; data2: string }>({ data1: '123', data2: 'abc' });
const rule1 = { validator: (val: string) => { const hasError = /^[0-9]{1,}$/gi.test(`${val || ''}`) !== true; return { hasError, message: hasError ? '仅支持0-9的数字' : '' }; } };
const rule2 = { validator: (val: string) => { const hasError = /^[a-z]{1,}$/gi.test(`${val || ''}`) !== true; return { hasError, message: hasError ? '仅支持a-z的大小写字母' : '' }; } }; </script>
|
代码是用表单组件、表单字段组件集合表单数据组件(原生HTML的<input>标签)实现的一个表单的实时数据校验,最终实现效果如动图所示:

好了,我们已经用表单组件里通用的“表单字段组件”,来管理每个“表单数据组件”,也就是实现了“桥梁”的一端,接下来就是要连接“桥梁”的另一端,也就是“表单组件”的提交数据的统一校验。
如何给封装的受控表单组件做统一提交校验?
在HTML里,原生的表单标签<form>是支持统一的submit事件的。我们现在要做的是,拦截这个表单事件,在提交数据前,从“共享数据”里拿到校验规则和所有数据来做数据校验,校验不通过就阻断表单提交。
具体代码如下:
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
| <template> <form :class="{ [className]: true }" @submit="handleSubmit"> <slot /> </form> </template>
<script lang="ts" setup> import { reactive, provide, toRaw } from 'vue'; import { prefixName } from '../theme'; import { FORM_CONTEXT_KEY } from './common'; import type { FormInstance, FormContext, FormItemContext } from './types'; const className = `${prefixName}-form`;
const props = defineProps<{ model?: FormContext['model'] }>();
const fieldList: FormItemContext[] = [];
const addField = (field: FormItemContext) => { fieldList.push(field); };
const resetFields = () => { fieldList.forEach((field) => { field?.resetField(); }); };
const formContext = reactive<FormContext>({ model: props.model, addField, resetFields });
provide<FormContext>(FORM_CONTEXT_KEY, formContext);
defineExpose<FormInstance>({ addField, resetFields });
const emits = defineEmits<{ (event: 'submit', e: Event): void; (event: 'finish', e: unknown): void; (event: 'finishFail', e: unknown): void; }>();
const validateFields = async () => { const errorList = []; for (let i = 0; i < fieldList.length; i++) { const field = fieldList[i]; const result = await field?.validateField(); if (result?.hasError) { errorList.push(result); } } return errorList; };
const handleSubmit = (e: Event) => { e.stopPropagation(); e.preventDefault(); emits('submit', e); if (props.model) { validateFields() .then((errorList) => { if (errorList.length > 0) { emits('finishFail', errorList); } else { emits('finish', toRaw(props.model)); } }) .catch((e) => { emits('finishFail', e); }); } }; </script>
|
好了,至此我们就实现了一个完整的“受控表单组件”的基本组件内容,更多详细的代码实现细节,你可以查看完整代码案例。
总结
这节课我们主要学习了如何在Vue.js 自研组件库场景下实现“受控表单组件”,有两个掌握重点。
第一个重点“什么是受控组件和非受控组件”,我们了解了“受控组件”的技术概念,同时如何在Vue.js 3.x的框架环境,实现它描述的“受控”能力。
当然,你也要知道,这个技术概念并不是React.js或者Vue.js专有的,任何Web框架,只要能以统一的“数据状态”来代替HTML原生能力管理表单,就可以算是“受控组件”概念的技术实现。
第二个重点“如何抽象表单组件的复用逻辑”。这里你会发现最终抽选出来的逻辑核心就是“表单数据校验”。没错,表单最复杂、最核心的一个逻辑就是“数据校验”,如果以后你遇到要实现表单场景的功能,我希望你把数据校验操作作为首要的技术考虑点。
思考题
表单组件除了劫持代理“submit”事件,还有其它的方式来管理表单提交数据的操作吗?
欢迎你留言参与讨论,如果有疑问也欢迎评论,下一讲见。