手写 Element Form 表单

·  阅读 694
手写 Element Form 表单

最近在给公司做后台管理系统,一直有用到 element 组件库,这其中最常用的组件之一就是 form 表单了,今天试着自己实现一下其基本功能

项目搭建

老样子,我们先创建一个项目

image.png 大致上是这样一个结构

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上(这也是组件传值的一种方法)

效果如下

image.png

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>
复制代码

image.png

非常成功,值和规则都拿到了,接下去就能做校验了。 校验的触发一般有两种情况,一种是表单的全局校验,这个功能交由 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>
复制代码

image.png 我们可以看到 当用户名输入为空时,有了提示信息

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>

复制代码

我们来看下结果:

image.png

在空表单提交以后,视图上给出了提示,并且下列代码也在控制台上打出了 false

form/index

//....
submit() {
      this.$refs.loginForm.validate((isValid) => {
        console.log(isValid)
      });
    },
复制代码

至此,我们实现了 element 表单的基本功能,感兴趣可以动手一起敲一遍

代码优化

细心的你可能已经发现,上述代码中有个两个非常别扭的地方。一个就是在 Input 组件中 通过 $parent 去获取 formItem 组件来派发校验事件。另一个就是 在 form 组件中通过 $children 去获取所有的 FormItem组件。 这万一用户不是按套路出牌,比如在 inputformItem之前又套了一层,或者在 formformItem中又隔着其他组件,我们这个写法就失效了,因此有必要进行解藕。

广播和派发

我从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函数内。

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改