26. 实践篇-表单验证上

一 前言

验证表单的设计,一直是比较复杂棘手的问题,难点在于对表单数据层的管理,以及把状态分配给每一个表单单元项。本章节来实现一套表单验证系统,通过本章节的学习,读者能够掌握以下知识点:

  • 表单控件组件设计。

  • 建立表单状态管理,状态分发,表单验证。

  • 自定义 hooks —— useForm 编写。

  • FormFormItem 如何建立关联,协调管理表单状态。

由于表单验证章节内容过多,分为上下两个章节来介绍。

  • 本章节主要介绍表单系统的设计思路和表单状态管理 FormStore 的实现。

  • 下章节将介绍 Form 和 FormItem 的编写,以及功能验证。

二 设计思路

可能开发者平时使用验证表单控件感觉挺便携方便的,那是因为在整个表单内部,已经为开发者做了大部分的‘脏活’,‘累活’,一个完整验证表单体系实际是很复杂的,整个流程可以分为,状态收集状态管理状态验证状态下发 ,等诸多环节,所以在开发一套受宠于大众的表单控件,首先每一个环节设计是蛮重要的。接下来首先介绍一下,如何设计一套表单系统。

在设计之前,拿 antd 为例子,看一下一个基本的表单长什么样子 ( 可以称之为 Demo1 ) :

<Form  onFinish={onFinish} >
   <FormItem name="name"  label="小册名称" >
       <Input />
   </FormItem>
    <FormItem name="author"  label="小册作者" >
       <Input />
   </FormItem>
   <Button htmlType="submit" >确定</Button>
</Form>

1 表单组件层模型设计

如上,一套表单系统分为 Form ,FormItem ,表单控件三部分构成,下面一一介绍三个部分作用以及应该如何设计。

Form 组件定位以及设计原则:

  • 状态保存Form 的作用,管理整个表单的状态,这个状态包括具体表单控件的 value,以及获取表单,提交表单,重置表单,验证表单等方法。

  • 状态下发Form 不仅仅要管理状态,而且还要下发传递这些状态。把这些状态下发给每一个 FormItem ,由于考虑到 Form 和 FormItem 有可能深层次的嵌套,所以选择通过 React context 保存下发状态最佳。

  • 保存原生 form 功能Form 满足上述两点功能之外,还要和原生的 form 的功能保持一致性。

FormItem组件定位以及设计原则:

  • 状态收集: 首先很重要的一点,就是收集表单的状态,传递给 Form 组件,比如属性名,属性值,校验规则等。

  • 控制表单组件:还有一个功能就是,将 FormItem 包裹的组件,变成受控的,一方面能够自由传递值 value 给表单控件,另一方面,能够劫持表单控件的 change 事件,得到最新的 value ,上传状态。

  • 提供Label和验证结果的 UI 层FormItem 还有一个作用就是要提供表单单元项的标签 label ,如果校验不通过的情况下,需要展示错误信息 UI 样式。

表单控件设计(比如 Input ,Select 等):

  • 首先表单控件一定是与上述整个表单验证系统零耦合的,也就是说 Input 等控件脱离整个表单验证系统,可以独立使用。

  • 在表单验证系统中,表单控件,不需要自己绑定事件,统一托管于 FormItem 处理。

三者关系如下图所示:

1.jpg

2 状态管理层设计

如何设计表单的状态层?

保存信息: 首先最直接的是,需要保存表单的属性名 name ,和当前的属性值 value ,除此之外还要保存当前表单的验证规则 rule ,验证的提示文案 message ,以及验证状态 status。 我这里收到 Promise 的启发,引用了三种状态:

  • resolve -> 成功状态,当表单验证成功之后,就会给 resolve 成功状态标签。

  • reject -> 失败状态,表单验证失败,就会给 reject 失败状态标签。

  • pendding -> 待验证状态,初始化,或者重新赋予表单新的值,就会给 pendding 待验证标签。

数据结构:

上面介绍了表单状态层保存的信息。接下来用什么数据结构保留这些信息。

