前言
Form表单组件无疑是用户与应用程序之间沟通的桥梁。无论是数据录入、信息修改还是搜索查询,Form组件都扮演着至关重要的角色。本文将简单介绍Form表单组件的基本实现。
开始之前,先来回顾一下我们平时是怎样使用Form组件的,首先需要一个Form,然后Form里边放的是Form-Item,Form-Item中放的是我们想要展示的其他元素,如Input、Button等等,所以我们需要将Form组件分为两个部分。
需求分析
一个Form表单组件需要具备什么基本功能呢?如下:
- 自定义组件
- 用户可以自定义多种表单元素,也可以是普通文本
- 用户可以自定义提交区域内容
- 验证
- 表单需要对内容进行验证
- 每一个Form-Item中的内容都可以验证
- 提交时对所有的Form-Item中的内容验证
- 验证规则
- 有的Input验证需要多条验证规则
- 用户可以自定义验证规则,比如两次密码输入要一致
async-validator
既然说到了验证规则,那么先来看一下一个第三库------ async-validator,这是一个用于校验的库,提供了丰富的校验规则和API,我们这里如果想自己实现校验规则的话,那就需要话费更多的时间和精力来编写大量的代码,比如正则表达式,那么这样就违背了开发本组件库的初衷,效果也不见得比使用第三方库更好。所以这里我们使用async-validator
来帮助我们完成校验的工作。
那么先来看一下怎样使用这个第三方库吧:
- 下载
bash
npm i async-validator
- 定义规则
ts
import Schema from 'async-validator';
const descriptor = {
name: {
type: 'string',
required: true,
validator: (rule, value) => value === 'muji',
},
age: {
type: 'number',
asyncValidator: (rule, value) => {
return new Promise((resolve, reject) => {
if (value < 18) {
reject('too young'); // 拒绝并显示错误消息
} else {
resolve();
}
});
},
},
};
- 校验
ts
const validator = new Schema(descriptor);
validator.validate({ name: 'muji' }, (errors, fields) => {
if (errors) {
//验证失败,errors是所有错误的数组
// fields是一个以字段名为关键字的对象,带有一个
//每个字段的错误数
return handleErrors(errors, fields);
}
// validation passed
});
- Promise 用法
ts
validator.validate({ name: 'muji', age: 16 }).then(() => {
// 验证通过或没有错误消息
}).catch(({ errors, fields }) => {
return handleErrors(errors, fields);
});
async-validator
不仅仅可以在框架中使用,在原生js中也是可以使用的,那么接下来我们就将通过async-validator来进行校验。
校验流程
确定开发方案之前,我们先来想一个问题,表单的校验流程是怎样实现的?回答这个问题之前,不妨先回忆一下,我们平时是怎样进行校验的:
- 每个需要校验的表单元素会在特定的时机触发校验,如blur、change
- 在提交的时候,需要对整体做一次校验,如果有一个不通过,那么就整体都不通过
可能你有这样的疑问,为什么要对整体进行校验呢?每个表单元素不是有已经设置校验了吗?是这样的,理论上来说,只要用户按照规则填写了每个表单,那么最终所有的表单元素都是符合要求的,但是如果用户漏掉了某一项必填的元素,那么这个元素的校验是不是一直都不会被触发呢,所以这个时候就需要整体验证来完成这个工作,提醒用户漏掉了哪一项必填的元素。
所以我们需要解决的问题就是:
- 如何校验每个表单元素
- 如何做整体校验
- 整体校验的时候怎样获取每个表单的校验信息
确定方案
属性
FormProps
ts
export interface FormProps {
model?: Record<string, any>
rules?: FormRules
}
rules保存了整个表单所有元素的验证规则:
ts
export interface FormItemRule extends RuleItem {
// 对应不同的验证时机,如blur、change
trigger?: string
}
export type FormRules = Record<string, FormItemRule[]>
RuleItem为async-validator的内置类型,这里不再详细介绍。
FormItemProps:
ts
export interface FormItemProps {
label?: string
prop?: string
}
每个Form-Item都关联一个表单元素,在使用校验的时候,prop是必填的 FormInstance:
ts
// Form实例
export interface FormInstance {
// 用于提交时整体验证
validate: () => Promise<any>
// 重置所有验证
resetFields: (props?: string[]) => void
// 清空所有验证信息
clearValidates: (props?: string[]) => void
}
FormItemInstance:
ts
export interface ValidateStatusProp {
state: 'init' | 'success' | 'error'
errorMsg: string
loading: boolean
}
// FormItem实例
export interface FormItemInstance {
validateStatus: ValidateStatusProp
validate: (trigger?: string) => Promise<any>
// 重置验证
resetField: (props?: string[]) => void
// 清空验证信息
clearValidate: (props?: string[]) => void
}
validateStatus属性保存了表单元素的校验状态
确定好属性之后,再来思考一下验证信息的传递:
- 将每个表单元素的的验证规则传递给相应的表单元素
- 每个表单元素在校验之后要将信息保存下来
为了解决以上两个问题,我们可以使用provide
和inject
的方法来完成校验方法的传递,然后通过调用相应的方法就可以实现校验,最后调用FormInstance实例的校验方法完成整体校验。
ts
// 传递给Form-Item
export interface FormContext extends FormProps {
addField: (filed: FormItemContext) => void
removeField: (filed: FormItemContext) => void
}
// 传递给单个表单元素
export interface FormItemContext {
prop: string
validate: (trigger?: string) => Promise<any>
resetField: (props?: string[]) => void
clearValidate: (props?: string[]) => void
}
简单总结一下就是:在表单元素中通过调用FormItem传递的方法进行校验,然后在FormItem中通过Form传递的方法将所有的校验信息保存下来,最后完成整体校验
组件
html
// Form.vue
<template>
<form class="yv-form">
<slot />
</form>
</template>
html
// FormItem.vue
<template>
<div class="yv-form-item" :class="{
'is-error': validateStatus.state === 'error',
'is-success': validateStatus.state === 'success',
'is-loading': validateStatus.loading,
'is-required': isRequired
}">
<!-- 用于展示表单表头 -->
<label class="yv-form-item__label">
<slot name="label" :label="label">
{{ label }}
</slot>
</label>
<!-- 展示表单校验信息 -->
<div class="yv-form-item__content">
<slot :validate="validate"></slot>
<div v-if="validateStatus.state === 'error'" class="yv-form-item__error-msg">
{{ validateStatus.errorMsg }}
</div>
</div>
</div>
</template>
代码实现
先来看Form中要做的事情:
- 收集所有表单中的验证信息: 用一个数组来保存这些信息
ts
const fields: FormItemContext[] = []
其中FormItemContext是传递给表单元素,表单元素会根据用户自定义的规则在特定时机进行校验
- 维护数组: 在特定的时机操作数组
ts
// 添加校验信息
const addField: FormContext['addField'] = (field) => {
fields.push(field)
}
// 移除校验信息
const removeField: FormContext['removeField'] = (field) => {
if (field.prop) {
fields.splice(fields.indexOf(field), 1)
}
}
- Form中的整体验证:
ts
const validate = async () => {
let validationErrors: ValidateFieldsError = {}
for (const field of fields) {
try {
await field.validate('')
} catch (err) {
const error = err as FormValidateFailure
validationErrors = {
...validationErrors,
...error.fields
}
}
}
if (Object.keys(validationErrors).length === 0) return true
return Promise.reject(validationErrors)
}
通过遍历fileds数组,然后调用validate
进行对每一项规则进行验证,传入一个空字符串即表示会匹配到所有触发时机,下文将在FormItem中介绍。
除了有验证,相对应的还有取消验证和清除页面验证信息:
ts
const resetFields = (keys: string[] = []) => {
const filterArr = keys.length > 0 ? fields.filter(field => keys.includes(field.prop)) : fields
filterArr.forEach(field => field.resetField())
}
const clearValidates = (keys: string[] = []) => {
const filterArr = keys.length > 0 ? fields.filter(field => keys.includes(field.prop)) : fields
filterArr.forEach(field => field.clearValidate())
}
然后,要将这些方法提供给FormItem组件,再将最终验证方法暴露出去:
ts
provide(formContextKey, {
...props,
addField,
removeField
})
defineExpose<FormInstance>({
validate,
resetFields,
clearValidates
})
看到这里,相信你已经进一步了解了Form中在做什么事情了:简单来说就是把控全局------将全局的表单元素验证相关的信息和方法收集起来,然后一一调用,接下来再看FormItem组件做了什么:
- FormItem中的验证:
接收Form传递过来的方法:
ts
const formContext = inject(formContextKey, undefined)
定义属性:
ts
const props = defineProps<FormItemProps>()
其他信息:
ts
// 关于校验的信息
const validateStatus: ValidateStatusProp = reactive({
state: 'init',
errorMsg: '',
loading: false
})
// 传递model
const innerValue = computed(() => {
const model = formContext?.model
if (model && props.prop && !isNil(model[props.prop])) {
return model[props.prop]
} else {
return null
}
})
let initialValue: any = null
// 传递rules
const itemRlues = computed(() => {
const rules = formContext?.rules
if (rules && props.prop && rules[props.prop]) {
return rules[props.prop]
} else {
return []
}
})
获取触发时机:
ts
const getTriggeredRules = (trigger?: string) => {
const rules = itemRlues.value
if (rules) {
return rules.filter(rule => {
if (!rule.trigger || !trigger) return true
return rule.trigger && rule.trigger === trigger
})
} else {
return []
}
}
这里解释了为什么上文Form中,最终验证时传入空字符串可以匹配到所有的触发验证时机,原因就是传入空字串表示没有trigger,那么这样就会返回true
,这样就将对所有的规则进行验证。
完成校验:
ts
// 借助第三方库完成校验
const validate = async (trigger?: string) => {
const modelName = props.prop
const triggeredRules = getTriggeredRules(trigger)
if (triggeredRules.length === 0) {
return true
}
if (modelName) {
const validator = new Schema({
[modelName]: triggeredRules
})
validateStatus.loading = true
return validator.validate({ [modelName]: innerValue.value })
.then(() => {
validateStatus.state = 'success'
})
.catch((err: FormValidateFailure) => {
const { errors } = err
validateStatus.state = 'error'
validateStatus.errorMsg = (errors && errors.length > 0) ? errors[0].message || '' : ''
return Promise.reject(err)
}).finally(() => {
validateStatus.loading = false
})
}
}
这里借助第三方库async-validator
完成校验,上文已经简单介绍过基本使用,这里不再赘述。
接着是上文中Form中的重置验证resetFields
和清除所有验证信息提示clearValidates
对应的方法:
ts
// clear validate
const clearValidate = () => {
validateStatus.state = 'init'
validateStatus.errorMsg = ''
validateStatus.loading = false
}
const resetField = () => {
const model = formContext?.model
clearValidate()
if (model && props.prop && model[props.prop]) {
model[props.prop] = initialValue
}
}
说明:validateStatus
保存的是表单元素验证时对应的状态,validateStatus.errorMsg
就是对用户的提示信息,这里做简单说明,不再展示UI部分。
到这里,FormItem需要完成的事情其实也就要做完了,接下来就是将相关方法提供给表单元素,然后在表单元素中进行调用,这里传递为什么不用父子传递呢?而要用provide
和inject
呢?答案其实很简单:
- 这里的表单元素是通过预留插槽的方式来实现的,并不一定就是FormItem的子组件,也可能是子孙组件
- 使用
provide/inject
可以使组件更加解耦。由于数据是在祖先组件中提供的,因此后代组件不需要知道数据是如何传递过来的,只需要关注如何使用这些数据即可。这有助于降低组件之间的耦合度,提高代码的可维护性 provide/inject
提供了更大的灵活性。你可以根据需要选择性地注入数据,而不是像props
那样必须接收所有传递过来的数据。并不是所有的情况下都需要进行表单验证,比如搜索框
接下来,将相关方法提供给表单元素:
ts
const context: FormItemContext = {
validate,
prop: props.prop || '',
clearValidate,
resetField
}
provide(formItemContextKey, context)
然后在表单元素中使用:以Blur为例:在处理blur时也触发校验
ts
const formItemContext = inject(formItemContextKey, undefined)
const handleValidate = (trigger?: string) => {
formItemContext?.validate(trigger).catch(err => {
console.warn(err.errors)
})
}
const handleBlur = (event: FocusEvent) => {
// 其他代码省略
// 触发验证
handleValidate('blur')
}
为什么默认为undefined,这是因为,如果单独使用Input组件,也没有provide来提供,在控制台就会出现警告信息,所以默认为undefined
总结
本文主要介绍了Form组件的基本实现,相对于之前的组件,Form应该是最具有挑战性的一个组件了,关键就在于处理验证的流程,要分别在Form、FormItem、表单元素(如Select、Input)做处理,通过依赖注入的方法完整校验方法的传递,然后在合适的时机调用,其他细节不再一一赘述。