简单仿写一个 ElementUI 的 Form 组件

3,028 阅读9分钟

使用 vue 做了不少管理后台方面的业务,都使用了 ui 框架,或iview,或Element。用框架很香,但业务写多了也不免想自己试着实现一下框架中的一些组件,于是我选择了Form组件,没有特别的理由,就是表单组件用的实在很频繁。我看了下Element的源码,兼顾的很多,代码也比较多,所以就不照抄源码了,只简单来实现一个demo,同时也不写css代码,毕竟不是为了用于生产,只是了解原理。

看效果可以拉到最下面。

明确要实现的效果

Element中一个最最基本的表单结构应该是这样的:

<el-form :model="form" :rules="rules" ref="loginForm">
  <el-form-item label="用户名" prop="username">
    <el-input v-model="form.username"></el-input>
  </el-form-item>
</el-form>

包含三个组件:FormFormItemInput。我准备实现的就是这三个部分,并且带表单验证功能。

实现Input

首先来实现一个 input 组件,取个名字就叫做AInput吧,它的特点是可以使用v-model进行数据绑定,所以在使用时应该是这样的:

<a-input v-model="username" />

那么问题来了,怎么使得自定义组件上的v-model命令生效呢?vue文档给出了答案,可参考自定义组件的 v-model

大致意思是说当自定义组件使用了v-model命令时,会在组件内部解析为名为value的prop和名为input的事件。利用这个规则,可以这样来编写AInput组件的代码:

<!-- AInput 组件代码 -->
<template>
  <div>
    <input :value="value" @input="$emit('input',$event.target.value)">
  </div>
</template>

<script>
export default {
  props: {
    value: [String, Number]
  }
}
</script>

相信这段代码大家都能看得懂,来稍微捋一下,当在 input 输入框输入时,触发一个自定义事件input,并将输入值作为参数传入,此时父组件是可以监听input这个自定义事件的,并可以拿到输入值,而且我们希望父组件可以将拿到的输入值通过 prop 方式传回给子组件,以更新子组件的 input 标签的 value 值。流程是这样的:

流程中的第二步父组件的操作其实就是v-model起到的作用,所以我们需要按照规定,prop传入值的名字要为value,注意 prop 也可以传入其他名字的值,只是要绑定到 input 标签 value 上的值必须叫做 "value",而自定义事件名也必须为input

简单分析之后,其实也可以了解到v-model到底做了啥,无非就是取值再传值,当不使用v-model时,它就是这样用的:

<template>
  <div id="app">
    <!-- <a-input v-model="username" /> -->

    <!-- 此方式等效于使用 v-model -->
    <a-input :value="username" @input="handleInput" />
  </div>
</template>

<script>
import AInput from './components/Input'
export default {
  components: {
    AInput
  },
  data () {
    return {
      username: ''
    }
  },
  methods: {
    handleInput (val) {
      this.username = val
    }
  }
}
</script>

补充:前面说了,一个组件上的 v-model 会把 value 用作 prop 且把 input 用作 event,这是默认情况下的。其实valueinput这两个名字是可以修改的,可参考:model

实现FormItem

form-item的作用是包裹input等具体输入组件,所以我们肯定知道要使用slot插槽,除此之外,它需要传入两个 prop,一个是label,作用显而易见是标签文本,另外一个是prop,它的作用是用来做验证的。按照条件我们实现如下代码:

<!-- FormItem 组件代码 -->
<template>
  <div>
    <label for="">{{label}}</label>
    <slot></slot>
  </div>
</template>

<script>
export default {
  props: {
    label: String,
    prop: String
  }
}
</script>

不要怀疑,代码就是这么简单,只是还没写关于验证方面的东西,这个我们放到后面再说。现在使用时就可以这样了:

<a-form-item label="用户名" prop="username">
  <a-input v-model="username" />
</a-form-item>

实现Form

首先不用考虑的就是Form里肯定有插槽,然后它需要传入model,这是表单数据对象,还要传入rules,这个是表单验证规则。ref不用多说,可以用来访问组件实例,不用我们实现。所以根据条件,我们实现Form的代码如下:

<!-- Form 组件代码 -->
<template>
  <div>
    <form>
      <slot></slot>
    </form>
  </div>
</template>

<script>
export default {
  props: {
    model: Object,
    rules: Object
  }
}
</script>

写完之后我都惊呆了,这么简单的么,就这样也能叫做表单组件吗?