/*  
    TODO: 数据结构
    model = {
       [name] ->  validate  = {
           value     -> 表单值    (可以重新设定)
           rule      -> 验证规则  ( 可以重新设定)
           required  -> 是否必添 -> 在含有 rule 的情况下默认为 true
           message   -> 提示消息
           status    -> 验证状态  resolve -> 成功状态 |reject -> 失败状态 | pending -> 待验证状态 |
       }
   }
*/
  • model 为整个 Form 表单的数据层结构。

  • name 为键,对应 FormItem 的每一个 name 属性,

  • validate 为 name 属性对应的值,保存当前的表单信息,包括上面说到那几个重要信息。

打个比方:上述 Demo1 中,最后存在 form 的数据结构如下所示:

model = {
    name :{ /* 小册名称 formItem */
        value: ...
        rule:...
        required:...
        message:...
        status:...
    },
    author:{ /* 小册作者 formItem */
        value: ...
        rule:...
        required:...
        message:...
        status:...
    }
}

表单状态层保存在哪里?

上面说到了整个表单的状态层,那么状态层保存在哪里呢 ?

状态层最佳选择就是保存在 Form 内部,可以通过 useForm 一个自定义 hooks 来维护和管理表单状态实例 FormStore

3 数据通信层设计

整个表单系统数据通信,还是从改变状态触发校验两个方向入手。

改变状态

当系统中一个控件比如 Input 值改变的时候,①可以是触发了 onChange 方法,首先由于 FormItem 控制表单控件,所以 FormItem 会最先感知到最新的 value ,②并通知给 Form 中的表单管理 FormStore , ③ FormStore 会更新状态,④ 然后把最新状态下发到对应的 FormItem ,⑤FormItem 接收到任务,再让 Input 更新最新的值,视觉感受 Input 框会发生变化 ,完成受控组件状态改变流程。

比如触发上述 Demo1 中 name 对应的 Input,内部流程图如下:

2.jpg

表单校验

表单校验有两种情况:

第一种: 可能是给 FormItem 绑定的校验事件触发,比如 onBlur 事件触发 ,而引起的对单一表单的校验。流程和上述改变状态相同类似。

第二种: 有可能是提交事件触发,或者手动触发校验事件,引起的整个表单的校验。流程首先触发 submit 事件,①然后通知给 Form 中 FormStore,② FormStore 会对整个表单进行校验,③然后把每个表单的状态,异步并批量下发到每一个 FormItem ,④ FormItem 就可以展示验证结果。

比如触发 Demo1 中的提交按钮,流程图:

3.jpg

整个表单验证系统的设计阶段,从几个角度介绍了系统设计,当然其中还有很多没有提及细节,会在实现环节详细讲解,接下来就是具体功能的实现环节。

三 FormStore 表单状态管理

FormStore 是整个表单验证系统最核心的功能了,里面包括保存的表单状态 model, 以及管理这些状态的方法,这些方法有的是对外暴露的,开发者可以通过调用这些对外的 api 实现提交表单校验表单重置表单 等功能。 参考和对标 antd 本质上是 rc-form, 罗列的方法如下。

FormStore 实例对外接口

以下接口提供给开发者使用。

对外接口名称
作用
参数说明

submit

提交表单

一个参数 cb,校验完表单执行,通过校验 cb 的参数为表单数据层,未通过校验 cb 参数为 false

resetFields

重置表单

无参数

setFields

设置一组表单值

一个参数为 object, key ——为表单名称,value ——表单项,可以是值,校验规则,校验文案 ,例如 setFields({ name: { value : '《React进阶实践指南》' , author:'我不是外星人' } , }) 或者 setFields({ name:'《React进阶实践指南》', author:'我不是外星人' })

setFieldsValue

设置单一表单值

二个参数,第一个参数为 name 表单项名称,第二个参数 value ,设置表单的值 , 例如 setFieldsValue('author','我不是外星人')

getFieldValue

获取对应字段名的值

一个参数,对应的表单项名称,例如 getFieldValue('name')

getFieldsValue

获取整个表单的value

无参数

validateFields

验证整个表单层

一个参数,回调函数,回调函数,参数为验证结果,

以下接口提供给 Form 和 FormItem 使用。

接口名称
作用
参数说明

setCallback

注册绑定在 Form 上的事件 , 比如 onFinishonFinishFailed

一个参数,为一个对象,存放需要注册的事件。

dispatch

可以通过 dispatch 调用 FormStore 内部的方法

