【step by step】使用 Vue 封裝一個表單校驗

1、前面的話

不論是PC端仍是移動端,提交表單是一個常見場景,那麼友好交互的表單校驗也是一個常見場景了。vue

最近本人在開發移動端頁面,也碰見了表單需求,倒黴的是技術選型中的UI庫不符合業務場景,因而~~~~node

我走上了一條本身動手,而後但願豐衣足食的道路.....正則表達式

不過當我將思路想清楚以後,我發現實現過程比我想象得簡單了不少!如今我來記錄一下個人思路。數組

2、思路

交互設計

因爲咱們的產品經理沒提表單交互要求(果真隨便纔是最難的),因而我就成了上帝,想怎麼折騰就怎麼折騰 ^ _ ^bash

因而我就根據經驗和業務場景,設計了一下需求:app

  1. 可校驗整個表單
  2. 可校驗表單的一部分
  3. 重置表單而且清除校驗結果
  4. 在表單字段的值變化或者輸入結束就校驗(通俗點就是輸入框值變化或者失焦時觸發校驗)

實現設計

根據業務場景,在實現表單校驗的過程當中,我封裝了三個組件:FormFormItemInput,它的結構應該下面這樣的:ide

下面是我對他們的功能分配:函數

1. 表單組件——Form

Form組件重點不在UI,它的重點在於邏輯實現,好比實現校驗(局部)表單,重置表單等這些功能。工具

之因此將這些功能放在Form組件中,是由於想和UI表單一致,將整個表單包裝成一個總體。ui

而若是將這些功能放在FormItem組件中,就是將表單打散了。並且,若是校驗整個表單或重置表單時,咱們就須要操做一個一個FormItem,這樣會讓咱們的代碼很難讀。

2. 表單項組件——FormItem

表單項組件重點在UI,主要呈現表單項標籤和校驗信息

3. 輸入框

其實這裏"輸入框"指的是能和用戶交互的部分,好比selectinputradiotextArea等。根據業務場景,我是對input進行了封裝。

3、實現

先把場景擺出來~~~

上面的場景就包含兩個校驗需求:

  1. 點擊提交,校驗整個表單
  2. 點擊獲取短信驗證碼,須要校驗是否輸入了正確的手機號,這就是一個校驗單個字段的業務場景。

如今開始一步一步實現表單校驗。

如今個人目錄結構是這樣的:

開始時,我是這樣寫的:

<template>
  <div>
    <li-form ref="form">
      <li-form-item>
        <li-input></li-input>
      </li-form-item>
      <!-- 省略部分 -->
    </li-form>
  </div>
</template>
export default {
  name: 'app',
  data() {
    return {
      form: {
        name: '', // 姓名
        phone: '', // 手機號
        email: '',  // 郵箱,
        code: '' // 驗證碼
      }
    }
  },
  components: {
    [Form.name]: Form,
    [FormItem.name]: FormItem,
    [Input.name]: Input
  }
}
</script>
複製代碼

咱們使用form來存儲用戶輸入,可是如今的表單只是一個基本的結構,還不具有校驗功能。

那麼,實現表單校驗的第一步是啥???

不知道你想到的第一步是什麼,我想到的第一步是實現校驗工具

step 1:實現校驗工具

校驗工具是用來實現校驗功能的

對於實現校驗工具,我須要思考兩個問題:

  1. 校驗工具的輸入和輸出
  2. 定義校驗規則

1. 校驗工具的輸入和輸出

校驗工具一般是一個函數,那麼這個函數就有會輸入和輸出。

校驗工具是爲了校驗用戶輸入,那麼它的輸入(參數)就應該是用戶的輸入和校驗規則,因爲能夠對用戶的輸入進行多個限制的校驗,那麼校驗規則就應該是一個數組。

校驗工具的輸出應該是校驗結果,校驗結果能夠是下面幾種方式:

  1. Promise.reject() / Promise.resolve(): 用Promise方法來表示校驗是否經過。
  2. Boolean

