1. 程式人生 > >騷操作: 基於 Antd Form 的高階元件 AutoBindForm

騷操作: 基於 Antd Form 的高階元件 AutoBindForm

1. 前言

很久沒更新部落格了, 皮的嘛,就不談了,不過問題不大,今天就結合 專案中寫的一個 React 高階元件 的例項 再來講一講,結合上一篇文章,加深一下印象

2. Ant Design 的 Form 元件

國民元件庫 Ant-DesignForm 庫 想必大家都用過, 比較強大, 基於 rc-form 封裝, 功能比較齊全

最近專案中遇到了一個需求, 普通的一個表單, 表單欄位沒有 填完的時候, 提交按鈕 是 disabled 狀態的, 聽起來很簡單, 由於用的是 antd 翻了翻文件, copy 了一下程式碼 , 發現需要些不少的程式碼

Edit antd reproduction template

import
{ Form, Icon, Input, Button } from 'antd'; const FormItem = Form.Item; function hasErrors(fieldsError) { return Object.keys(fieldsError).some(field => fieldsError[field]); } @Form.create(); class Page extends React.Component<{},{}> { componentDidMount() { this.props.form.validateFields(); } handleSubmit = (e: React.FormEvent<HTMLButtonElement>
) =>
{ e.preventDefault(); this.props.form.validateFields((err:any, values:any) => { if (!err) { ... } }); } render() { const { getFieldDecorator, getFieldsError, getFieldError, isFieldTouched } = this.props.form; const userNameError = isFieldTouched('userName'
) && getFieldError('userName'); const passwordError = isFieldTouched('password') && getFieldError('password'); return ( <Form layout="inline" onSubmit={this.handleSubmit}> <FormItem validateStatus={userNameError ? 'error' : ''} help={userNameError || ''} > {getFieldDecorator('userName', { rules: [{ required: true, message: 'Please input your username!' }], })( <Input prefix={<Icon type="user" style={{ color: 'rgba(0,0,0,.25)' }} />} placeholder="Username" /> )} </FormItem> <FormItem validateStatus={passwordError ? 'error' : ''} help={passwordError || ''} > {getFieldDecorator('password', { rules: [{ required: true, message: 'Please input your Password!' }], })( <Input prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />} type="password" placeholder="Password" /> )} </FormItem> <FormItem> <Button type="primary" htmlType="submit" disabled={hasErrors(getFieldsError())} > 登入 </Button> </FormItem> </Form> ); } } 複製程式碼

3. 那麼問題來了

上面的程式碼咋一看沒什麼毛病, 給每個欄位繫結一個 validateStatus 去看當前欄位 有沒有觸碰過 並且沒有錯, 並在 元件渲染的時候 觸發一次驗證, 通過這種方式 來達到 disabled 按鈕的目的, 但是要命的 只是 實現一個 disabled 的效果, 多寫了這麼多的程式碼, 實際遇到的場景是 有10多個這種需求的表單,有沒有什麼辦法不寫這麼多的模板程式碼呢? 於是我想到了 高階元件

4. 開始幹活

由於 Form.create() 後 會給 this.props 新增 form 屬性 ,從而使用它提供的 api, 經過觀察 我們預期想要的效果有以下幾點

// 使用效果

@autoBindForm   //需要實現的元件
export default class FormPage extends React.PureComponent {
    
}

複製程式碼

要達到如下效果

  • 1.componentDidMount 的時候 觸發一次 欄位驗證
  • 2.這時候會出現錯誤資訊, 這時候需要幹掉錯誤資訊
  • 3.然後遍歷當前元件所有的欄位, 判斷 是否有錯
  • 4.提供一個 this.props.hasError 類似的欄位給當前元件.控制 按鈕的 disabled 狀態
  • 5.支援非必填欄位, (igonre)
  • 6.支援編輯模式 (有預設值)

5. 實現 autoBindForm

import * as React from 'react'
import { Form } from 'antd'

const getDisplayName = (component: React.ComponentClass) => {
  return component.displayName || component.name || 'Component'
}

export default (WrappedComponent: React.ComponentClass<any>) => {
    class AutoBindForm extends WrappedComponent {
      static displayName = `HOC(${getDisplayName(WrappedComponent)})`


      autoBindFormHelp: React.Component<{}, {}> = null

      getFormRef = (formRef: React.Component) => {
        this.autoBindFormHelp = formRef
      }

      render() {
        return (
          <WrappedComponent
            wrappedComponentRef={this.getFormRef}
          />
        )
      }


    return Form.create()(AutoBindForm)
  }
複製程式碼

首先 Form.create 一下我們需要包裹的元件, 這樣就不用每一個頁面都要 create 一次

然後我們通過 antd 提供的 wrappedComponentRef 拿到了 form 的引用

根據 antd 的文件 ,我們要實現想要的效果,需要用到 如下 api

  • validateFields 驗證欄位
  • getFieldsValue 獲取欄位的值
  • setFields 設定欄位的值
  • getFieldsError 獲取欄位的錯誤資訊
  • isFieldTouched 獲取欄位是否觸碰過
class AutoBindForm extends WrappedComponent
複製程式碼

繼承我們需要包裹的元件(也就是所謂的反向繼承), 我們可以 在初始化的時候 驗證欄位

componentDidMount(){
  const {
    form: {
        validateFields,
        getFieldsValue,
        setFields,
      },
   } = this.props

    validateFields()
  }
}
複製程式碼

