仿 ElmentUI 实现一个 Form 表单

使用组件就像流水线上的工人;设计组件就像设计流水线的人,设计好了给工人使用。css

完整项目地址:仿 ElmentUI 实现一个 Form 表单vue

一. 目标

仿 ElementUI 实现一个简单的 Form 表单,主要实现如下四点:git

  • Form
  • FormItem
  • Input
  • 表单验证

咱们先看一下 ElementUI 中 Form 表单的基本用法github

<el-form :model="ruleForm" :rules="rules" ref="loginForm">
   
      <el-form-item label="用户名" prop="name">
        	<el-input v-model="ruleForm.name"></el-input>
      </el-form-item>
   
      <el-form-item label="密码" prop="pwd">
        	<el-input v-model="ruleForm.pwd"></el-input>
      </el-form-item>
   
      <el-form-item>
        	<el-button type="primary" @click="submitForm('loginForm')">登陆</el-button>
      </el-form-item>
   
</el-form>
复制代码

在 ElementUI 的表单中,主要进行了 3 层嵌套关系,Form 是最外面一层,FormItem 是中间一层,最内层是 Input 或者 Buttonshell

二. 建立项目

咱们经过 Vue CLI 3.x 建立项目。npm

使用 vue create e-form 建立一个目录。数组

使用 npm run serve 启动项目。promise

三. Form 组件设计

ElementUI 中的表单叫作 el-form ,咱们设计的表单就叫 e-form缓存

为了实现 e-form 表单,咱们参考 ElementUI 的表单用法,总结出如下咱们须要设计的功能。app

  1. e-form 负责全局校验,并提供插槽;
  2. e-form-item 负责单一项校验及显示错误信息,并提供插槽;
  3. e-input 负责数据双向绑定;

1. Input 的设计

咱们首先观察一下 ElementUI 中的 Input 组件:

<el-input v-model="ruleForm.name"></el-input>
复制代码

在上面的代码中,咱们发现 input 标签能够实现一个双向数据绑定,而实现双向数据绑定须要咱们在 input 标签上作两件事。

  • 要绑定 value
  • 要响应 input 事件

当咱们完成这两件事之后,咱们就能够完成一个 v-model 的语法糖了。

咱们建立一个 Input.vue 文件:

<template>
  <div>
    <!-- 1. 绑定 value 
    		 2. 响应 input 事件
		-->
    <input type="text" :value="valueInInput" @input="handleInput">
  </div>
</template>

<script>
export default {
  name: "EInput",
  props: {
    value: { // 解释一
      type: String,
      default: '',
    }
  },
  data() {
    return {
      valueInInput: this.value // 解释二
    };
  },
  methods: {
      handleInput(event) {
          this.valueInInput = event.target.value; // 解释三
        	this.$emit('input', this.valueInInput); // 解释四
      }
  },
};
</script>
复制代码

咱们对上面的代码作一点解释:

**解释一:**既然咱们想作一个 Input 组件,那么接收的值必然是父组件传进来的,而且当父组件没有传进来值的时候,咱们能够它一个默认值 ""

**解释二:**咱们在设计组件的时候,要遵循单向数据流的原则:父组件传进来的值,咱们只能用,不能改。那么将父组件传进来的值进行一个赋值操做,赋值给 Input 组件内部的 valueInInput ,若是这个值发生变更,咱们就修改内部的值 valueInInput 。这样咱们既能够处理数据的变更,又不会直接修改父组件传进来的值。

**解释三:**当 Input 中的值发生变更时,触发 @input 事件,此时咱们经过 event.target.value 获取到变化后的值,将它从新赋值给内部的 valueInInput

**解释四:**完成了内部赋值以后,咱们须要作的就是将变化后的值通知父组件,这里咱们用 this.$emit 向上派发事件。其中第一个参数为事件名,第二个参数为变化的值。

完成了以上四步,一个实现了双向数据绑定的简单的 Input 组件就设计完成了。此时咱们能够在 App.vue 中引入 Input 组件观察一下结果。

<template>
  <div id="app">
    <e-input v-model="initValue"></e-input>
    <div>{{ initValue }}</div>
  </div>
</template>

<script>
import EInput from './components/Input.vue';

export default {
  name: "app",
  components: {
    EInput
  },
  data() {
    return {
      initValue: '223',
    };
  },
};
</script>
复制代码

01

2. FormItem 的设计

<el-form-item label="用户名" prop="name">
		<el-input v-model="ruleForm.name"></el-input>