第一个参数是一个对象,里面 type 为调用的方法,其余参数依次为调用方法的参数。

registerValidateFields

FormItem 注册表单单元项

三个参数,第一个参数单元项名称,第二个参数为,FormItem 的控制器,可以让 FormItem 触发更新,第三个参数,为注册的内容,比如 rule,message 等

unRegisterValidate

解绑注册的表单单元项

一个参数,表单单元项名称

重要属性

上述介绍了需要完成的对外接口,接下来介绍一下 FormStore 保存的重要属性。

  • model : 首先 model 为整个表单状态层的核心,绑定单元项的内容都存在 model 中,上述已经介绍了。

  • control :control 存放了每一个 FormItem 的更新函数,因为表单状态改变,Form 需要把状态下发到每一个需要更新的 FormItem 上。

  • callback: callback 存放表单状态改变的监听函数。

  • penddingValidateQueue:由于表单验证状态的下发是采用异步的,显示验证状态的更新,

代码实现

接下来就是具体的代码实现和流程分析。

/* 对外接口  */
const formInstanceApi = [
    'setCallback',
    'dispatch',
    'registerValidateFields',
    'resetFields',
    'setFields',
    'setFieldsValue',
    'getFieldsValue',
    'getFieldValue',
    'validateFields',
    'submit',
    'unRegisterValidate'
]

/* 判断是否是正则表达式 */
const isReg = (value) => value instanceof RegExp
class FormStore{
    constructor(forceUpdate,defaultFormValue={}){
        this.FormUpdate = forceUpdate     /* 为 Form 的更新函数,目前没有用到 */
        this.model = {}                   /* 表单状态层 */
        this.control = {}                 /* 控制每个 formItem 的控制器  */
        this.isSchedule = false           /* 开启调度 */
        this.callback = {}                /* 存放监听函数 callback */
        this.penddingValidateQueue = []   /* 批量更新队列 */
        this.defaultFormValue = defaultFormValue /* 表单初始化的值 */
    }
    /* 提供操作form的方法 */
    getForm(){
        return formInstanceApi.reduce((map,item) => {
            map[item] = this[item].bind(this)
            return map
        } ,{})
    }
    /* 创建一个验证模块 */
    static createValidate(validate){
        const { value, rule, required, message } = validate
        return {
            value,
            rule: rule || (() => true),
            required: required || false,
            message: message || '',
            status:'pending'
        }

    }
    /* 处理回调函数 */
    setCallback(callback){
        if(callback) this.callback = callback
    }
    /* 触发事件 */
    dispatch(action,...arg){
        if(!action && typeof action !== 'object') return null
       const { type } = action
       if(~formInstanceApi.indexOf(type)){
           return this[type](...arg)
       }else if(typeof this[type] === 'function'   ){
        return this[type](...arg)
       }
    }
    /* 注册表单单元项 */
    registerValidateFields(name,control,model){
       if(this.defaultFormValue[name]) model.value = this.defaultFormValue[name] /* 如果存在默认值的情况 */
       const validate = FormStore.createValidate(model)
       this.model[name] = validate
       this.control[name] = control
    }
    /* 卸载注册表单单元项 */
    unRegisterValidate(name){
       delete this.model[name]
       delete this.control[name]
    }
    /* 通知对应FormItem更新 */
    notifyChange(name){
        const controller = this.control[name]
        if(controller) controller?.changeValue()
    }
    /* 重置表单 */
    resetFields(){
        Object.keys(this.model).forEach(modelName => {
             this.setValueClearStatus(this.model[modelName],modelName,null)
        })
    }
    /* 设置一组字段状态	  */
    setFields(object){
        if( typeof object !== 'object' ) return
        Object.keys(object).forEach(modelName=>{
            this.setFieldsValue(modelName,object[modelName])
        })
    }
    /* 设置表单值 */
    setFieldsValue(name,modelValue){
      const model = this.model[name]
       if(!model) return false
       if(typeof modelValue === 'object' ){ /* 设置表单项 */
           const { message ,rule , value  } = modelValue
           if(message) model.message = message
           if(rule)    model.rule = rule
           if(value)   model.value = value
           model.status = 'pending'              /* 设置待验证状态 */
           this.validateFieldValue(name,true)     /* 如果重新设置了验证规则,那么重新验证一次 */
       }else {
           this.setValueClearStatus(model,name,modelValue)
       }
    }
    /* 复制并清空状态 */
    setValueClearStatus(model,name,value){
        model.value = value
        model.status = 'pending'
        this.notifyChange(name)
    }
  
