Element Plus 组件库实现:10. Form表单组件

前言

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属性保存了表单元素的校验状态

确定好属性之后,再来思考一下验证信息的传递:

  • 将每个表单元素的的验证规则传递给相应的表单元素
  • 每个表单元素在校验之后要将信息保存下来

为了解决以上两个问题,我们可以使用provideinject的方法来完成校验方法的传递,然后通过调用相应的方法就可以实现校验,最后调用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需要完成的事情其实也就要做完了,接下来就是将相关方法提供给表单元素,然后在表单元素中进行调用,这里传递为什么不用父子传递呢?而要用provideinject呢?答案其实很简单:

  • 这里的表单元素是通过预留插槽的方式来实现的,并不一定就是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)做处理,通过依赖注入的方法完整校验方法的传递,然后在合适的时机调用,其他细节不再一一赘述。

相关推荐
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅10 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
猫头虎10 小时前
如何排查并解决项目启动时报错Error encountered while processing: java.io.IOException: closed 的问题
java·开发语言·jvm·spring boot·python·开源·maven
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端