最近在给公司做后台管理系统,一直有用到 element 组件库,这其中最常用的组件之一就是 form 表单了,今天试着自己实现一下其基本功能
项目搭建
老样子,我们先创建一个项目
大致上是这样一个结构
man.js
import Vue from 'vue'
import App from './App.vue'
Vue.config.productionTip = false
// 事件总线
Vue.prototype.$bus = new Vue()
new Vue({
render: h => h(App)
}).$mount('#app')
App.vue
<template>
<div id="app">
<my-form></my-form>
</div>
</template>
<script>
import MyForm from '@/components/form'
export default ({
components:{
MyForm
}
})
</script>
form/index
<template>
<div>
<my-Form :model="model" :rules="rules" ref="loginForm">
<my-FormItem label="用户名" prop="username">
<my-Input v-model="model.username" placeholder="请输入用户名"></my-Input>
</my-FormItem>
<my-FormItem>
<button @click="submit">提交</button>
</my-FormItem>
</my-Form>
</div>
</template>
<script>
import myInput from "@/components/form/myInput.vue";
import myFormItem from "@/components/form/myFormItem.vue";
import myForm from "@/components/form/myForm.vue";
export default {
components: {
myElementForm,
myFormItem,
myForm,
},
data() {
return {
model: {
username: "tom",
},
rules: {
username: [{ required: true, message: "请输入用户名" }],
},
};
},
methods: {
submit() {
this.$refs.loginForm.validate((isValid) => {
console.log(isValid)
});
},
},
};
</script>
<style scoped></style>
接下去的关键就是这几个表单组件的实现
需求分析
myInput 组件
myInput 组件的功能最为基础,主要就是实现自定义组件双向绑定,即v-model语法糖
<template>
<div>
<!-- 自定义组件双绑:v-model语法糖,:value,@input -->
<input :type="type" :value="value" @input="onInput" v-bind="$attrs">
</div>
</template>
<script>
export default {
inheritAttrs: false,
props: {
value: {
type: String,
default: ''
},
type: {
type: String,
default: 'text'
}
},
methods: {
onInput(e) {
this.$emit('input', e.target.value)
}
},
}
</script>
<style lang="scss" scoped>
</style>
因为外层可能为使用 placeholder 这样的属性直接写在组件上,这些特性没有必要一一在props中申明(因为组件里的逻辑也用不上),这时可以考虑使用 $attrs 将其直接转移到input上(这也是组件传值的一种方法)
效果如下
myFormItem 组件
这个组件的主要作用就是表单条目校验,并且显示错误信息。
myFormItem.vue
基本结构
<template>
<div>
<label v-if="label">{{label}}</label>
<slot></slot>
<p v-if="error">{{error}}</p>
</div>
</template>
<script>
]
export default {
props: {
label: {
type: String,
default: ''
},
prop: {
type: String,
default: ''
}
},
data() {
return {
error: ''
}
},
}
</script>
<style scoped>
</style>
表单项校验
我们都知道 formItem会接收一个 prop 值,然后做校验,具体说就是校验 model[prop]的值 是否满足 rules[prop] 定义的规则要求。 但是这里有个问题, model 和 rules 都是定义在 my-form 这个组件上,作为子组件的 formItem 如何能拿到 (而且这里的层级关系也未必是父子,实际项目中可能会隔了好几层)。为了解决这个跨层通信问题,我们可以采用 provide/inject 的方式。
myForm.vue
<template>
<div>
<slot></slot>
</div>
</template>
<script>
export default {
provide() {
return {
// 直接提供当前表单实例
form: this,
};
},
props: {
model: {
type: Object,
required: true,
},
rules: Object,
},
};
</script>
<style lang="scss" scoped></style>
我们可以测试一下在formitem 中是否有拿到这个表单
myFormItem.vue
<template>
<div>
<label v-if="label">{{label}}</label>
<slot></slot>
<p v-if="error">{{error}}</p>
<!--这里用来打印表单被校验值和相应规则-->
<p>{{form.rules[prop]}}</p>
<p>{{form.model[prop]}}</p>
</div>
</template>
<script>
export default {
//注入表单实例
inject: ['form'],
props: {
label: {
type: String,
default: ''
},
prop: {
type: String,
default: ''
}
},
data() {
return {
error: ''
}
},
}
</script>
<style scoped>
</style>
非常成功,值和规则都拿到了,接下去就能做校验了。 校验的触发一般有两种情况,一种是表单的全局校验,这个功能交由 myForm去实现。另一种就是由 myInput 组件触发,比如通过change,blur 事件触发。但是myInput在formItem中是一个slot,监听事件不能写在slot上,因此我们可以考虑将校验事件的监听和触发都改由formItem来完成,而在myInput组件中可以通过 $parent 来拿到myFormItem实例。这里我们就演示一下由input事件触发的校验。
myInput.vue
//... template 部分
<script>
export default {
、//.. props
methods: {
onInput(e) {
this.$emit('input', e.target.value)
// // 触发校验。 观察者模式要求监听和派发必须是同一组件,父组件对应位置是 slot,无法实现监听,因此监听和派发都在 formItem 上实现 this.$parent 即是formItem
this.$parent.$emit('validate')
}
},
}
</script>
<style lang="scss" scoped>
</style>
myFormItem.vue
<template>
<div>
<label v-if="label">{{label}}</label>
<slot></slot>
<p v-if="error">{{error}}</p>
</div>
</template>
<script>
import Schema from 'async-validator'
export default {
inject: ['form'],
props: {
label: {
type: String,
default: ''
},
prop: {
type: String,
default: ''
}
},
data() {
return {
error: ''
}
},
mounted () {
// 监听validate事件
this.$on('validate', () => {
this.validate()
})
},
methods: {
validate() {
// 执行校验,async-validator
console.log('validate');
// 1.获取校验规则
const rules = this.form.rules[this.prop]
const value = this.form.model[this.prop]
// 2.构造一个validator实例
const validator = new Schema({[this.prop]: rules})
// 3.执行校验
return validator.validate({[this.prop]: value}, errors => {
// errors数组存在则有校验错误
if (errors) {
this.error = errors[0].message
} else {
this.error = ''
}
})
}
},
}
</script>
<style scoped>
</style>
我们可以看到 当用户名输入为空时,有了提示信息
myForm组件
其实我们已经完成了myForm组件的一部分功能,即管理表单数据和规则(包括接受数据和向formItem提供数据)。 我们还需要实现其全局校验的功能。
因为每个formItem 中都有对应的 validate方法,因此一个简单粗暴的策略就是遍历所有formItem组件,然后执行其校验发法就可以了。那么如何在 form 组件中拿到 formItem呢,可以使用 $children
myForm.vue
<template>
<div>
<slot></slot>
</div>
</template>
<script>
export default {
provide() {
return {
// 直接提供当前表单实例
form: this,
};
},
props: {
model: {
type: Object,
required: true,
},
rules: Object,
},
methods: {
validate(cb) {
// 全局校验
// 执行内部全部FormItem的validate方法
// 获取Promise构成的数组
const tasks = this.$children
.filter(item => item.prop)
.map((item) => item.validate());
// 检查校验结果
Promise.all(tasks)
.then(() => cb(true))
.catch(() => cb(false));
},
},
};
</script>
<style lang="scss" scoped></style>
我们来看下结果:
在空表单提交以后,视图上给出了提示,并且下列代码也在控制台上打出了 false
form/index
//....
submit() {
this.$refs.loginForm.validate((isValid) => {
console.log(isValid)
});
},
至此,我们实现了 element 表单的基本功能,感兴趣可以动手一起敲一遍
代码优化
细心的你可能已经发现,上述代码中有个两个非常别扭的地方。一个就是在 Input 组件中 通过 $parent 去获取 formItem 组件来派发校验事件。另一个就是 在 form 组件中通过 $children 去获取所有的 FormItem组件。 这万一用户不是按套路出牌,比如在 input 和 formItem之前又套了一层,或者在 form 和 formItem中又隔着其他组件,我们这个写法就失效了,因此有必要进行解藕。
广播和派发
我从element的源码里找到了如下的内容,很受启发,分享一下
这是一段用来混入的代码,我们创建在项目里 src/mixins/emitter.js
//广播,递归查找符合匹配的组件让其派发指定事件
function broadcast(compName,evtName,params){
this.$children.forEach(child => {
let name=child.$options.componentName;
if(name==compName){
child.$emit.apply(child,[evtName].concat(params))
}else{
broadcast.apply(child,[compName,evtName].concat([params]))
}
})
}
export default {
methods:{
//派发,冒泡查找匹配的组件令其派发指定事件
dispatch(compName,evtName,params){
let parent=this.$parent||this.$root;
let name=parent.$options.componentName;
while(parent && (!name || name!==compName)){
parent=parent.$parent;
if(parent){
name=parent.$options.componentName
}
}
if(parent){
parent.$emit.apply(parent,[evtName].concat(params))
}
},
broadcast(compName,evtName,params){
broadcast.call(this,compName,evtName,params)
}
}
}
myInput.vue
<template>
<!--....template部分-->
</template>
<script>
import emitter from '../../mixins/emitter'
export default {
mixins:[emitter],
//...
methods: {
onInput(e) {
this.$emit('input', e.target.value)
// // 触发校验
// this.$parent.$emit('validate')
//冒泡找到 myFormItem 然后触发校验,这样即使 Input的的父级不是formItem也能正常运行
this.dispatch('MyFormItem','validate')
}
},
}
</script>
myFormItem.vue
<!--template-->
<script>
export default {
inject: ['form'],
//因为dispatch方法是根据componentName来匹配的,因此需要在 formItem中设置
name:'MyFormItem',
componentName:'MyFormItem',
}
</script>
我们现在处理了 myInput 组件 和 myFormItem 组件之间的耦合问题。接下去就是处理 form 组件中 $children 的问题,要确保如果form的直接后代不是 formItem也可以拿到 formItem。
这里的具体解决办法是在 form 组件中维护一个 field 数组。当 formItem 挂载时冒泡触发某个事件,将自身作为参数传入,同时 form 组件中监听该事件,将传入的 formItem组件添加到 field 数组中。这样在全局校验时就能拿到所有的 formItem 组件,而不必再通过 $children
myForm.vue
<template>
<div>
<slot></slot>
</div>
</template>
<script>
export default {
name:'MyForm',
componentName:'MyForm',
provide() {
return {
// 直接提供当前表单实例
form: this,
};
},
props: {
model: {
type: Object,
required: true,
},
rules: Object,
},
data(){
return {
//field 用以存放 formItem
field:[]
}
},
created(){
this.$on('myForm.addField',(item)=>{
//每当有 formItem 被挂载,就会触发该事件
this.field.push(item)
})
},
methods: {
validate(cb) {
// 全局校验
// 执行内部全部FormItem的validate方法
// // 获取Promise构成的数组
// const tasks = this.$children
// .filter(item => item.prop)
// .map((item) => item.validate());
// 从 field 中拿到 formItem
const tasks=this.field.map(item=>{
item.validate()
})
// 检查校验结果
Promise.all(tasks)
.then(() => cb(true))
.catch(() => cb(false));
},
},
};
</script>
<style lang="scss" scoped></style>
myFormItem.vue
<template>
<!--template部分-->
</template>
<script>
import Schema from 'async-validator'
import emitter from '../../mixins/emitter'
export default {
inject: ['form'],
name:'MyFormItem',
componentName:'MyFormItem',
mixins:[emitter],
//... prop 和 data
mounted () {
// 监听validate事件
//这里要派发一个事件,新增一个 formItem实例
//prop 的过滤也在这里执行
//formItem 挂载时 冒泡触发myForm中监听的 addField事件,将自身传入
if(this.prop){
this.dispatch('MyForm','myForm.addField',[this])
}
},
methods: {
//... validte 事件
},
}
</script>
<style scoped>
</style>
最后要注意一点,由于父组件的挂载时晚于子组件的,因此父组件的监听要放在 created生命周期函数内,而不能是mounted函数内。