我们来回想一下要完成的效果,一是输入时能够使用v-model进行数据绑定,这个已经实现,二是能够对输入值进行规则校验,这个待完成。所以不用怀疑,如果你不要表单验证功能,那么上面代码已经可以了,无非就是再加上css代码,使得结构美观。现在可以使用表单如下:

<template>
  <div id="app">
    <a-form :model="form" :rules="rules" ref="form">
      <a-form-item label="用户名" prop="username">
        <a-input v-model="form.username" />
      </a-form-item>
    </a-form>
  </div>
</template>

<script>
import AInput from './components/Input'
import AFormItem from './components/FormItem'
import AForm from './components/Form'
export default {
  components: {
    AInput,
    AFormItem,
    AForm
  },
  data () {
    return {
      form: {
        username: ''
      },
      rules: {}
    }
  }
}
</script>

到这里,已经有内味了,而我们还没用上的几个prop:modelrulesprop全是为了表单验证准备的。接下来就做表单验证功能。

实现表单验证

表单验证功能是放在FormItem组件里的,所以接下来着重完善这个组件。

添加验证信息的显示位置

首先,警告信息的显示位置,一般是在输入框的下方,当有错误时才显示,无错误时隐藏,所以改造FormItem代码如下:

<template>
  <div>
    <label for="">{{label}}</label>
    <div>
      <slot></slot>
      <!-- 添加错误提示信息 -->
      <p v-if="errStatus">{{errMessage}}</p>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    label: String,
    prop: String
  },
  data () {
    return {
      errStatus: false, // 错误状态,false or true
      errMessag: '' // 错误信息
    }
  }
}
</script>

使用 async-validator 做验证

Element中表单验证用到了第三方插件 async-validator,所以这里我也使用这个插件,关于它的使用可以自行查阅文档,这里有一个简单的使用说明:

// 引入插件
import schema from 'async-validator'

// 定义一个descriptor,这个就是我们平时写在 rules 中的规则
var descriptor = {
  name: {
    type: "string",
    required: true,
    validator: (rule, value) => value === 'muji',
  },
}

// 将 descriptor 传入 schema,得到 validator 实例
var validator = new schema(descriptor)

// 将需要校验的对象和回调传入 validator.validate 方法中
validator.validate({name: "muji"}, (errors, fields) => {
  if(errors) {
    // validation failed, errors is an array of all errors
    // fields is an object keyed by field name with an array of
    // errors per field

    // 校验未通过的情况,errors 是所有错误的数组
    // fields 是一个 object,以字段作为 key 值,该字段对应的错误数组作为 value
    // (其实 fields 就是把 errors 按照原对象的 key 值分组)

    return handleErrors(errors, fields);
  }

  // validation passed
  // 这里说明校验已通过
})

安装就不演示了,如果已经安装了Element,也会默认安装async-validator

FormItem 中获取 rulesmodel

从上面async-validator插件的使用说明中可以知道,我们需要在 FormItem 中获取 rulesmodel。那么问题又来了,rulesmodel是传入到Form中的,怎么才能在FormItem中获取Form中的数据呢?

你可能会想为什么不直接将这两个值传入到FormItem中呢?试想下真要这样的话,那每一个FormItem都要重复传入这两个对象,代码一下子臃肿起来,对性能也造成一定影响。所以还是要相信Element的设计思想,按照现有的来。

Element中解决这个问题的方法就是使用provide / inject。之前我写过一篇专栏 vue组件通信总结,里面介绍了一些 vue 中的通信方式,但是没有写到provide / inject,为此还有小伙伴在评论区提出来了,没写是因为vue文档中有这样一句话:

provide 和 inject 主要为高阶插件/组件库提供用例。并不推荐直接用于应用程序代码中。

那么我想说现在可以用了,因为我在写“组件库”啊,hh。使用它们很简单,我们只需在父组件中用provide提供一个值,然后在子组件使用inject就可以拿到父组件提供的值,在我们这个案例中,我们需要在Form中提供值,在FormItem中接收,代码如下:

<!-- Form 组件代码 -->
<!-- 添加 provide -->
<template>
  <div>
    <form>
      <slot></slot>
    </form>
  </div>
</template>

<script>
export default {
  // provide 与 data 等选项同级
  provide () {
    return {
      aForm: this // 将 AForm 组件自身提供给下层组件
    }
  },
  props: {
    model: Object,
    rules: Object
  }
}
</script>

再在FormItem组件中接收:

<!-- FormItem 组件代码 -->
<!-- 添加 inject -->
<template>
  <div>
    <label for="">{{label}}</label>
    <div>
      <slot></slot>
      <p v-if="errStatus">{{errMessage}}</p>
    </div>
  </div>