</el-form-item>
复制代码

在 ElementUI 的 formItem 中,咱们能够看到:

  1. 须要 label 来显示名称;
  2. 须要 prop 来校验当前项;
  3. 须要给 inputbutton 预留插槽;

根据上面的需求,咱们能够建立出本身的 formItem ,新建一个 FormItem.vue 文件 。

<template>
    <div>
        <!-- 解释一 -->
        <label v-if="label">{{ label }}</label>
        <div>
            <!-- 解释二 -->
            <slot></slot>
            <!-- 解释三 -->
            <p v-if="validateState === 'error'" class="error">{{ validateMessage }}</p>
        </div>
    </div>
</template>

<script>
    export default {
        name: "EFormItem",
        props: {
      			label: { type: String, default: '' },
      			prop:  { type: String, default: '' }
  			},
        data() {
            return {
                validateState: '',
                validateMessage: ''
            }
        },
    }
</script>

<style scoped>
.error {
    color: red;
}
</style>
复制代码

和上面同样,咱们接着对上面的代码进行一些解释:

**解释一:**根据 ElementUI 中的用法,咱们知道 label 是父组件传来,且当传入时咱们展现,不传入时不展现。

解释二: slot 是一个预留的槽位,咱们能够在其中放入 input 或其余组件、元素。

解释三: p 标签是用来展现错误信息的,若是验证状态为 error 时,就显示。

此时,咱们的 FormItem 组件也可使用了。一样,咱们在 App.vue 中引入该组件。

<template>
  <div id="app">
    
    <e-form-item label="用户名" prop="name">
      	<e-input v-model="ruleForm.name"></e-input>
    </e-form-item>
    
    <e-form-item label="密码" prop="pwd">
      	<e-input v-model="ruleForm.pwd"></e-input>
    </e-form-item>
    
    <div>
      {{ ruleForm }}
    </div>
    
  </div>
</template>

<script>
import EInput from './components/Input.vue';
import EFormItem from './components/FormItem.vue';

export default {
  name: "app",
  components: {
    EInput,
    EFormItem
  },
  data() {
    return {
      ruleForm: {
        name: '',
        pwd: '',
      },
    };
  },
};
</script>
复制代码

02

3. Form 的设计

到如今,咱们已经完成了最内部的 input 以及中间层的 FormItem 的设计,如今咱们开始设计最外层的 Form 组件。

当层级过多而且组件间须要进行数据传递时,Vue 为咱们提供了 provideinject API,方便咱们跨层级传递数据。

咱们举个例子来简单实现一下 provideinject 。在 App.vue 中,咱们提供数据(provide)。

export default {
  name: "app",
  provide() {
    return {
      msg: '哥是最外层提供的数据'
    }
  }
};
</script>
复制代码

接着,咱们在最内层的 Input.vue 中注入数据,观察结果。

<template>
  <div>
    <!-- 一、绑定 value 
    二、响应 input 事件-->
    <input type="text" :value="valueInInput" @input="handleInput">
    <div>{{ msg }}</div>
  </div>
</template>

<script>
export default {
  name: "EInput",
  inject: [ 'msg' ],
  props: {
    value: {
      type: String,
      default: '',
    }
  },
  data() {
    return {
      valueInInput: this.value
    };
  },
  methods: {
      handleInput(event) {
          this.valueInInput = event.target.value;
        	this.$emit('input', this.valueInInput);
      }
  },
};
</script>
复制代码

04

根据上图,咱们能够看到不管跨越多少层级,provideinject 能够很是方便的实现数据的传递。

理解了上面的知识点后,咱们能够开始设计 Form 组件了。

<el-form :model="ruleForm" :rules="rules" ref="loginForm">
	
</el-form>
复制代码

根据 ElementUI 中表单的用法,咱们知道 Form 组件须要实现如下功能:

  1. 提供数据模型 model;
  2. 提供校验规则 rules;
  3. 提供槽位,里面放咱们的 FormItem 等组件;

根据上面的需求,咱们建立一个 Form.vue 组件:

<template>
    <form>
        <slot></slot>
    </form>
</template>

<script>
    export default {
        name: 'EForm',
        props: { // 解释一
            model: {
                type: Object,
                required: true
            },
            rules: {
                type: Object
            }
        },
        provide() { // 解释二
            return {
                eForm: this // 解释三
            }
        }
    }
</script>
复制代码