由於進入頁面時 使用者並沒有輸入, 所以需要手動清空 錯誤資訊

componentDidMount() {
    const {
      form: {
        validateFields,
        getFieldsValue,
        setFields,
      },
    } = this.props

    validateFields()

    Object.keys(getFieldsValue())
      .forEach((field) => {
        setFields({
          [field]: {
            errors: null,
            status: null,
          },
        })
      })

  }
}
複製程式碼

通過 getFieldsValue() 我們可以動態的拿到當前 表單 所有的欄位, 然後再使用 setFields 遍歷一下 把所有欄位的 錯誤狀態設為 null, 這樣我們就實現了 1,2 的效果,

6. 實現實時的錯誤判斷 hasError

由於子元件 需要一個 狀態 來知道 當前的表單是否有錯誤, 所以我們定義一個 hasError 的值 來實現, 由於要是實時的,所以不難想到用 getter 來實現,

熟悉Vue 的同學 可能會想到 Object.definedPropty 實現的 計算屬性,

本質上 Antd 提供的 表單欄位收集也是通過 setState, 回觸發頁面渲染, 在當前場景下, 直接使用 es6 支援的get 屬性即可實現同樣的效果 程式碼如下


get hasError() {
    const {
      form: { getFieldsError, isFieldTouched }
    } = this.props
    
    let fieldsError = getFieldsError() as any
    
    return Object
      .keys(fieldsError)
      .some((field) => !isFieldTouched(field) || fieldsError[field]))
    }
複製程式碼

程式碼很簡單 ,在每次 getter 觸發的時候, 我們用 some 函式 去判斷一下 當前的表單是否觸碰過 或者有錯誤, 在建立表單這個場景下, 如果沒有觸碰過,一定是沒輸入,所以不必驗證是否有錯

最後 在 render 的時候 將 hasError 傳給 子元件

  render() {
    return (
      <WrappedComponent
        wrappedComponentRef={this.getFormRef}
        {...this.props}
        hasError={this.hasError}
      />
    )
  }
  
  
  //父元件
  console.log(this.prop.hasError)
  <Button disabled={this.props.hasError}>提交</Button>
複製程式碼

同時我們定義下 type

export interface IAutoBindFormHelpProps {
  hasError: boolean,
}
複製程式碼

寫到這裡, 建立表單的場景, 基本上可以用這個高階元件輕鬆搞定, 但是有一些表單有一些非必填項, 這時就會出現,非必填項但是認為有錯誤的清空, 接下來, 改進一下程式碼

7. 優化元件, 支援 非必填欄位

非必填欄位, 即認為是一個配置項, 由呼叫者告訴我哪些是 非必填項, 當時我本來想搞成 自動去查詢 當前元件哪些欄位不是 requried 的, 但是 antd 的文件貌似 莫得, 就放棄了

首先修改函式, 增加一層柯里化

export default (filterFields: string[] = []) =>
  (WrappedComponent: React.ComponentClass<any>) => {
  }
複製程式碼
@autoBindForm(['fieldA','fieldB'])   //需要實現的元件
export default class FormPage extends React.PureComponent {
    
}
複製程式碼

修改 hasError 的邏輯

    get hasError() {
      const {
        form: { getFieldsError, isFieldTouched, getFieldValue },
        defaultFieldsValue,
      } = this.props

      const { filterFields } = this.state
      const isEdit = !!defaultFieldsValue
      let fieldsError = getFieldsError()

      const needOmitFields = filterFields.filter((field) => !isFieldTouched(field)).concat(needIgnoreFields)
      if(!isEmpty(needOmitFields)) {
        fieldsError = omit(fieldsError, needOmitFields)
      }

      return Object
        .keys(fieldsError)
        .some((field) => {
          const isCheckFieldTouched = !isEdit || isEmpty(getFieldValue(field))
          return isCheckFieldTouched ? (!isFieldTouched(field) || fieldsError[field]) : fieldsError[field]
        })
    }
複製程式碼

邏輯很簡單粗暴, 遍歷一下需要過濾的欄位,看它有沒有觸碰過,如果觸碰過,就不加入錯誤驗證

同理, 在 初始化的時候也過濾一下,