我選擇的是第二種方法。第一種方法的缺點是,在多個表單組合成一個大表單的時候,若是第一個表單校驗不經過(Promise.reject())會阻塞後面表單的校驗,不符合業務場景。

2. 定義校驗規則

對於用戶的輸入,咱們應該從什麼角度規定用戶的輸入是合適的,好比是不是必填項,限制長度、最小長度、最大長度、類型(數字或字符串)等。有時咱們還須要根據具體需求來動態校驗。因此,我從下面幾個角度來定義校驗規則:

  1. 是否必填
  2. 輸入長度
  3. 最小(大)輸入長度
  4. 類型
  5. 正則校驗規則
  6. 自定義校驗

假設,咱們如今對用戶的姓名作這樣的限制:必填,輸入長度在1 - 10;若是用戶沒有輸入,則提示"請輸入姓名";若是用戶輸入的字符超出了10個字符,則提示"請輸入1 - 10個字符"

// 表單:
const form = {
  name: '', // 姓名
  phone: '', // 手機號
  email: ''  // 郵箱
}
// 表單規則:
const rules = {
  name: [
    { required: true, message: '請輸入姓名' },
    { min: 1, max: 10, message: '請輸入1 - 10個字符'}
  ]
}
複製代碼

下面的方法是根據用戶的輸入,而後返回校驗信息,注意:這裏不是返回校驗結果。

// validator.js
/* rules = [ { required: true, message: '請輸入姓名' }, { min: 1, max: 10, message: '請輸入1 - 10個字符'} ] */
export const validator = (value, rules) => {
  const result = [] // 保存校驗信息
  rules.forEach(rule => {
    let msg = ''
    const {
      len = 0,          // 字段長度
      max = 0,          // 最大長度
      min = 0,          // 最小長度
      message = '',     // 校驗文案
      pattern = '',     // 正則表達式校驗
      type = 'string',  // 類型
      required = false, // 是否必填
      validator         // 自定義函數
    } = rule
    if (required && !value) {
      msg = message || '請輸入'
    }
    // typeValidator: 校驗類型
    if (type === 'string' && typeValidator(value) && value) {
      const length = String(value).length || 0
      // lengthValidator: 校驗長度
      msg = lengthValidator(length, min, max, len, message)
    }
    if (pattern) {
      const isReg = typeValidator(pattern, 'RegExp')
      if (!isReg) {
        msg = '正則校驗規則不正確'
      }
      if (!pattern.test(value)) {
        msg = message || '請輸入正確的值'
      }
    }
    if (validator && typeof validator === 'function') {
      msg = validator(value, rules)
    }
    if (msg) {
      result.push(msg)
    }
  })
  return result
}
// typeValidator: 類型校驗函數,比較粗糙~~~
const baseTypes = ['string', 'number', 'boolean']
const typeValidator = (value, type = 'string') => {
  if (baseTypes.includes(type)) {
    const valueType = typeof value
    return baseTypes.includes(valueType)
  } else if (type === 'array') {
    return Array.isArray(value)
  } else if (type === 'email') {
    const reg = /^[a-zA-Z0-9_.-]+@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z0-9]{2,6}$/
    return reg.test(value)
  } else if (type === 'RegExp') {
    return value instanceof RegExp
  }
}
// lengthValidator: 長度校驗函數
const lengthValidator = (length, min, max, len, message) => {
  if (len && len !== length) {
    return message || `請輸入${len}個字符`
  }
  if (min && length < min) {
    return message || `至少輸入${min}個字符`
  }
  if (max && length > max) {
    return message || `最多輸入${max}個字符`
  }
  if (min && max && (length < min || length > max)) {
    return message || `請輸入${min} ~ ${max}個字符`
  }
  return ''
}
複製代碼

若是用戶沒有輸入時,上面的方式執行後,會返回數組:

validator(form.name, rules.name)
// ['請輸入姓名', '請輸入 1 - 10 個字符']
複製代碼

Step 2: 郵遞員送信

在上一步中,咱們調用validator 方法就能夠獲得校驗信息,而前面咱們就有說,表單的校驗是在Form組件裏實現的,因此,咱們在Form組件中調用validator就能夠了,而調用validator須要拿到formrules這兩個值,咱們能夠經過prop的形式傳遞給Form組件。