    /* 获取表单数据层的值 */
    getFieldsValue(){
       const formData = {}
       Object.keys(this.model).forEach(modelName=>{
           formData[modelName] = this.model[modelName].value
       })
       return formData
    }
    /* 获取表单模型 */
    getFieldModel(name){
        const model =  this.model[name]
        return model ? model : {}
    }
    /* 获取对应字段名的值 */
    getFieldValue(name){
        const model =  this.model[name]
        if(!model && this.defaultFormValue[name]) return this.defaultFormValue[name] /* 没有注册,但是存在默认值的情况 */
        return model ? model.value : null
    }
    /* 单一表单单元项验证 */
    validateFieldValue(name,forceUpdate = false){
        const model = this.model[name]
        /* 记录上次状态 */
        const lastStatus =  model.status
        if(!model) return null
        const { required, rule , value } = model
        let status = 'resolve'
        if(required && !value ){
            status = 'reject'
        }
        else if(isReg(rule)){     /* 正则校验规则 */
            status = rule.test(value) ? 'resolve' : 'reject'
        }else if(typeof rule === 'function'){ /* 自定义校验规则 */
            status = rule(value) ? 'resolve' : 'reject'
        }
        model.status = status
        if(lastStatus !==  status || forceUpdate ){
           const notify = this.notifyChange.bind(this,name)
           this.penddingValidateQueue.push( notify )
        }
        this.scheduleValidate()
        return status
    }
    /* 批量调度验证更新任务 */
    scheduleValidate(){
       if(this.isSchedule) return
       this.isSchedule = true
       Promise.resolve().then(()=>{
           /* 批量更新验证任务 */
          unstable_batchedUpdates(()=>{
              do{
                let notify = this.penddingValidateQueue.shift()
                notify && notify()  /* 触发更新 */
              }while(this.penddingValidateQueue.length > 0)
              this.isSchedule = false
          })
       })
    }
    /* 表单整体验证 */
    validateFields(callback){
       let status = true
       Object.keys(this.model).forEach(modelName=>{
           const modelStates = this.validateFieldValue(modelName,true)
           if(modelStates==='reject') status = false
       })
       callback(status)
    }
    /* 提交表单 */
    submit(cb){
        this.validateFields((res)=>{
            const { onFinish, onFinishFailed} = this.callback
            cb && cb(res)
            if(!res) onFinishFailed && typeof onFinishFailed === 'function' && onFinishFailed() /* 验证失败 */
            onFinish && typeof onFinish === 'function' && onFinish( this.getFieldsValue() )     /* 验证成功 */
        })
    }

}

流程分析:

初始化流程

  • constructor ,FormStore 通过 new 方式实例化。实例化过程中会绑定 modelcontrol 等属性。

  • getForm: 这里思考一下问题,就是需不需要把整个 FormStore 全部向 Form 组件暴露出去,答案是肯定不能这么做,因为如果 FormStore 整个实例暴露出去,就可以获取内部的状态 model 和 control 等重要模块,如果篡改模块下的内容,那么后果无法想象的,所以对外提供的只是改变表单状态的接口。通过 getForm 把重要的 API 暴露出去就好。getForm 通过数组 reduce 把对外注册的接口数组 formInstanceApi 一一绑定 this 然后形成一个对象,传递给 form 组件。

  • setCallback : 这个函数做的事情很简单,就是注册 callback 事件。在表单的一些重要阶段,比如提交成功,提交失败的时候,执行这些回调函数。

表单注册流程

  • static createValidate: 静态方法——创建一个验证 Validate ,也就是 model 下的每一个模块,主要在注册表单单元项的时候使用。

  • registerValidateFields :注册表单单元项,这个在 FormItem 初始化时候调用,把验证信息,验证文案,等信息,通过 ·createValidate 注册到 model 中, 把 FormItem 的更新函数注册到 control 中。

  • unRegisterValidate:在 FormItem 的生命周期销毁阶段执行,解绑上面 registerValidateFields 注册的内容。