</template>

<script>
export default {
  inject: ['aForm'], // 接收 aForm
  props: {
    label: String,
    prop: String
  },
  data () {
    return {
      errStatus: false,
      errMessag: ''
    }
  }
}
</script>

现在就可以在 FormItem 中分别使用 this.aForm.modelthis.aForm.rules 来获取 modelrules 了。

完善验证功能

上面铺垫的差不多了,可以来完善最后一步了。首先,我们要考虑下什么时候触发验证,显然是在输入框输入的时候。那么先要改造下 AInput 组件,如下:

<!-- AInput 组件代码 -->
<template>
  <div>
    <input :value="value" @input="onInput">
  </div>
</template>

<script>
export default {
  props: {
    value: [String, Number]
  },
  methods: {
    onInput (e) {
      this.$emit('input', e.target.value)
      this.$parent.$emit('validate') // 输入时,使 FormItem 自身触发 validate 事件
    }
  }
}
</script>

如代码中所示,当输入时,让 FormItem 自身触发 validate 自定义事件,$parent指当前实例的父实例,此处指的就是FormItem。此时FormItem还需监听这个 validate 事件,以进行验证逻辑,所以最终 FormItem 的代码如下:

<!-- FormItem 组件代码 -->
<template>
  <div>
    <label for="">{{label}}</label>
    <div>
      <slot></slot>
      <p v-if="errStatus">{{errMessage}}</p>
    </div>
  </div>
</template>

<script>
import Schema from 'async-validator' // 引入插件
export default {
  inject: ['aForm'], // 接收 aForm
  props: {
    label: String,
    prop: String
  },
  data () {
    return {
      errStatus: false,
      errMessag: ''
    }
  },
  mounted () {
    // 监听自身触发的 validate 方法,进行验证逻辑
    this.$on('validate', this.handleValidate)
  },
  methods: {
    // 定义验证方法
    handleValidate () {
      // 获取此时正在输入的值
      // prop 是通过 props传入的,表示当前要验证的字段,此例中指的就是 username
      const value = this.aForm.model[this.prop]

      // 获取 username 的验证规则
      const rule = this.aForm.rules[this.prop]

      // 描述对象
      const descriptor = { [this.prop]: rule }

      // 传入描述对象,创建验证实例
      const validator = new Schema(descriptor)

      // 校验
      validator.validate({ [this.prop]: value }, errors => {
        if (errors) {
          this.errMessage = errors[0].message
          this.errStatus = true
        } else {
          this.errMessage = ''
          this.errStatus = ''
        }
      })
    }
  }
}
</script>

测试效果

简单测试一下完成的效果,下面举一个例子:

<!-- Form 表单组件测试demo -->
<template>
  <div id="app">
    <a-form :model="form" :rules="rules" ref="form">
      <a-form-item label="用户名" prop="username">
        <a-input v-model="form.username" />
      </a-form-item>
      <a-form-item label="密码" prop="password">
        <a-input v-model="form.password" />
      </a-form-item>
      <a-form-item label="确认密码" prop="checkPass">
        <a-input v-model="form.checkPass" />
      </a-form-item>
    </a-form>
  </div>
</template>

<script>
import AInput from './components/Input'
import AFormItem from './components/FormItem'
import AForm from './components/Form'
export default {
  components: {
    AInput,
    AFormItem,
    AForm
  },
  data () {
    var validatePass = (rule, value, callback) => {
      if (value === '') {
        callback(new Error('请输入密码'))
      } else {
        if (this.form.checkPass !== '') {
          this.$refs.form.validateField('checkPass')
        }
        callback()
      }
    }
    var validatePass2 = (rule, value, callback) => {
      if (value === '') {
        callback(new Error('请再次输入密码'))
      } else if (value !== this.form.password) {
        callback(new Error('两次输入密码不一致!'))
      } else {
        callback()
      }
    }
    return {
      form: {
        username: '',
        password: '',
        checkPass: ''
      },
      rules: {
        username: [
          { required: true, message: '请输入用户名', trigger: 'blur' },
          { min: 3, max: 5, message: '长度在 3 到 5 个字符', trigger: 'blur' }
        ],
        password: [
          { validator: validatePass, trigger: 'blur' }
        ],
        checkPass: [
          { validator: validatePass2, trigger: 'blur' }
        ]
      }
    }
  }
}
</script>

效果如图:

没有 css 样式,丑是丑了点,但是想要的功能已经完成了,而且表单验证也支持自定义的校验规则。想要👍。

源码