因此,開始,Form組件是這樣的:

<!-- form.vue -->
<template>
  <div>
    <slot></slot>
  </div>
</template>
<script>
import { validator } from './validator'
export default {
  name: 'li-form',
  props: {
    data: {
      type: Object,
      default: () => ({})
    },
    rules: {
      type: Object,
      default: () => ({})
    }
  },
  methods: {
    // 校驗整個表單
    validateFields () {
      // ...
    },
  }
}
</script>
複製代碼

1. 校驗表單

Form組件裏validateFields方法來校驗整個表單,在這裏咱們就要根據校驗信息來返回校驗結果啦^ _ ^

實現校驗整個表單須要思考下面兩點:

  1. 校驗每一個字段,得到每一個字段的校驗信息,而後根據校驗信息,返回校驗結果。
  2. FormItem顯示校驗信息

第一個問題咱們已經實現了,只需每一個字段調用validator就能夠了:

// form.vue
validateFields () {
  let hasError = false
  const ruleKeys = Object.keys(this.rules)
  ruleKeys.forEach(ruleKey => {
    const value = this.data[ruleKey]
    const keyResult = this.validateField(value, ruleKey)
    if (!hasError) {
      hasError = keyResult.length > 0
    }
  })
  return hasError
}
validateField (value, prop) {
  const rules = this.rules[prop]
  let keyResult = validator(value, rules)
  return keyResult
}
複製代碼

上面代碼的邏輯以下圖所示:

咱們經過遍歷每一個字段(key),將表單(form)和校驗規則(rules)聯繫起來,這樣就能夠獲取到每一個字段的值(value)和對應的校驗規則(rule),最後調用校驗工具函數就能夠了

第二個問題FormItem 顯示校驗信息,就好像郵遞員送信同樣,須要將特定的信送到特定的收信人手上,這裏Form組件就是郵遞員,因此如今咱們就須要將信與收信人聯繫起來,將檢驗信息給FormItem,並顯示出來

那麼咱們怎麼將信與收信人聯繫起來呢?

回憶第一個問題,咱們是經過遍歷key獲得了對應的校驗信息,那咱們一樣能夠將keyFormItem綁定起來,用key做爲FormItem的惟一標識:

<template>
  <div>
    <li-form :data="form" :rules="rules" ref="form">
      <li-form-item prop="name">
        <li-input></li-input>
      </li-form-item>
    </li-form>
  </div>