表单状态设置,获取,重置

  • notifyChange:每当给表单单元项 FormItem 重新赋值的时候,就会执行当前 FormItem 的更新函数,派发视图更新。(这里可以提前透露一下,control 存放的就是每个 FormItem 组件的 useState 方法 )

  • setValueClearStatus: 重新设置表单值,并重置待验证状态 pendding,然后触发 notifyChange 促使 FormItem 更新。

  • setFieldsValue:设置一个表单值, 如果重新设置了验证规则,那么重新验证一次,如果只是设置了表单项的值,调用 setValueClearStatus 更新。

  • setFields: 设置一组表单值,本质上对每一个单元项触发 setFieldsValue。

  • getFieldValue:获取表单值,本质上就是获取 model 下每一个模块的 value 值。

  • getFieldsValue:获取整个表单的数据层(分别获取每一个模块下的 value )。

  • getFieldModel:获取表单的模型,这个 api 设计为了让 UI 显示验证成功或者失败的状态,以及提示的文案。

  • resetFields:本质上就是调用 setValueClearStatus 重新设置每一个表单单元项的状态。

表单验证

  • validateFieldValue:验证表单的单元项,通过判断规则,如果规则是正则表达式那么触发正则 test 方法,如果是自定义规则,那么执行函数,返回值布尔值判断是否通过校验。如果状态改变,把当前更新任务放在 penddingValidateQueue 待验证队列中。为什么采用异步校验更新呢? 首先验证状态改变,带来的视图更新,不是那么重要,可以先执行更高优先级的任务,还有一点就是整个验证功能,有可能在异步情况下,表单会有多个表单单元项,如果直接执行更新任务,可能会让表单更新多次,所以放入penddingValidateQueue 在配合 unstable_batchedUpdates批量更新,统一更新这些状态。

  • scheduleValidate:scheduleValidate 执行会开启 isSchedule = true开关,如果有多个验证任务,都会放入 penddingValidateQueue ,最后统一执行一次任务处理逻辑。调用 Promise.resolve()unstable_batchedUpdates 异步批量更新 ,批量更新完毕,关闭开关 this.isSchedule = false

  • validateFields: validateFields 会对每一个表单单元项触发 validateFieldValue ,然后执行回调函数,回调函数参数,代表验证是否通过,如果有一个验证不通过,那么整体就不通过验证。

  • submit:submit 本质就是调用 validateFields 验证这个表单,然后在 validateFields 回调函数中,触发对应的监听方法 callback , 成功触发 onFinish, 失败调用 onFinishFailed ,这些方法都是绑定在 Form 的回调函数。

四 useForm 表单状态管理 hooks 设计

上面的 FormStore 就是通过自定义 hooks —— useForm 创建出来的。useForm 可以独立使用,创建一个 formInstance ,然后作为 form 属性赋值给 Form 表单。 如果没有传递 默认会在 Form 里通过 useForm 自动创建一个。(参考 antd,用法一致 )。

代码实现:

function useForm(form,defaultFormValue = {}){
   const formRef = React.useRef(null)
   const [, forceUpdate] = React.useState({})
   if(!formRef.current){
      if(form){
          formRef.current = form  /* 如果已经有 form,那么复用当前 form  */
      }else { /* 没有 form 创建一个 form */
        const formStoreCurrent = new FormStore(forceUpdate,defaultFormValue)
        /* 获取实例方法 */
        formRef.current = formStoreCurrent.getForm()
      }
   }
   return formRef.current
}

useForm 的逻辑实际很简单:

  • 通过一个 useRef 来保存 FormStore 的重要 api。

  • 首先会判断有没有 form ,如果没有,会实例化 FormStore ,上面讲的 FormStore 终于用到了,然后会调用 getForm ,把重要的 api 暴露出去。

  • 什么情况下有 form ,当开发者用 useForm 单独创建一个 FormStore 再赋值给 Form 组件的 form 属性,这个时候就会存在 form 了。

五 总结

本章节主要学习内容如下:

  • 表单的设计思路与细节。

  • 编写一个表单状态管理工具—— FormStore 。

  • 编写一个自定义 hooks —— useForm 。

  • 异步批量处理表单验证更新任务。

下一章节,将继续完成表单的 Form 和 FormItem 组件。

最后更新于