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

发布于:2024-05-05 ⋅ 阅读:(28) ⋅ 点赞:(0)

前言

Form表单组件无疑是用户与应用程序之间沟通的桥梁。无论是数据录入、信息修改还是搜索查询,Form组件都扮演着至关重要的角色。本文将简单介绍Form表单组件的基本实现。

开始之前,先来回顾一下我们平时是怎样使用Form组件的,首先需要一个Form,然后Form里边放的是Form-Item,Form-Item中放的是我们想要展示的其他元素,如Input、Button等等,所以我们需要将Form组件分为两个部分。

需求分析

一个Form表单组件需要具备什么基本功能呢?如下:

  • 自定义组件
    • 用户可以自定义多种表单元素,也可以是普通文本
    • 用户可以自定义提交区域内容
  • 验证
    • 表单需要对内容进行验证
    • 每一个Form-Item中的内容都可以验证
    • 提交时对所有的Form-Item中的内容验证
  • 验证规则
    • 有的Input验证需要多条验证规则
    • 用户可以自定义验证规则,比如两次密码输入要一致

async-validator

既然说到了验证规则,那么先来看一下一个第三库—— ,这是一个用于校验的库,提供了丰富的校验规则和API,我们这里如果想自己实现校验规则的话,那就需要话费更多的时间和精力来编写大量的代码,比如正则表达式,那么这样就违背了开发本组件库的初衷,效果也不见得比使用第三方库更好。所以这里我们使用async-validator来帮助我们完成校验的工作。

那么先来看一下怎样使用这个第三方库吧:

  • 下载
npm i async-validator
  • 定义规则
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();
        }
      });
    },
  },
};
  • 校验
const validator = new Schema(descriptor);
validator.validate({ name: 'muji' }, (errors, fields) => {
  if (errors) {
   //验证失败,errors是所有错误的数组 
   // fields是一个以字段名为关键字的对象,带有一个 
   //每个字段的错误数
    return handleErrors(errors, fields);
  }
  // validation passed
});
  • Promise 用法

validator.validate({ name: 'muji', age: 16 }).then(() => {
//  验证通过或没有错误消息
}).catch(({ errors, fields }) => {
  return handleErrors(errors, fields);
});

async-validator不仅仅可以在框架中使用,在原生js中也是可以使用的,那么接下来我们就将通过async-validator来进行校验。

校验流程

确定开发方案之前,我们先来想一个问题,表单的校验流程是怎样实现的?回答这个问题之前,不妨先回忆一下,我们平时是怎样进行校验的:

  • 每个需要校验的表单元素会在特定的时机触发校验,如blur、change
  • 在提交的时候,需要对整体做一次校验,如果有一个不通过,那么就整体都不通过

可能你有这样的疑问,为什么要对整体进行校验呢?每个表单元素不是有已经设置校验了吗?是这样的,理论上来说,只要用户按照规则填写了每个表单,那么最终所有的表单元素都是符合要求的,但是如果用户漏掉了某一项必填的元素,那么这个元素的校验是不是一直都不会被触发呢,所以这个时候就需要整体验证来完成这个工作,提醒用户漏掉了哪一项必填的元素。

所以我们需要解决的问题就是:

  • 如何校验每个表单元素
  • 如何做整体校验
  • 整体校验的时候怎样获取每个表单的校验信息

确定方案

属性

FormProps

export interface FormProps {
  model?: Record<string, any>
  rules?: FormRules
}

rules保存了整个表单所有元素的验证规则:

export interface FormItemRule extends RuleItem {
  // 对应不同的验证时机,如blur、change
  trigger?: string
}

export type FormRules = Record<string, FormItemRule[]>

RuleItem为async-validator的内置类型,这里不再详细介绍。

FormItemProps:

export interface FormItemProps {
  label?: string
  prop?: string
}

每个Form-Item都关联一个表单元素,在使用校验的时候,prop是必填的 FormInstance:

// Form实例
export interface FormInstance {
  // 用于提交时整体验证
  validate: () => Promise<any>
  // 重置所有验证
  resetFields: (props?: string[]) => void
  // 清空所有验证信息
  clearValidates: (props?: string[]) => void
}

FormItemInstance:

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实例的校验方法完成整体校验。

// 传递给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传递的方法将所有的校验信息保存下来,最后完成整体校验

组件

// Form.vue
<template>
    <form class="yv-form">
        <slot />
    </form>
</template>
// 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中要做的事情:

  • 收集所有表单中的验证信息: 用一个数组来保存这些信息
const fields: FormItemContext[] = []

其中FormItemContext是传递给表单元素,表单元素会根据用户自定义的规则在特定时机进行校验

  • 维护数组: 在特定的时机操作数组
// 添加校验信息
const addField: FormContext['addField'] = (field) => {
    fields.push(field)
}
// 移除校验信息
const removeField: FormContext['removeField'] = (field) => {
    if (field.prop) {
        fields.splice(fields.indexOf(field), 1)
    }
}
  • Form中的整体验证:
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中介绍。

除了有验证,相对应的还有取消验证和清除页面验证信息:

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组件,再将最终验证方法暴露出去:

provide(formContextKey, {
    ...props,
    addField,
    removeField
})
defineExpose<FormInstance>({
    validate,
    resetFields,
    clearValidates
})

看到这里,相信你已经进一步了解了Form中在做什么事情了:简单来说就是把控全局——将全局的表单元素验证相关的信息和方法收集起来,然后一一调用,接下来再看FormItem组件做了什么:

  • FormItem中的验证:

接收Form传递过来的方法:

const formContext = inject(formContextKey, undefined)

定义属性:

const props = defineProps<FormItemProps>()

其他信息:

// 关于校验的信息
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 []
    }
})

获取触发时机:

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,这样就将对所有的规则进行验证。

完成校验:

// 借助第三方库完成校验
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对应的方法:

// 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那样必须接收所有传递过来的数据。并不是所有的情况下都需要进行表单验证,比如搜索框

接下来,将相关方法提供给表单元素:

const context: FormItemContext = {
    validate,
    prop: props.prop || '',
    clearValidate,
    resetField
}
provide(formItemContextKey, context)

然后在表单元素中使用:以Blur为例:在处理blur时也触发校验

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)做处理,通过依赖注入的方法完整校验方法的传递,然后在合适的时机调用,其他细节不再一一赘述。


网站公告

今日签到

点亮在社区的每一天
去签到