[React] 使用 FORMIK 搭配 Yup 實作表單驗證

體驗使用 FORMIK 搭配 Yup 實作表單驗證的開發模式

前言


筆者剛接觸 React 時,既有專案是配置 Redux-Form 作為表單控制套件,但當時我就有很大的疑問,為什麼單純的表單資料需要綁到 Redux 中 (類似全域變數),在我認知中該資料應該存在於特定表單組件 state 中,被好好封裝在組件中調用;後來看到 FORMIK 正好解開了我的疑惑,也是比較符合我期待的表單套件,而以下是 FORMIK 官網列出為何不使用 Redux-Form 的痛點及理由,大家可以參考看看(青菜蘿蔔各有喜好)。

  1. According to our prophet Dan Abramov, form state is inherently ephemeral and local, so tracking it in Redux (or any kind of Flux library) is unnecessary.
  2. Redux-Form calls your entire top-level Redux reducer multiple times ON EVERY SINGLE KEYSTROKE. This is fine for small apps, but as your Redux app grows, input latency will continue to increase if you use Redux-Form.
  3. Redux-Form is 22.5 kB minified gzipped (Formik is 12.7 kB).

 

測試版本:FORMIK v2.0.4, Yup v0.27.0

 

 

建立表單


建立表單的方式有很多種,可簡單使用 FORMIK 提供的 FormikFormField 及 ErrorMessage 組件來達成,在 Formik 中給予初始值 initialValues 並可設定為允許重複初始 enableReinitialize 旗標來滿足初始值重載需求,並且檢核邏輯也會從 validationSchema 傳入表單組件中,最後就是送出表單 onSubmit 事件;另外在 FieldErrorMessage 必須給予 name 作為欄位識別,大致上的結構如下。

 

 

重複賦予初始值


有時表單是有步驟性的,部分資料是從前個步驟表單設定的,因此需要從 prop 傳入表單資料來「重新」覆蓋 state 上的初始值,此時可以透過先前提過的 enableReinitialize 旗標來完成;以下範例具有相同概念,在建構子中定義表單初始資料 formInitValues 後,於 FormikinitValues 賦予初始表單資料,並延遲一秒模擬呼叫 api 行為來重新設定 formInitValues 狀態,讓表單的初始值重新調整。

 

 

調整欄位數值


有時候表單邏輯上的需要會主動去調整特定欄位值,例如貸款金額變動時必須調整可用的貸款期間範圍,因此我們可以透過 setFieldValue 方法賦予特定欄位新值。

驗證一下

 

 

檢核邏輯


MORMIK 的檢核方式可以完全手工打造,透過一個可以取得所有表單數值的方法逐一判斷,並且給予對應的錯誤訊息,但是.... 這樣的開發方式是有點攏長的,因此 MORMIK 有針對 Yup 檢核庫做深度整合,可以透過 Yup 簡便直覺的語法完成欄位檢核邏輯 schema 的設定。

Yup 設計概念中,主要就是去定義作為各欄位檢核的 schema 邏輯,當然預設會提供不少口語化的檢核邏輯,讓開發者一目了然各欄位的檢核重點,例如以下的官方範例。

import * as yup from 'yup'

const schema = yup.object().shape({
  // 字串、必填
  name: yup.string().required(),
  // 數字、必填、正整數
  age: yup.number().required().positive().integer(),
  // 字串、電子信箱
  email: yup.string().email(),
  // 字串、網址
  website: yup.string().url()
})

 

由於 FORMIK 已整合 Yup,因此只要把 schemavalidationSchema 傳入 Formik 表單組件中就搞定了。

<Formik validationSchema={schema}>
  {/* ... 略 ... */}
</Formik>

 

 

自訂檢核


自訂檢核邏輯的重點在於可重用性,因此可以將檢核邏輯包裝成一個 schema 來使用,以下是針對「用戶帳號名稱」作為檢核範例,兩點邏輯如下 (1) 小寫英文 (2) 不得為 admin 字串。

/* strAccountSchema.js */

import * as yup from 'yup'

// 可以傳入參數作為檢核邏輯或錯誤訊息文字
export default ({ title } = { title: '' }) => yup.string().test({
  name: 'accountSchema', // 檢核名稱 (不重複,套件內部使用)
  exclusive: true, // 如果有相同名稱的檢核時,使用此專用的檢核邏輯
  params: { title }, // 插入錯誤訊息的參數定義
  message: '${title}僅允許輸入小寫英文', // 預設錯誤訊息

  // 檢核邏輯
  // 回傳 true / false 表示是否合法,並使用"預設"錯誤訊息
  // 回傳 createError 表示發生特定錯誤需顯示特定的錯誤訊息
  test: async function isValid (value) {
    const { createError, path } = this

    // rule01 (自定義的錯誤訊息) - 不得為 admin 字串
    if (value === 'admin') {
      return createError({ path, message: `${title}禁止設定為 ${value}` })
    }

    // rule02 (非同步的遠端驗證) - 模擬後端資料驗證
    await new Promise(resolve => setTimeout(resolve, 50))

    // rule03 (預設的錯誤訊息) - 須為小寫英文
    return /^[a-z]+$/.test(value)
  }
})
允許非同步的遠端驗證,並可透過 createError 在不同情境下產生不同的錯誤訊息。

 