解释一: 该组件须要用户传递进来一个数据模型 model 进来,类型为 Objectrules 为可传项。

解释二: 为了让各个层级都能使用 Form 中的数据,须要依靠 provide 函数提供数据。

解释三:直接将组件的实例传递下去。

完成了 Form 组件的设计,咱们在 App.vue 中使用一下:

<template>
  <div id="app">
    
    <e-form :model="ruleForm" :rules="rules">
      
      <e-form-item label="用户名" prop="name">
        <e-input v-model="ruleForm.name"></e-input>
      </e-form-item>
      
      <e-form-item label="密码" prop="pwd">
        <e-input v-model="ruleForm.pwd"></e-input>
      </e-form-item>
      
      <e-form-item>
        <button>提交</button>
      </e-form-item>
    
  	</e-form>
  </div>
</template>

<script>
import EInput from './components/Input.vue';
import EFormItem from './components/FormItem.vue';
import EForm from "./components/Form";

export default {
  name: "app",
  components: {
    EInput,
    EFormItem,
    EForm
  },
  data() {
    return {
      ruleForm: {
        name: '',
        pwd: '',
      },
      rules: {
        name: [{ required: true }],
        pwd: [{ required: true }]
      },
    };
  },
};
</script>
复制代码

05

到目前为止,咱们的基本功能就已经实现了,除了提交与验证规则外,全部的组件几乎与 ElementUI 中的表单如出一辙了。下面咱们就开始实现校验功能。

4. 设计校验规则

在上面设计的组件中,咱们知道校验当前项和展现错误信息的工做是在 FormItem 组件中,可是数据的变化是在 Input 组件中,因此 FormItemInput 组件是有数据传递的。当 Input 中的数据变化时,要告诉 FormItem ,让 FormItem 进行校验,并展现错误。

首先,咱们修改一下 Input 组件:

methods: {
    handlerInput(event) {
      this.valueInInput = event.target.value;
      this.$emit("input", this.valueInInput);
      
      // 数据变了,定向通知 FormItem 校验
      this.dispatch('EFormItem', 'validate', this.valueInput);
    },
		// 查找指定 name 的组件,
    dispatch(componentName, eventName, params) {
      var parent = this.$parent || this.$root;
      var name = parent.$options.name;

      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent;

        if (parent) {
          name = parent.$options.name;
        }
      }
      if (parent) {
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    }
  }
复制代码

这里,咱们不能用 this.$emit 直接派发事件,由于在 FormItem 组件中,Input 组件的位置只是一个插槽,没法作事件监听,因此此时咱们让 FormItem 本身派发事件,并本身监听。修改 FormItem 组件,在 created 中监听该事件。

created() {
	this.$on('validate', this.validate);
}
复制代码

Input 组件中的数据变化时,FormItem 组件监听到 validate 事件后,执行 validate 函数。

下面,咱们就要处理咱们的 validate 函数了。而在 ElementUI 中,验证用到了一个底层库 async-validator,咱们能够经过 npm 安装这个包。

npm i async-validator
复制代码

async-validator 是一个能够对数据进行异步校验的库,具体的用法能够参考上面的连接。咱们经过这个库来完成咱们的 validate 函数。继续看 FormItem.vue 这个文件:

<template>
  <div>
    <label v-if="label">{{ label }}</label>
    <div>
      <slot></slot>
      <p v-if="validateState === 'error' " class="error">{{ validateMessage }}</p>
    </div>
  </div>
</template>

<script>
import AsyncValidator from "async-validator";

export default {
  name: "EFormItem",
  props: {
			label: { type: String, default: '' },
			prop:  { type: String, default: '' }
  },
  inject: ["eForm"], // 解释一
  created() {
    this.$on("validate", this.validate);
  },
  mounted() { // 解释二
    if (this.prop) { // 解释三
      this.dispatch('EForm', 'addFiled', this);
    }
  },
  data() {
    return {
      validateMessage: "",
      validateState: ""
    };
  },
  methods: {
    validate() {
        // 解释四
      return new Promise(resolve => {
        // 解释五
        const descriptor = {
          // name: this.form.rules.name =>
          // name: [ { require: true }, { ... } ]
        };
        descriptor[this.prop] = this.eForm.rules[this.prop];
        // 校验器
        const validator = new AsyncValidator(descriptor);
        const model = {};
        model[this.prop] = this.eForm.model[this.prop];
        // 异步校验
        validator.validate(model, errors => {
          if (errors) {
            this.validateState = "error";
            this.validateMessage = errors[0].message;

            resolve(false);
          } else {
            this.validateState = "";
            this.validateMessage = "";

            resolve(true);
          }
        });
      });
    },
    // 查找上级指定名称的组件
    dispatch(componentName, eventName, params) {
      var parent = this.$parent || this.$root;
      var name = parent.$options.name;

      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent;

        if (parent) {
          name = parent.$options.name;
        }
      }
      if (parent) {
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    }
  }
};
</script>

