12 受控表单组件:如何开发受控的表单组件?
你好,我是杨文坚。今天我们为自研组件库增加表单组件。
表单技术,日常业务运用非常广泛,除了常见的用户注册和登录场景,信息填写操作、信息编辑操作和上传文件图片等操作都是基于表单技术的。所以很多前端组件库都会实现相关的表单组件,提供给开发者使用,尽量减少表单的开发工作量。
而表单组件的实现,都是基于“受控组件”的技术概念来实现的。那什么是受控组件和非受控组件呢?
什么是受控组件和非受控组件?
“受控组件”和“非受控组件”,是谁最先提出来的,目前无从得知,比较流行的描述来自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 基于非受控表单组件的概念实现一个注册表单:
<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 基于受控表单组件概念,实现一个注册表单:
<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,通过这个数据名称拿到这个父级组件的“共享数据”。如果这个“共享数据”是“响应式数据”类型,我们在子组件里修改这个“共享数据”就可以触发响应式特性,影响到父组件和其他子组件的操作。反之,如果“共享数据”是“普通变量数据”,子组件里是无法修改影响父组件的。
好,我们现在就来实现具体的代码。
先定义各种数据类型:
// 表单组件 实例的数据类型
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提供了一个“共享数据”:
<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>
现在我们再来实现“表单字段组件”:
<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)。
表单字段组件里,能根据字段名称,从共享数据里拿到当前管理的“字段数据”,就是包裹在表单数据组件里的数据。拿到这个字段数据和字段数据校验规则,我们可以在表单字段组件内部,监听这个数据变化,实现实时校验和提醒用户是否数据填写正确。
现在我们实现一个表单字段校验功能效果:
<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事件的。我们现在要做的是,拦截这个表单事件,在提交数据前,从“共享数据”里拿到校验规则和所有数据来做数据校验,校验不通过就阻断表单提交。
具体代码如下:
<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”事件,还有其它的方式来管理表单提交数据的操作吗?
欢迎你留言参与讨论,如果有疑问也欢迎评论,下一讲见。
完整的代码在这里
- 初烬 👍(2) 💬(1)
if (formContext?.model && props.name && formContext?.model?.[props?.name]) 这里是不是写错了。如果model['name'],初始化为null 岂不是不执行addField方法?
2022-12-23 - ifelse 👍(0) 💬(0)
学习打卡
2024-09-09 - 庄周梦蝶 👍(0) 💬(0)
感觉难的是各种不满足需求的表单组件,要自己二次封装。比如可编辑列表,作为表单的输入组件,然可编辑列表里面又有各种组件
2023-02-18 - ll 👍(0) 💬(2)
看完思考题突然有个疑问,就是一定要用 form 吗?因为受控表单很重要一点就是劫持代理 submit 事件,那换一个没有原生 submit 事件的元素,像是div,会不会方便点,不知可行不,会不会有意想不到的效果?
2022-12-22