接著就可以透過 concat 方法將剛剛定義的 strAccountSchema 串到 Yup 的檢核 schema 中使用囉。

import strAccountSchema from '@src/utils/validations/strAccountSchema'

const schema = yup.object().shape({
  // [必填、小寫英文、不能為 admin 字串]
  account: yup.string().required().concat(strAccountSchema({ title: '帳號' }))
})

 

此邏輯不單單給 FORMIK 使用之外,也可以透過 isValid 方法複用此邏輯於任何地方。

import strAccountSchema from '@src/utils/validations/strAccountSchema'
await strAccountSchema().isValid('admin'); // => false

 

 

選擇性的檢核


有時欄位會依 state 數值而決定是否要做特定的檢核,以下為例當 isCheckAgeRange 狀態為 true 時才需要檢查輸入值是否大於 18 歲,因此若檢核條件不成立時,在 concat 中可以傳入 null 表示之。

class RegisterForm extends React.Component {
 
  // 表單檢核邏輯
  formSchema = () => yup.object().shape({
    // [必填、選擇性檢核需大於18歲邏輯] (相依狀態值)
    age: this.getAgeSchema()

  });

  // 年紀檢核邏輯
  getAgeSchema = () => {
    const { isCheckAgeRange } = this.state
    const required = yup.number().required()
    const min = yup.number().min(18)

    // 依據 isCheckAgeRange 狀態控制是否檢查大於 18 歲
    return required.concat(isCheckAgeRange ? min : null)
  }

  /* ... 略 ... */
}

 

 

相依性的檢核


常見例子就是變更密碼的表單,通常都需要輸入 pcode 密碼及 pcodeConfirm 確認密碼,並且在 pcode 有輸入的情況下去檢查 pcodeConfirm 是否有填寫,並且確認 pcode 與 pcodeConfirm 是否一致。

pcodeConfirm 檢核設定中透過 when 作為表單數值相依條件設定方法,先給予相依條件為 pcode 後,隨後針對 pcode 數值做相對應的檢核設定。首先當 pcode 有值時,透過 scheme 在向下串出 oneOf 條件來表示 pcodeConfirm 必須要與陣列中 pcode 數值同,若否則顯示「密碼需相同」錯誤訊息,並且也設定 required 必填限制;另外當 pcode 沒有資料時,則回傳原本的 schema 設定,也就是無特別檢核。

class RegisterForm extends React.Component {

  // 表單檢核邏輯
  formSchema = () => yup.object().shape({
    // [必填]
    pcode: yup.string().required(),
    // [密碼欄位輸入後才檢核。必填、需與密碼欄位輸入的資訊相同] (相依輸入值)
    pcodeConfirm: yup.string().when('pcode', (pcode, schema) => {
      return pcode ? schema.oneOf([pcode], '密碼需相同').required() : schema
    })
  })

  /* ... 略 ... */
}

 

效果如下:

沒有輸入 pcode 時,下方 pcodeConfirm 不會有檢核效果 (必填、比較密碼是否一致)

有輸入 pcode 時,會檢核「必填」及「密碼是否一致」邏輯

 

 

調整錯誤訊息


預設的 Yup 錯誤訊息可以透過 setLocale 方法進行覆寫,筆者習慣將這類 global setup 工作放置於 setupYup.js 獨立的檔案中,並在程式進入點中執行。

/* setupYup.js */

import { setLocale } from 'yup'

// ref: https://github.com/jquense/yup/blob/master/src/locale.js
setLocale({
  mixed: {
    default: '欄位檢核錯誤',
    required: '必填欄位'
  },
  string: {
    min: '請至少輸入 ${min} 字元',
    uppercase: '請輸入大寫英文'
  },
  number: {
    min: '輸入數值必須超過 ${min} '
  }
})

 

 

完整範例


在此提供一個具上述各功能的範例,有興趣的朋友可以體驗看看。

import React from 'react'
import { Formik, Form, Field, ErrorMessage } from 'formik'
import * as yup from 'yup'
import strAccountSchema from '@src/utils/validations/strAccountSchema'

class RegisterFrom extends React.Component {
  constructor (props) {
    super(props)
    this.formik = {}
    this.state = {
      // 是否需要檢核成年年齡
      isCheckAgeRange: true,
      // 初始表單資料
      formInitValues: {
        account: '',
        name: '',
        age: 0,
        pcode: '',
        pcodeConfirm: ''
      }
    }
  }