</template>
export default {
  name: 'app',
  data() {
    return {
      form: {
        name: '', // 姓名
      },
      rules: {
        name: [
          { required: true, message: '請輸入姓名' },
          { min: 1, max: 10, message: '請輸入1 - 10個字符'}
        ]
      }
  }
}
</script>
複製代碼

如圖所示,這樣咱們就將FormItem和檢驗信息綁定起來:

在這裏,將keyFormItem綁定起來,具體是使用ref屬性。給FormItem添加ref屬性後,咱們就能夠拿到FormItem實例,而且能夠操做FormItem的屬性和方法。

因此,當咱們獲得校驗信息後,就能夠經過ref來操做FormItem實例,讓它顯示校驗信息。

下面是對FormItem的封裝:

<!-- form-item.vue -->
<template>
  <div :ref="prop">
    <div>
      <slot name="label">
        <span>{{ label }}</span>
      </slot>
    </div>
    <div>
      <slot></slot>
      <span>{{ msg }}</span>
    </div>
  </div>
</template>
<script>
export default {
  name: 'li-form-item',
  props: {
    prop: {
      type: String,
      default: ''
    },
  },
  data () {
    return {
      error: [] // 校驗信息:['請輸入姓名', '請輸入 1 - 10 個字符']
    }
  },
  computed: {
    msg () {
      return this.error[0] // 顯示第一個
    }
  }
}
</script>
複製代碼

FormItem校驗信息具體是什麼結構,這個就看我的決定了

如今咱們再回過頭來改寫validateFields

// form.vue
validateFields () {
  // ...
  const ruleKeys = Object.keys(this.rules)
  ruleKeys.forEach(ruleKey => {
    const value = this.data[ruleKey]
    const keyResult = this.validateField(value, ruleKey)
    // ...
  }
  // ...
},
validateField (value, prop) {
  const [vNode] = this.$children.filter(vnode => prop in vnode.$refs)
  const rules = this.rules[prop]
  let keyResult = []
  if (vNode && rules) {
    keyResult = validator(value, rules)
    vNode.error = keyResult
  }
  return keyResult
}
複製代碼

在頁面點擊提交的時候,咱們只寫下面的代碼就能夠實現整個表單的校驗啦!

this.$refs.form.validateFields()
複製代碼

到這裏咱們就實現了校驗整個表單

並且細心的同窗可能就會發現其實validateField方法就是校驗某個字段的方法。

validateField方法經過key 來獲得FormItem實例,而且獲得來校驗信息,可是它返回的是校驗信息,是一個數組。在這裏,我爲了和validateFields返回結構保持一致,因此我還另外寫了一個方法用來校驗某個字段

// form.vue

// 校驗表單字段
// @params value 表單字段值
// @params label 表單字段名稱
validateFieldValue (value, lable) {
  let hasError = false
  const keyResult = this.validateField(value, lable)
  hasError = keyResult.length > 0
  return hasError
}
複製代碼

這裏 validateFieldValue 只能校驗一個字段,它完整的功能應該是能夠校驗多個字段,可是我沒有這個業務場景,因此就沒寫,後面再完善這裏。

因此在頁面裏點擊獲取短信驗證碼按鈕時,寫上:

this.$refs.form.validateFieldValue(this.form.phone, 'phone')
複製代碼

這就能夠校驗單個字段了。

2. 重置表單

重置表單實現兩個功能:

  1. 將表單值清空
  2. 移除校驗結果

將表單值清空就須要將props傳過來的form清空,這就是和父組件通訊

移除校驗結果和校驗整個表單相似,只是不須要執行校驗工具函數(validator)而已

因此,重置表單:

// form.vue
// 重置表單
resetFields () {
  let obj = {}
  Object.keys(this.data).forEach(key => {
    obj = {
      ...obj,
      [key]: null
    }
  })
  this.validateFields(true)
  this.$emit('change', obj)
},
// 校驗整個表單
validateFields (reset = false) {
  // ...
  const ruleKeys = Object.keys(this.rules)
  ruleKeys.forEach(ruleKey => {
    const value = this.data[ruleKey]
    const keyResult = this.validateField(value, ruleKey, reset)
  }
  // ...
},
validateField (value, prop, reset = false) {
  // ...
  let keyResult = []
  if (vNode && rules) {
    if (!reset) {
      keyResult = validator(value, rules)
    }
    vNode.error = keyResult
  }
  return keyResult
},
複製代碼

Step 3: 寄信

表單校驗功能基本完成了,可是還有一個校驗功能沒實現,就是在輸入框值變化或者失焦的時候出發校驗,因此先讓咱們來改寫一下校驗規則:

rules: {
  name: [
    { required: true, message: '請輸入姓名', trigger: 'blur' },
    { min: 1, max: 10, message: '請輸入1 - 10個字符', , trigger: 'change' }
  ]
}
複製代碼

其中trigger就表示校驗是在失焦仍是值變化時觸發校驗。

那怎麼實現呢~~

這裏有兩種思路:

  1. Input組件內部再寫一個校驗方法
  2. 觸發Form組件的方法

第一種思路實現的核心仍是key

  1. 根據key,首先獲得該字段的校驗規則
  2. Input組件裏調用validator方法獲得校驗信息
  3. 操做父組件實例或使用事件,讓FormItem顯示校驗信息

思路以下所示

可是這個方法也有問題:

  1. 校驗可能會執行屢次,好比在最後一個輸入框輸完後,點擊提交,這會出發Form的總體校驗和Input的失焦校驗。
  2. Input做爲一個輸入UI,不必具有邏輯功能,這使得Input和父組件、FormItem組件的耦合性過高了
  3. 最後,最重要的一點,做爲一個深度懶癌患者,我不想將一樣的代碼,具有一樣的功能的代碼寫在兩個地方寫兩遍。

結果一想,這是讓InputForm通訊啊,若是採用事件,那必須得在父組件裏監聽Inputblur或者change事件,這樣靈活性過低了。

因此最後,我採用的是第二個方法——觸發Form組件的方法

實現第二種方法,咱們須要解決兩個問題:

  1. Input能拿到Form組件的實例
  2. 由於是調用validateFieldValue (value, lable) 方法,因此咱們還須要知道字段key

這個過程就好像咱們把信寫好了,須要去郵局去寄信同樣。

因此如今的問題是咱們怎麼知道郵局在哪呢?

image

在又雙叒叕看了VueAPI後,忽然靈光一現,發現Provide / inject能夠解決個人問題

這對選項須要一塊兒使用,以容許一個祖先組件向其全部子孫後代注入一個依賴,不論組件層次有多深,並在起上下游關係成立的時間裏始終生效。

Provide能夠向子孫後代注入依賴,那咱們將Form實例注入Input就能夠啦,另外字段key值咱們也可使用Provide ^ _ ^

如今咱們先在Form組件裏使用Provide來向子孫後代注入實例。

// form.vue
export default {
  provide () {
    return {
      liForm: this
    }
  }
}
複製代碼

而後在FormItem組件裏使用Provide向子孫注入key

// form-item.vue
export default {
  props: {
    prop: {
      type: String,
      default: ''
    }
  }
  provide () {
    return {
      formItem: this.prop
    }
  }
}
複製代碼

最後咱們在Input裏使用inject來接收依賴:

<!-- input.vue -->
<template>
  <div>
    <input
      :value="value"
      @blur="$blur"
      @change="$change"
      @input="$input"
    >
  </div>
</template>
<script>
export default {
  name: 'li-input',
  model: {
    prop: 'value',
    event: 'change'
  },
  inject: {
    liForm: {
      default: ''
    },
    formItem: {
      default: ''
    }
  },
  props: {
    value: [String, Number]
  },
  methods: {
    $blur (e) {
      this.$emit('blur', e)
      const value = e.target.value
      this.triggerValidate(value, 'blur')
    },
    $change (e) {
      const value = e.target.value
      this.$emit('change', value)
    },
    $input (e) {
      const value = e.target.value
      this.$emit('input', value)
      this.$emit('change', value)
      this.triggerValidate(value, 'change')
    },
    triggerValidate (value, triggerTime) {
      if (!(this.liForm && this.formItem)) return
      const trigger = this.liForm.getTriggerTimer(this.formItem, triggerTime)
      if (trigger === triggerTime) {
        this.liForm.validateField(value, this.formItem)
      }
    }
  }
}
</script>
複製代碼

Note:在inject裏給liFormformItem添加一個默認值(default),不然會報錯

liForm.getTriggerTimer是爲了獲取字段校驗規則裏面定義的校驗時機:

// form.vue
getTriggerTimer (lable, triggerTime) {
  const rules = this.rules[lable]
  const ruleItem = rules.find(item => item.trigger === triggerTime)
  const { trigger = '' } = ruleItem || {}
  return trigger
}
複製代碼

到這裏,表單校驗基本完成啦^ _ ^

最後

開始是打算附上源碼,並寫個Demo,但是目前沒時間啊,後面會加上。並且這代碼寫的比較粗糙,也須要完善,不過我寫這文章是爲了記錄個人思路,而後但願讓看到這篇文章的你引發思考,讓咱們能有一個思想上的碰撞。

下面來總結一下:

  1. Form組件實現校驗功能,FormItem顯示校驗信息,Input用於用戶輸入。
  2. 經過每一個字段的key值將FormFormItemInput組件聯繫在一塊兒。經過key值,Form操做FormItem實例,讓FormItem顯示校驗信息。
  3. 經過Provide/injectkey值,讓Input操做Form組件,實現失焦或值變化校驗。
相關文章
相關標籤/搜索