首先通過 Object.keys(getFieldsValue) 拿到當前表單 的所有欄位, 由於 這時候不知道哪些欄位 是 requierd 的, 機智的我

validateFields 驗證一下當前表單, 這個函式 返回當前表單的錯誤值, 非必填的欄位 此時不會有錯誤, 所以 只需要拿到當前錯誤資訊, 和 所有欄位 比較 兩者 不同的值, 使用 loadshxor 函式 完成

    const filterFields = xor(fields, Object.keys(err || []))
    this.setState({
      filterFields,
    })
複製程式碼

最後清空 所有錯誤資訊

完整程式碼:

 componentDidMount() {
      const {
        form: {
          validateFields,
          getFieldsValue,
          getFieldValue,
          setFields,
        },
      } = this.props

      const fields = Object.keys(getFieldsValue())

      validateFields((err: object) => {
        const filterFields = xor(fields, Object.keys(err || []))
        this.setState({
          filterFields,
        })

        const allFields: { [key: string]: any } = {}
        fields
          .filter((field) => !filterFields.includes(field))
          .forEach((field) => {
            allFields[field] = {
              value: getFieldValue(field),
              errors: null,
              status: null,
            }
          })

        setFields(allFields)

      })
    }
複製程式碼

經過這樣一波修改, 支援非必填欄位的需求就算完成了

8. 最後一波, 支援預設欄位

其實這個很簡單, 就是看子元件是否有預設值 , 如果有 setFieldsValue 一下就搞定了, 子元件和父元件約定一個 defaultFieldsValue

完整程式碼如下

import * as React from 'react'
import { Form } from 'antd'
import { xor, isEmpty, omit } from 'lodash'

const getDisplayName = (component: React.ComponentClass) => {
  return component.displayName || component.name || 'Component'
}

export interface IAutoBindFormHelpProps {
  hasError: boolean,
}

interface IAutoBindFormHelpState {
  filterFields: string[]
}

/**
 * @name AutoBindForm
 * @param needIgnoreFields string[] 需要忽略驗證的欄位
 * @param {WrappedComponent.defaultFieldsValue} object 表單初始值
 */
const autoBindForm = (needIgnoreFields: string[] = [] ) => (WrappedComponent: React.ComponentClass<any>) => {
  class AutoBindForm extends WrappedComponent {

    get hasError() {
      const {
        form: { getFieldsError, isFieldTouched, getFieldValue },
        defaultFieldsValue,
      } = this.props

      const { filterFields } = this.state
      const isEdit = !!defaultFieldsValue
      let fieldsError = getFieldsError()

      const needOmitFields = filterFields.filter((field) => !isFieldTouched(field)).concat(needIgnoreFields)
      if(!isEmpty(needOmitFields)) {
        fieldsError = omit(fieldsError, needOmitFields)
      }

      return Object
        .keys(fieldsError)
        .some((field) => {
          const isCheckFieldTouched = !isEdit || isEmpty(getFieldValue(field))
          return isCheckFieldTouched ? (!isFieldTouched(field) || fieldsError[field]) : fieldsError[field]
        })
    }

    static displayName = `HOC(${getDisplayName(WrappedComponent)})`

    state: IAutoBindFormHelpState = {
      filterFields: [],
    }

    autoBindFormHelp: React.Component<{}, {}> = null

    getFormRef = (formRef: React.Component) => {
      this.autoBindFormHelp = formRef
    }

    render() {
      return (
        <WrappedComponent
          wrappedComponentRef={this.getFormRef}
          {...this.props}
          hasError={this.hasError}
        />
      )
    }
    componentDidMount() {
      const {
        form: {
          validateFields,
          getFieldsValue,
          getFieldValue,
          setFields,
        },
      } = this.props

      const fields = Object.keys(getFieldsValue())

      validateFields((err: object) => {
        const filterFields = xor(fields, Object.keys(err || []))
        this.setState({
          filterFields,
        })

        const allFields: { [key: string]: any } = {}
        fields
          .filter((field) => !filterFields.includes(field))
          .forEach((field) => {
            allFields[field] = {
              value: getFieldValue(field),
              errors: null,
              status: null,
            }
          })

        setFields(allFields)

         // 由於繼承了 WrappedComponent 所以可以拿到 WrappedComponent 的 props
        if (this.props.defaultFieldsValue) {
          this.props.form.setFieldsValue(this.props.defaultFieldsValue)
        }
      })
    }
  }

  return Form.create()(AutoBindForm)
}

export default autoBindForm



複製程式碼

這樣一來, 如果子元件 有 defaultFieldsValue 這個 props, 頁面載入完就會設定好這些值,並且不會觸發錯誤

10. 使用

import autoBindForm from './autoBindForm'

# 基本使用
@autoBindForm()
class MyFormPage extends React.PureComponent {
    ...沒有靈魂的表單程式碼
}