  async componentDidMount () {
    // 模擬呼叫 api 等待資料回應
    this.getUserInfoToFillForm()
  }

  getUserInfoToFillForm = () => {
    setTimeout(() => {
      // 再次初始化表單資料
      this.setState({
        ...this.state,
        formInitValues: {
          account: 'CHRIS',
          name: '克里斯',
          age: 10,
          pcode: '123',
          pcodeConfirm: '456'
        }
      })
    }, 1000)
  }

  // 表單檢核邏輯
  formSchema = () => yup.object().shape({
    // [必填、小寫英文、不能為 admin 字串]
    account: yup.string().required().concat(strAccountSchema({ title: '帳號' })),
    // [必填]
    name: yup.string().required(),
    // [必填、選擇性檢核需大於18歲邏輯] (相依狀態值)
    age: this.getAgeSchema(),
    // [必填]
    pcode: yup.string().required(),
    // [密碼欄位輸入後才檢核。必填、需與密碼欄位輸入的資訊相同] (相依輸入值)
    pcodeConfirm: yup.string().when('pcode', (pcode, schema) => {
      return pcode ? schema.oneOf([pcode], '密碼需相同').required() : schema
    })
  });

  getAgeSchema = () => {
    const { isCheckAgeRange } = this.state
    const required = yup.number().required()
    const min = yup.number().min(18)
    return required.concat(isCheckAgeRange ? min : null)
  }

  handleSwitchAgeRangeChecker = () => {
    this.setState({ ...this.state, isCheckAgeRange: !this.state.isCheckAgeRange })
  }

  handleChangeAgeTo18 = setFieldValue => e => {
    setFieldValue('age', 18)
  }

  handleRegister = async values => {
    const formData = JSON.stringify(values, null, 2)
    console.log('%c formData ', 'background-color: #3A88AE; color: white;font-size: 14px; font-weight: bold;', formData)
  }

  render () {
    return (
      <>
        <h3>會員註冊</h3>

        <Formik
          // 初始值
          initialValues={this.state.formInitValues}
          // 檢核邏輯
          validationSchema={this.formSchema()}
          // 送出表單
          onSubmit={this.handleRegister}
          // 允許重複賦予初始值
          enableReinitialize
        >

          {(formik) => {
            // Formik render methods and props
            // https://jaredpalmer.com/formik/docs/api/formik#formik-render-methods-and-props
            this.formik = formik // 如果需要在此組件外操作 formik 時可以使用
            const { isSubmitting, values, setFieldValue } = formik
            return (
              <Form className='tp-form'>

                <div className='tp-form__row'>
                  <div className='tp-code'>
                    {values ? JSON.stringify(values, null, 2) : ''}
                  </div>
                </div>

                <div className='tp-form__row'>
                  <div className='tp-form__label'> 帳號 </div>
                  <div className='tp-form__field'>
                    <Field type='text' name='account' />
                    <ErrorMessage name='account' component='div' className='tp-form__error' />
                  </div>
                </div>

                <div className='tp-form__row'>
                  <div className='tp-form__label'> 姓名 </div>
                  <div className='tp-form__field'>
                    <Field type='text' name='name' />
                    <ErrorMessage name='name' component='div' className='tp-form__error' />
                  </div>
                </div>

                <div className='tp-form__row'>
                  <div className='tp-form__label'> 年齡 </div>
                  <div className='tp-form__field'>
                    <Field type='number' name='age' />
                    <ErrorMessage name='age' component='div' className='tp-form__error' />
                  </div>
                </div>

                <div className='tp-form__row'>
                  <div className='tp-form__label'> 密碼 </div>
                  <div className='tp-form__field'>
                    <Field type='password' name='pcode' />
                    <ErrorMessage name='pcode' component='div' className='tp-form__error' />
                  </div>
                </div>

                <div className='tp-form__row'>
                  <div className='tp-form__label'> 確認密碼 </div>
                  <div className='tp-form__field'>
                    <Field type='password' name='pcodeConfirm' />
                    <ErrorMessage name='pcodeConfirm' component='div' className='tp-form__error' />
                  </div>
                </div>

                <div className='tp-form__row tp-form__row--right'>
                  <button type='button' onClick={this.handleChangeAgeTo18(setFieldValue)}> 設定年齡值為18 </button>
                  <button type='button' onClick={this.handleSwitchAgeRangeChecker}> 是否檢查年齡區間: {this.state.isCheckAgeRange ? 'Y' : 'N'}  </button>
                </div>

                <div className='tp-form__row tp-form__row--right'>
                  <button type='submit' disabled={isSubmitting}> 登入 </button>
                </div>
              </Form>
            )
          }}

        </Formik>

      </>
    )
  }
}

export default RegisterFrom

 

 

參考資訊


FORMIK - Build forms in React, without the tears.

Yup - Dead simple Object schema validation

 

 


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !