Skip to content

二.数据输入(表单)

前言 --> 表单组件特点

  • Form 组件分为两个部分:
    • 外层的Form表单域组件,一组表单控件只有一个 Form
    • 内部包含了多个FormItem组件,每一个表单控件都被一个 FormItem 包裹
  • 它的主要功能包括:
    • 数据校验,并在对应的 FormItem 中给出校验失败的提示(async-validator)
    • 鼠标滑出下拉菜单组件,隐藏待选菜单
    • 鼠标点击待选菜单中的条目,选中项文本更新,组件派发 change 事件

1.目录结构

sh
├── form
   ├── form-item.vue
   ├── form.vue
   └── index.js

2.接口设计

而 Form 和 FormItem 两个组件主要做数据校验,用不到 events。Form 的 slot 就是一系列的 FormItem,FormItem 的 solt 就是具体的表单控件,比如输入框。

Form组件中,定义两个 props:

  • model:表单控件绑定的数据对象,在校验或重置时会访问该数据对象下对应的表单数据,类型为 Object

  • rules:表单验证规则,即上面介绍的 async-validator 所使用的校验规则,类型为 Object

    FormItem组件中,也定义两个 props:

  • label:单个表单组件的标签文本,类似原生<label>元素,类型为 String

  • prop:对应表单域 Form 组件 model 里的字段,用于校验或重置时访问表单组件绑定的数据,类型为 String

vue
<template>
  <div>
    <i-form :model="formValidate" :rules="ruleValidate">
      <i-form-item label="用户名" prop="name">
        <i-input v-model="formValidate.name"></i-input>
      </i-form-item>
      <i-form-item label="邮箱" prop="mail">
        <i-input v-model="formValidate.mail"></i-input>
      </i-form-item>
    </i-form>
  </div>
</template>

<script>
import iForm form '../components/form/form.vue'
import iFormItem from '../components/form/form-item.vue'
import iInput from '../components/input/input.vue'

export default {
  components: {
    iForm,
    iFormItem,
    iInput
  },
  data(){
    return {
      formValidate:{
        name: '',
        mail: ''
      },
      ruleValidate: {
        name: [
          {required:true,message:'用户名称不能为空',trigger:'blur'}
        ],
        mail: [
          {required:true, message:'邮箱不能为空',trigger:'blur'},
          {type:'email',message:'邮箱格式不正确',trigger:'blur'}
        ]
      }
    }
  }
}
</script>
vue
/* form.vue */
<template>
  <form>
    <slot></slot>
  </form>
</template>
<script>
export default {
  name: "iForm",
  props: {
    model: {
      type: Object,
    },
    rules: {
      type: Object,
    },
  },
}
</script>
vue
/* form-item.vue */
<template>
  <div>
    <label v-if="label">{{ label }}</label>
    <div>
      <slot></slot>
    </div>
  </div>
</template>
<script>
export default {
  name: "iFormItem",
  props: {
    label: {
      type: String,
      default: "",
    },
    prop: {
      type: String,
    },
  },
}
</script>

3.在 Form 中缓存 FormItem 实例

Form组件的核心功能是数据校验,一个 Form 中包含了多个 FormTtem,当点击提交按钮时,要逐一对每一个 FormItem 内的表单组件校验,而校验是由使用者发起,并通过Form来掉用每一个FormItem的验证方法,再将校验结果汇总后,通过Form返回出去。

因为要在 Form 中逐一调用 FormItem 的验证方法,而 Form 和 FormItem 是独立的,需要预先将 FormItem 的每个实例缓存在 Form 中。每当 FormItem 渲染时,将其自身作为参数通过dispatch派发到Form组件中,然后通过一个数组缓存起来;同理当 FormItem 销毁时,将其从 Form 缓存的数组中移除

vue
<script>
import Emitter from "../../mixins/emitter.js"
export default {
  name: "iFormItem",
  mixins: [Emitter],
  mounted() {
    if (this.prop) {
      this.dispatch("iForm", "on-form-item-add", this)
    }
  },
  beforeDestory() {
    this.dispatch("iForm", "on-form-item-remove", this)
  },
}
</script>