# 忽略欄位

@autoBindForm(['filedsA','fieldsB'])
class MyFormPage extends React.PureComponent {
    ...沒有靈魂的表單程式碼
}

# 預設值

// MyFormPage.js
@autoBindForm()
class MyFormPage extends React.PureComponent {
    ...沒有靈魂的表單程式碼
}

// xx.js
const defaultFieldsValue = {
    name: 'xx',
    age: 'xx',
    rangePicker: [moment(),moment()]
}
<MyformPage defaultFieldsValue={defaultFieldsValue} />
複製程式碼

這裡需要注意的是, 如果使用 autoBindForm 包裝過的元件 也就是

<MyformPage defaultFieldsValue={defaultFieldsValue}/>
複製程式碼

這時候 想拿到 ref , 不要忘了 forwardRef

this.ref = React.createRef()
<MyformPage defaultFieldsValue={defaultFieldsValue} ref={this.ref}/>

複製程式碼

同理修改 'autoBindForm.js'

render() {
  const { forwardedRef, props } = this.props
  return (
    <WrappedComponent
      wrappedComponentRef={this.getFormRef}
      {...props}
      hasError={this.hasError}
      ref={forwardedRef}
    />
  )
}
return Form.create()(
    React.forwardRef((props, ref) => <AutoBindForm {...props} forwardedRef={ref} />),
)
複製程式碼

11. 最終程式碼

import * as React from 'react'
import { Form } from 'antd'
import { xor, isEmpty, omit } from 'lodash'

const getDisplayName = (component: React.ComponentClass) => {
  return component.displayName || component.name || 'Component'
}

export interface IAutoBindFormHelpProps {
  hasError: boolean,
}

interface IAutoBindFormHelpState {
  filterFields: string[]
}

/**
 * @name AutoBindForm
 * @param needIgnoreFields string[] 需要忽略驗證的欄位
 * @param {WrappedComponent.defaultFieldsValue} object 表單初始值
 */
const autoBindForm = (needIgnoreFields: string[] = []) => (WrappedComponent: React.ComponentClass<any>) => {
  class AutoBindForm extends WrappedComponent {

    get hasError() {
      const {
        form: { getFieldsError, isFieldTouched, getFieldValue },
        defaultFieldsValue,
      } = this.props

      const { filterFields } = this.state
      const isEdit = !!defaultFieldsValue
      let fieldsError = getFieldsError()

      const needOmitFields = filterFields.filter((field) => !isFieldTouched(field)).concat(needIgnoreFields)
      if (!isEmpty(needOmitFields)) {
        fieldsError = omit(fieldsError, needOmitFields)
      }

      return Object
        .keys(fieldsError)
        .some((field) => {
          const isCheckFieldTouched = !isEdit || isEmpty(getFieldValue(field))
          return isCheckFieldTouched ? (!isFieldTouched(field) || fieldsError[field]) : fieldsError[field]
        })
    }

    static displayName = `HOC(${getDisplayName(WrappedComponent)})`

    state: IAutoBindFormHelpState = {
      filterFields: [],
    }

    autoBindFormHelp: React.Component<{}, {}> = null

    getFormRef = (formRef: React.Component) => {
      this.autoBindFormHelp = formRef
    }

    render() {
      const { forwardedRef, props } = this.props
      return (
        <WrappedComponent
          wrappedComponentRef={this.getFormRef}
          {...props}
          hasError={this.hasError}
          ref={forwardedRef}
        />
      )
    }
    componentDidMount() {
      const {
        form: {
          validateFields,
          getFieldsValue,
          getFieldValue,
          setFields,
        },
      } = this.props

      const fields = Object.keys(getFieldsValue())

      validateFields((err: object) => {
        const filterFields = xor(fields, Object.keys(err || []))
        this.setState({
          filterFields,
        })

        const allFields: { [key: string]: any } = {}
        fields
          .filter((field) => !filterFields.includes(field))
          .forEach((field) => {
            allFields[field] = {
              value: getFieldValue(field),
              errors: null,
              status: null,
            }
          })

        setFields(allFields)

        // 屬性劫持 初始化預設值
        if (this.props.defaultFieldsValue) {
          this.props.form.setFieldsValue(this.props.defaultFieldsValue)
        }
      })
    }
  }

  return Form.create()(
    React.forwardRef((props, ref) => <AutoBindForm {...props} forwardedRef={ref} />),
  )
}

export default autoBindForm

複製程式碼

12. 結語

這樣一個 對 Form.create 再次包裝的 高階元件, 解決了一定的痛點, 少寫了很多模板程式碼, 雖然封裝的時候遇到了各種各樣奇奇怪怪的問題,但是都解決了, 沒毛病, 也加強了我對高階元件的認知,溜了溜了 :)