<style scoped>
.error {
  color: red;
}
</style>
复制代码

咱们对上面的代码作一个解释。

解释一: 注入 Form 组件提供的数据 - Form 组件的实例,下面就可使用 this.eForm.xxx 来使用 Form 中的数据了。

解释二: 由于咱们须要在 Form 组件中校验全部的 FormItem ,因此当 FormItem 挂载完成后,须要派发一个事件告诉 Form :你能够校验我了。

解释三:FormItem 中有 prop 属性的时候才校验,没有的时候不校验。好比提交按钮就不须要校验。

<e-form-item>
		<input type="submit" @click="submitForm()" value="提交">
</e-form-item>
复制代码

**解释四:**返回一个 promise 对象,批量处理全部异步校验的结果。

解释五: descriptor 对象是 async-validator 的用法,采用键值对的形式,用来检查当前项。好比:

// 检查当前项
// async-validator 给出的例子
name: {
		type: "string",
		required: true,
		validator: (rule, value) => value === 'muji',
}
复制代码

FormItem 中检查当前项完成了,如今咱们须要处理一下 Form 组件中的全局校验。表单提交时,须要对 form 进行一个全局校验。大体的思路是:循环遍历表单中的全部派发上来的 FormItem ,让每个 FormItem 执行本身的校验函数,若是有一个为 false ,则校验不经过;不然,校验经过。咱们经过代码实现一下:

<template>
  <form>
    <slot></slot>
  </form>
</template>

<script>
    export default {
        props: {
            model: { type: Object, required: true },
            rules: { type: Object }
        },
        provide() {
          return {
              eForm: this, // provide this component's instance
          }  
        },
	      data() {
            return {
                fileds: [],
            }
        },
        created() {
            // 解释一
          	this.fileds = [];
            this.$on('addFiled', filed => this.fileds.push(filed));
        },
        methods: {
            async validate(cb) { // 解释二
                // 解释三
                const eachFiledResultArray = this.fileds.map(filed => filed.validate());

                // 解释四
                const results = await Promise.all(eachFiledResultArray);
                let ret = true;
                results.forEach(valid => {
                    if (!valid) {
                        ret = false;
                    }
                });
                cb(ret);
            }
        },
    }
</script>

<style lang="scss" scoped>
</style>
复制代码

解释一:fileds 缓存须要校验的表单项,由于咱们在 FormItem 中派发了事件。只有须要校验的 FormItem 会被派发到这里,并且都会保存在数组中。

if (this.prop) {
      this.dispatch('EForm', 'addFiled', this);
}
复制代码

解释二: 当点击提交按钮时,会触发这个事件。

解释三: 遍历全部被添加到 fileds 中的 FormItem 项,让每一项单独去验证,会返回 Promise 的 truefalse。将全部的结果,放在一个数组 eachFiledResultArray 中。

解释四: 获取全部的结果,统一进行处理,其中有一个结果为 false ,验证就不能经过。

至此,一个最简化版本的仿 ElementUI 的表单就实现了。

03

四. 总结

固然上面的代码还有不少能够优化的地方,好比说 dispatch 函数,咱们能够写一遍,使用的时候用 mixin 导入。因为篇幅关系,这里就不作处理了。

经过此次实现,咱们首先总结一下其中所涉及的知识点。

  • 父组件传递给子组件用 props
  • 子组件派发事件,用 $emit
  • 跨层级数据交互,用 provideinject
  • slot 能够预留插槽

其次是一些思想:

  • 单项数据流:父组件传递给子组件的值,子组件内部只能用,不能修改。
  • 组件内部的 name 属性,能够经过 this.$parent.$options.name 查找。
  • 想要批量处理不少异步的结果,能够用 promise 对象。

若是文章中错误或表述不严谨的地方,欢迎指正。

最后,文章会首先发布在个人 Github ,以及公众号上,欢迎关注,欢迎 star。

相关文章
相关标签/搜索