组件渲染顺序是由内而外的,所以 FormItem 要先于 Form 渲染,在 FormItem 的 mounted 触发时,我们向 Form 派发了事件on-form-item-add,并将当前 FormItem 的实例(this)传递给了 Form,而此时,Form 的 mounted 尚未触发,因为 Form 在最外层,如果 Form 的 mounted 里监听事件,是不可以的,所以要在其 created 内监听自定义事件,Form 的 created 要先于 FormItem 的 mounted。

vue
<script>
export default {
  name: "iForm",
  data() {
    return {
      fields: [],
    }
  },
  created() {
    this.$on("on-form-item-add", (field) => {
      if (field) this.fields.push(field)
    })
    this.$on("on-form-item-remove", (filed) => {
      if (field.prop) this.fields.splice(this.fields.indexOf(field), 1)
    })
  },
}
</script>

定义的数据fields就是用来缓存所有 FormItem 实例的。

4.触发校验

Form 支持两种事件来触发校验:

  • blur: 失去焦点时触发,常见的有输入框失去焦点时触发校验
  • change:实时输入是触发或选择时触发,常见的有输入框实时输入时触发校验、下拉选择器选择项目时触发校验等

以上两个事件,都是有具体的表单组件来触发的,我们先来编写一个简单的输入框组件i-input。在components下新建目录input,创建文件input.vue

vue
<template>
  <input
    type="text"
    :value="currentValue"
    @input="handleInput"
    @blur="handleBlur"
  />
</template>
<script>
import Emitter from '../../mixins/emitter.js'
export default{
  name: 'iInput',
  mixins:[Emitter],
  props:{
    value:{
      type:String,
      default:'
    }
  },
  data(){
    return {
      currentValue:this.value
    }
  },
  watch:{
    value(value){
      this.currentValue=val
    }
  },
  methods:{
    handleInput(event){
      const value = event.target.value
      this.currentValue= value
      this.$emit('input',value)
      this.dispatch('iFormItem','on-form-change',value)
    },
    handleBlur(){
      this.dispatch('iFormItem','on-from-blur',this.currentValue)
    }
  }
}
</script>

input 组件中,绑定在<input>元素上的原生事件@input,每当输入一个字符,都会调用句柄handleInput,并通过dispatch方法向上级的 FormItem 组件派发自定义事件on-form-change;同理,绑定的原生事件@blur会在 input 失焦时触发,并传递事件on-form-blur

基础组件有了,接下来要做的,是在 FormItem 中监听 input 组件派发的自定义事件。这里可以在 mounted 中监听,因为你的手速远赶不上组件的渲染速度,不过在 created 中监听也是没有任何问题的。

vue
<script>
// form-item.vue
export default {
  methods: {
    setRules() {
      this.$on("on-form-blur", this.onFieldBlur)
      this.$on("on-form-change", this.onFieldChange)
    },
    mounted() {
      if (this.prop) {
        this.dispatch("iForm", "on-form-item-add", this)
        this.setRules()
      }
    },
  },
}
</script>

通过调用setRules方法,监听表单组件的两个事件,并绑定了句柄函数onFieldBluronFiledChange,分别对应 blur 和 change 两种事件类型。当 onFieldBlur 或 onFieldChange 函数触发时,就意味着 FormItem 要对当前的数据进行一次校验。当前的数据,指的就是通过表单域 Form 中定义的 props:model,结合当前 FormItem 定义的 props:props 来确定的数据,可以回顾上文写过的用例。

因为 FormItem 中只定义了数据源的某个 key 名称(即属性 prop),要拿到 Form 中 model 里的数据,需要用到 provide/inject。所以在 Form 中,把整个实例(this)向下提供,并在 FormItem 中注入:

js
// form.vue,部分代码省略
export default {
  provide() {
    return {
      form: this,
    }
  },
}
js
// form-item.vue,部分代码省略
export default {
  inject: ["form"],
}

准备好这些,接着就是最核心的校验功能了。blur 和 change 事件都会触发校验,它们调用同一个方法,只是参数不同。相关代码如下:

js
// form-item.vue,部分代码省略
import AsyncValidator from "async-validator"

export default {
  inject: ["form"],
  props: {
    prop: {
      type: String,
    },
  },
  data() {
    return {
      validateState: "", // 校验状态
      validateMessage: "", // 校验不通过时的提示信息
    }
  },
  computed: {
    // 从 Form 的 model 中动态得到当前表单组件的数据
    fieldValue() {
      return this.form.model[this.prop]
    },
  },
  methods: {
    // 从 Form 的 rules 属性中,获取当前 FormItem 的校验规则
    getRules() {
      let formRules = this.form.rules

      formRules = formRules ? formRules[this.prop] : []

      return [].concat(formRules || [])
    },
    // 只支持 blur 和 change,所以过滤出符合要求的 rule 规则
    getFilteredRule(trigger) {
      const rules = this.getRules()
      return rules.filter(
        (rule) => !rule.trigger || rule.trigger.indexOf(trigger) !== -1
      )
    },
    /**
     * 校验数据
     * @param trigger 校验类型
     * @param callback 回调函数
     */
    validate(trigger, callback = function() {}) {
      let rules = this.getFilteredRule(trigger)

      if (!rules || rules.length === 0) {
        return true
      }

      // 设置状态为校验中
      this.validateState = "validating"

      // 以下为 async-validator 库的调用方法
      let descriptor = {}
      descriptor[this.prop] = rules

      const validator = new AsyncValidator(descriptor)
      let model = {}

      model[this.prop] = this.fieldValue

      validator.validate(model, { firstFields: true }, (errors) => {
        this.validateState = !errors ? "success" : "error"
        this.validateMessage = errors ? errors[0].message : ""

        callback(this.validateMessage)
      })
    },
    onFieldBlur() {
      this.validate("blur")
    },
    onFieldChange() {
      this.validate("change")
    },
  },
}

在 FormItem 的 validate() 方法中,最终做了两件事:

  1. 设置了当前的校验状态 validateState 和校验不通过提示信息 validateMessage(通过值为空);
  2. 将 validateMessage 通过回调 callback 传递给调用者,这里的调用者是 onFieldBlur 和 onFieldChange,它们只传入了第一个参数 trigger,callback 并未传入,因此也不会触发回调,而这个回调主要是给 Form 用的,因为 Form 中可以通过提交按钮一次性校验所有的 FormItem(后文会介绍)这里只是表单组件触发事件时,对当前 FormItem 做校验。

除了校验,还可以对当前数据进行重置。重置是指将表单组件的数据还原到最初绑定的值,而不是清空,因此需要预先缓存一份初始值。同时我们将校验信息也显示在模板中,并加一些样式。相关代码如下:

vue
<!-- form-item.vue,部分代码省略 -->
<template>
  <div>
    <label v-if="label" :class="{ 'i-form-item-label-required': isRequired }">{{
      label
    }}</label>
    <div>
      <slot></slot>
      <div v-if="validateState === 'error'" class="i-form-item-message">
        {{ validateMessage }}
      </div>
    </div>
  </div>
</template>
<script>
export default {
  props: {
    label: {
      type: String,
      default: "",
    },
    prop: {
      type: String,
    },
  },
  data() {
    return {
      isRequired: false, // 是否为必填
      validateState: "", // 校验状态
      validateMessage: "", // 校验不通过时的提示信息
    }
  },
  mounted() {
    // 如果没有传入 prop,则无需校验,也就无需缓存
    if (this.prop) {
      this.dispatch("iForm", "on-form-item-add", this)

      // 设置初始值,以便在重置时恢复默认值
      this.initialValue = this.fieldValue

      this.setRules()
    }
  },
  methods: {
    setRules() {
      let rules = this.getRules()
      if (rules.length) {
        rules.every((rule) => {
          // 如果当前校验规则中有必填项,则标记出来
          this.isRequired = rule.required
        })
      }

      this.$on("on-form-blur", this.onFieldBlur)
      this.$on("on-form-change", this.onFieldChange)
    },
    // 从 Form 的 rules 属性中,获取当前 FormItem 的校验规则
    getRules() {
      let formRules = this.form.rules

      formRules = formRules ? formRules[this.prop] : []

      return [].concat(formRules || [])
    },
    // 重置数据
    resetField() {
      this.validateState = ""
      this.validateMessage = ""

      this.form.model[this.prop] = this.initialValue
    },
  },
}
</script>
<style>
.i-form-item-label-required:before {
  content: "*";
  color: red;
}
.i-form-item-message {
  color: red;
}
</style>

至此,FormItem 代码已经完成,不过它只具有单独校验的功能,也就是说,只能对自己的一个表单组件验证,不能对一个表单域里的所有组件一次性全部校验。而实现全部校验和全部重置的功能,要在 Form 中完成。

上文已经介绍到,在 Form 组件中,预先缓存了全部的 FormItem 实例,自然也能在 Form 中调用它们。通过点击提交按钮全部校验,或点击重置按钮全部重置数据,只需要在 Form 中,逐一调用缓存的 FormItem 实例中的 validateresetField 方法。相关代码如下:

js
// form.vue,部分代码省略
export default {
  data() {
    return {
      fields: [],
    }
  },
  methods: {
    // 公开方法:全部重置数据
    resetFields() {
      this.fields.forEach((field) => {
        field.resetField()
      })
    },
    // 公开方法:全部校验数据,支持 Promise
    validate(callback) {
      return new Promise((resolve) => {
        let valid = true
        let count = 0
        this.fields.forEach((field) => {
          field.validate("", (errors) => {
            if (errors) {
              valid = false
            }
            if (++count === this.fields.length) {
              // 全部完成
              resolve(valid)
              if (typeof callback === "function") {
                callback(valid)
              }
            }
          })
        })
      })
    },
  },
}

虽然说 Vue.js 的 API 只来自 prop、event、slot 这三个部分,但一些场景下,需要通过 ref 来访问这个组件,调用它的一些内置方法,比如上面的 validateresetFields 方法,就需要使用者来主动调用。

resetFields 很简单,就是通过循环逐一调用 FormItem 的 resetField 方法来重置数据。validate 稍显复杂,它支持两种使用方法,一种是普通的回调,比如:

vue
<template>
  <div>
    <i-form ref="form"></i-form>
    <button @click="handleSubmit">提交</button>
  </div>
</template>
<script>
export default {
  methods: {
    handleSubmit() {
      this.$refs.form.validate((valid) => {
        if (valid) {
          window.alert("提交成功")
        } else {
          window.alert("表单校验失败")
        }
      })
    },
  },
}
</script>

同时也支持 Promise,例如:

js
handleSubmit () {
  const validate = this.$refs.form.validate();

  validate.then((valid) => {
    if (valid) {
      window.alert('提交成功');
    } else {
      window.alert('表单校验失败');
    }
  })
}

在 Form 组件定义的 Promise 中,只调用了 resolve(valid),没有调用 reject(),因此不能直接使用 .catch() ,不过聪明的你稍作修改,肯定能够支持到!

完整的用例如下:

vue
<template>
  <div>
    <h3>具有数据校验功能的表单组件——Form</h3>
    <i-form ref="form" :model="formValidate" :rules="ruleValidate">
      <i-form-item label="用户名" prop="name">
        <i-input v-model="formValidate.name"></i-input>
      </i-form-item>
      <i-form-item label="邮箱" prop="mail">
        <i-input v-model="formValidate.mail"></i-input>
      </i-form-item>
    </i-form>
    <button @click="handleSubmit">提交</button>
    <button @click="handleReset">重置</button>
  </div>
</template>
<script>
import iForm from "../components/form/form.vue"
import iFormItem from "../components/form/form-item.vue"
import iInput from "../components/input/input.vue"

export default {
  components: { iForm, iFormItem, iInput },
  data() {
    return {
      formValidate: {
        name: "",
        mail: "",
      },
      ruleValidate: {
        name: [{ required: true, message: "用户名不能为空", trigger: "blur" }],
        mail: [
          { required: true, message: "邮箱不能为空", trigger: "blur" },
          { type: "email", message: "邮箱格式不正确", trigger: "blur" },
        ],
      },
    }
  },
  methods: {
    handleSubmit() {
      this.$refs.form.validate((valid) => {
        if (valid) {
          window.alert("提交成功")
        } else {
          window.alert("表单校验失败")
        }
      })
    },
    handleReset() {
      this.$refs.form.resetFields()
    },
  },
}
</script>
<template>
    <vue-button>按钮</vue-button>
</template>

总结

通过对前端组件的分析,需要重点关注组件中易变性对组件封装的影响,它会对组件的可复用性、可扩展性产生很大影响