仿element-UI Form设计思想实现简单的自定义校验规则表单

2,046 阅读6分钟
大家好 ,我是子君,今天给大家介绍下如何实现一个类似element具有自定义检验规则的表单。本文旨在学习element-ui 组件化设计思想,并挑选出经典的表单组件进行模仿,不会去实现全部的表单功能,仅供参考学习


一. Element Form 表单结构分析

以官网给出的《自定义校验规则》为例:


查看其示例代码:

<el-form :model="ruleForm" status-icon :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm">
  <el-form-item label="密码" prop="pass">
    <el-input type="password" v-model="ruleForm.pass" autocomplete="off"></el-input>
  </el-form-item>
  <el-form-item label="确认密码" prop="checkPass">
    <el-input type="password" v-model="ruleForm.checkPass" autocomplete="off"></el-input>
  </el-form-item>
  <el-form-item label="年龄" prop="age">
    <el-input v-model.number="ruleForm.age"></el-input>
  </el-form-item>
  <el-form-item>
    <el-button type="primary" @click="submitForm('ruleForm')">提交</el-button>
    <el-button @click="resetForm('ruleForm')">重置</el-button>
  </el-form-item>
</el-form>
<script>
  export default {
    data() {
      var checkAge = (rule, value, callback) => {
        if (!value) {
          return callback(new Error('年龄不能为空'));
        }
        setTimeout(() => {
          if (!Number.isInteger(value)) {
            callback(new Error('请输入数字值'));
          } else {
            if (value < 18) {
              callback(new Error('必须年满18岁'));
            } else {
              callback();
            }
          }
        }, 1000);
      };
      var validatePass = (rule, value, callback) => {
        if (value === '') {
          callback(new Error('请输入密码'));
        } else {
          if (this.ruleForm.checkPass !== '') {
            this.$refs.ruleForm.validateField('checkPass');
          }
          callback();
        }
      };
      var validatePass2 = (rule, value, callback) => {
        if (value === '') {
          callback(new Error('请再次输入密码'));
        } else if (value !== this.ruleForm.pass) {
          callback(new Error('两次输入密码不一致!'));
        } else {
          callback();
        }
      };
      return {
        ruleForm: {
          pass: '',
          checkPass: '',
          age: ''
        },
        rules: {
          pass: [
            { validator: validatePass, trigger: 'blur' }
          ],
          checkPass: [
            { validator: validatePass2, trigger: 'blur' }
          ],
          age: [
            { validator: checkAge, trigger: 'blur' }
          ]
        }
      };
    },
    methods: {
      submitForm(formName) {
        this.$refs[formName].validate((valid) => {
          if (valid) {
            alert('submit!');
          } else {
            console.log('error submit!!');
            return false;
          }
        });
      },
      resetForm(formName) {
        this.$refs[formName].resetFields();
      }
    }
  }
</script>

可以看出其主要结构为:

-- <el-form :model="ruleForm" :rules="rules" ref="ruleForm" >

---- <el-form-item label="密码" prop="pass">

------ <el-input> 等收集用户输入的组件

由子组件到父组件逐层分析:

1. el-input 组件

    <el-input type="password" v-model="ruleForm.pass" autocomplete="off"></el-input>

a. props: 

    - type: 输入框类型[required]。ex: text 、password

    - autocomplete : 是否启用自动完成

b. v-model 双向绑定 监听用户输入


2. el-form-item 组件

<el-form-item label="密码" prop="pass">
    <!-- <el-input> -->
</el-form-item>

a. el-form-item 总是作为父容器组件包裹 el-input 组件使用,因此,其肯定存在一个default插槽 slot 来放置 el-input

b. props:

    - label: 表单项标签名

    - prop : 这个prop的值是干嘛用的呢?后面在编码过程中给出答案。tip: 观察 data 中 ruleForm 与 rules 的值

c. 用户在输入过程中,如果不满足校验规则,会有错误提示。因此推测 el-form-item 必定需要实现监听用户输入并且进行校验。监听用户的输入会由子组件 el-input实现,el-form-item需要有一个校验方法让 el-input 输入变化时触发,但是 校验的规则从哪里获取?el-input 又是怎么触发?


3. el-form 组件

<el-form :model="ruleForm" status-icon :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm">
    <!-- 省略表单内容 -->
</el-form>

a. 同 el-form-item ,其肯定存在一个 default slot 渲染表单项内容

b. props:

    - model : 接收用户自定义的表单内容

    - rules: 接收用户自定义的校验规则

c. 在提交时,触发了方法 submitForm:

submitForm(formName) {
        this.$refs[formName].validate((valid) => {
          if (valid) {
            alert('submit!');
          } else {
            console.log('error submit!!');
            return false;
          }
        });
      },

说明 el-form 实现了 validate 方法验证所有的表单项是否满足自定义校验规则。但是 el-form 

又是如何能知道每个表单项的输入信息,并且去验证是否满足检验规则的呢?

下面,根据上面的简单分析以及疑问,我们开始实现这个简单的表单。

二、实现表单

为了节省代码以及文章篇幅,本文只会做简单的非空规则校验,如需向 element 一样做自定义规则校验,请参阅 《async-validator 文档

为了与 element 组件区分,本文组件全部带上了作者的前缀:

el-input -> z-form-input

el-form-item -> z-form-item

el-form -> z-form

使用 vue-cli 创建demo项目

vue create hello-form

1. 实现 z-form-input 组件

<template>  
<div>    
    <input :type="type" :value="value" @input="onInput" />  
</div>
</template>
<script>
export default { 
    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>

在 App.vue中引用 z-form-input:

<template>
  <div id="app">
    <z-form-input type='text' v-model="name" />
    <p>{{name}}</p>
  </div>
</template>
<script>
import ZFormInput from '../components/form/z-form-input' // 文件路径请换成自己项目的
export default {
  components: {
    ZFormInput,
  },
  data() {
    return {
      name: 'zi jun'
    }
  },
}
</script>

启动项目:yarn serve

可以在浏览器看到:


说明 z-form-input组件创建成功,并实现了双向绑定!

但是如上述分析,此组件在使用上还有点问题:

-  autocomplete 是直接作为 prop 传入 el-input的,但是我们实现的组件中并没有去定义autocomplete的属性,而且在使用过程中完全可能再传入其他 prop ,如 placeholder等。此时意图去把所有可能的prop全部声明是不太理想的做法,我们需要用到 vm.$attrs 去接收父作用域中不作为prop被识别且获取的attribute绑定(class & style 除外),然后使用 v-bind=“$attrs” 展开。

修改 z-form-input:

    <input :type="type" :value="value" @input="onInput" v-bind="$attrs" />

从下文开始,将会省略 引入组件启动服务的过程,只关注组件的实现~,有问题欢迎留言


2. 实现z-form-item 组件

<template>
  <div class="itemWrapper">
    <label>{{label}}</label>
    <!-- 渲染 z-form-input -->
    <slot></slot>
    <span id="error" v-if="error">{{error}}</span>
  </div>
</template>
<script>
export default {
  mounted() {
 // 监听 validate 事件,触发时调用 validate 方法进行校验
    this.$on("validate", () => {this.validate()});
  },
  props: {
    label: {
      type: String,
      default: ""
    },
    prop: {
      type: String,
      default: ""
    }
  },
  data() {
    return {
      error: ""
    };
  },
  methods: {
    validate() {// todo 校验,校验规则与值,从哪里获取?
      return ture ;
    }
  }
};
</script>
<style lang="scss" scoped>
  position: relative;
  width: 100%;
  height: 50px;
  display: flex;
  justify-content:center;
  label{
    width: 100px;
    height: 100%;
  }
  #error {
    position: absolute;
    left: 60%;
    color: red;
  }
</style>

写到这里,我们定义了el-item的validate方法,并且监听了 validate 事件用来触发 validate 方法,但是 validate 方法是空的,因为我们暂时无法知道,需要校验的值以及校验的规则从哪里来。不慌,请继续往下实现 z-form 组件。


3. 实现z-form 组件

<template>
  <div>
    <slot></slot>
  </div>
</template>
<script>
export default {
  props: {
    model: {
      type: Object,
      default: null,
      required: true
    },
    rules: {
      type: Object
    }
  },
  methods: {
    validate(cb) {
      const tasks = this.$children
        .filter(item => item.prop)
        .map(item => item.validate());
      Promise.all(tasks)
        .then(() => cb(true))
        .catch(() => cb(false));
    },
    // 省略 resetForm 方法
  }
};
</script>
<style lang="scss" scoped></style>

十分精简的 z-form 组件:

- 通过 slot 接收所有表单项子组件作为内容;

- model 接收用户传入的自定义表单项内容,rules 接收用户自定义的校验规则

- validate 方法去校验所有的表单项。怎么做到?在z-form-item中我们定义了单个表单项自己的校验方法,如果validate()能执行所有表单项自己的校验方法,并且都通过,那么是不是就代表着表单通过校验?因此我们自然想到需要使用Promise.all()保证所有校验方法通过,并且使用vm.$children 来访问每个 z-form-item 

-------------我是分割线------------

至此,我们已经初步实现了 z-form 、z-form-item 、z-form-input基本结构,但是此时依旧无法实现校验功能,关键原因是 z-form-item的 validate() 方法不知道校验值以及校验规则,没法去进行校验。

但是,我们已经在 z-form 保存了用户传入的自定义 model & rules,现在的问题是,每个z-form-item 怎么去拿到自己的需要校验的值以及校验规则?

此时,我们需要用到两个开发中少用的实例选型: provide & inject

如果你有 react 组件库开发经验,或者 mobx-react 使用经验,那么相信你一眼就能知道这两个API的作用了.(悄悄吐槽一句,Vue 真是万物皆可“借”)

这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个非响应式的(画重点)依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。

ok,我们继续开干,实现校验功能:

1. 修改z-form 

- 增加 provide 选项

export default {
  provide() {
    return {
      elForm: this
    };
  },
  // 省略了其他选项
  }
};

为什么传递的是 this?

- 通过这个两个api 依赖注入的值不是响应式的,详情请见官网 ,但是我们需要的被校验的值以及校验规则都是用户自定义传入进来的,完全可能实时变化,需要响应式。因此,直接将 z-form实例引用作为依赖注入。

2. 修改z-form-item 

- 增加 inject 选项

export default {  inject: ["elForm"],
  // 省略其他配置项};

此时 ,就可以在 z-form-item 中取到 z-form 实例了,并且可以通过 this.elForm.model,this.elForm.rules 访问用户自定义的所有值以及所有校验规则。

那么,怎么取到属于该表单项自己的呢?

还记得在 z-form-item 中有个 prop属性吗?先回顾下在 el-form-item 中用法:

  <el-form-item label="密码" prop="pass">

prop 的值就是 model & rules 里的key值!

因此,我们就可以在 z-form-item中通过this.elForm.model[prop],this.elForm.rules[prop]取到对应的 值以及规则进行校验了。

- 改写 validate 方法:

使用了 async-validator 库进行校验,需要进行安装: yarn add  async-validator

export default {
  inject: ["elForm"],
  mounted() {
    this.$on("validate", () => this.validate());
  },
  props: {
    label: {
      type: String,
      default: ""
    },
    prop: {
      // 字段名 用来去 verify 规则
      type: String,
      default: ""
    }
  },
  data() {
    return {
      error: ""
    };
  },
  methods: {
    validate() {
      const rules = this.elForm.rules[this.prop];
      const value = this.elForm.model[this.prop];
      const descriptor = { [this.prop]: rules };
      const validator = new schema(descriptor);
      return validator.validate({ [this.prop]: value }, errors => {
        if (errors) {
          this.error = errors[0].message;
        } else {
          this.error = "";
        }
      });
    }
  }};

- 新建 my-form.vue, 封装应用自定义表单组件

<template>
  <div>
    <h3>element-form 表单实现</h3>
    <hr />
    <z-form :model="model" :rules="rules" ref="elForm">
      <z-form-item label="密码" prop="pass" >
        <z-form-input v-model="model.pass" type="password" placeholder="请输入密码"></z-form-input>
      </z-form-item>
      <z-form-item label="确认密码" prop="checkPass">
        <z-form-input v-model="model.checkPass" type="password"></z-form-input>
      </z-form-item>
      <z-form-item label="年龄" prop="age">
        <z-form-input v-model="model.age" type="text" placeholder="请输入年龄"></z-form-input>
      </z-form-item>
      <z-form-item>
          <button @click="submitForm">提交</button>
      </z-form-item>
    </z-form>
  </div>
</template>
<script>
// 注意: 文件路径请改成自己项目的
import ZFormInput from "./z-form-input";
import ZFormItem from "./Z-form-item";
import ZForm from "./z-form";
export default {
  components: {
    ZFormInput,
    ZFormItem,
    ZForm
  },
  data() {
    return {
      model: {
        pass: "",
        checkPass: "",
        age: ""
      },
      rules: {
        pass: [{ required: true, message: "请输入密码" }],
        checkPass: [{ required: true, message: "请确认密码" }],
        age: [{ required: true, message: "请输入年龄" }]
      }
    };
  },
  methods: {
    submitForm() {
      this.$refs["elForm"].validate(valid => {
        if (valid) {
                      alert('提交成功~')
        } else {
                      alert('表单校验失败,请正确填写信息~')
        }
      });
    }
  }};
</script>
<style lang="scss" scoped></style>


- 在App.vue 中引入 my-form.vue 启动服务,点击“提交”按钮可以看到一个 “乞丐版的表单【没写样式】”:



到这里,已经实现了提交表单时的校验功能,但是如果我们想在单个表单项输入变化时也对当前输入进行校验该怎么办?

还记得在 z-form-item 中监听的 validate 事件吗?

mounted() { // 监听 validate 事件,触发时调用 validate 方法进行校验
    this.$on("validate", () => this.validate());  
},

我们只需要在 z-input.vue 的 onInput 方法中派发当前z-form-item的validate事件即可:

  methods: {
    onInput(e) {
      this.$emit("input", e.target.value);
      this.$parent.$emit('validate'); // 派发 validate 事件 ,触发当前表单项校验
    }
  }

再往密码框以及确认密码框中输入试试:



OK,Perfect~ 我们已经实现了一个自定义校验规则的表单了!如果你不想通过路径来引用,也可以通过 Vue.component()的方式,将表单注册为全局组件,对此不在赘述。

三、进阶

我们真的完美实现了自定义校验规则表单吗?请看下面情况:

- 首先我们创建一个空的容器组件—— split-item.vue

<template>
    <div>
        <slot></slot>
    </div>
</template>
<script>
    export default {
            }
</script>
<style lang="scss" scoped></style>

- 引入 <split-item> 并对 my-form.vue中 z-form 内容进行改造:

    <z-form :model="model" :rules="rules" ref="elForm">
      <z-form-item label="密码" prop="pass">
        <split-item>
          <z-form-input v-model="model.pass" type="password" placeholder="请输入密码"></z-form-input>
        </split-item>
      </z-form-item>
      <split-item>
        <z-form-item label="确认密码" prop="checkPass">
          <split-item>
          <z-form-input v-model="model.checkPass" type="password"></z-form-input>
          </split-item>
          </z-form-item>
      </split-item>
      <z-form-item label="年龄" prop="age">
        <z-form-input v-model="model.age" type="text" placeholder="请输入年龄"></z-form-input>
      </z-form-item>
      <z-form-item>
        <button @click="submitForm">提交</button>
      </z-form-item>
    </z-form>

- 我们会发现,“密码”框输入时校验失效、“确认密码”框整个表单提交时的校验被忽略了!



很显然,上述现象表明,我们实现的表单兼容性并不强,因为表单用户完全有可能将<z-form-input>、<z-form-item> 使用其他组件容器包裹起来。此时我们的表单校验功能就失效了!

那么为什么会失效呢?

- 查看 z-form.vue 文件中的validate():

validate(cb) {
      const tasks = this.$children
        .filter(item => item.prop)
        .map(item => item.validate());
      Promise.all(tasks)
        .then(() => cb(true))
        .catch(() => cb(false));
    },

我们在此处使用了 this.$children 意图去获取所有的<z-form-item>,但是当其被<split-item>包裹时,this.$children 获取到的就是 <split-item>,当然无法触发 <z-form-item>中的validate()了。

官网明确指出了vm.$children 获取的是当前实例的直接子组件

- 查看 z-form-input.vue 文件中的 onInput():

onInput(e) {
      this.$emit("input", e.target.value);
       this.$parent.$emit('validate') // 派发 validate 事件 ,触发当前表单项校验
    }

同上述的原因,我们在此处使用了 this.$parent 意图去获取当前表单项的<z-form-item>,但是当其被<split-item>包裹时,this.$parent 获取到的就是 <split-item>,当然同样无法触发 <z-form-item>中的validate()了。

解决方案:

聪明的小伙伴肯定已经想到去递归遍历寻找 <z-form-item>了。

我们来康康element源码中怎么实现的:

>源码抽丝剥茧

- 查看form.vue (github.com/ElemeFE/ele…)中的validate方法,里面有这样一段:

// 如果需要验证的fields为空,调用验证时立刻返回callback
if (this.fields.length === 0 && callback) {
    callback(true);
}
let invalidFields = {};
this.fields.forEach(field => {
    field.validate('', (message, field) => {
        if (message) {
            valid = false;
        }
        invalidFields = objectAssign({}, invalidFields, field);
        if (typeof callback === 'function' && ++count === this.fields.length) {
            callback(valid, invalidFields);
        }
    });
});

发现是遍历 this.fields去做所有表单项的校验的,this.fields 就是我们代码中的 this.$children,即所有的 <z-form-item>。那么 this.fields 是在哪里被赋值的呢?

- 查阅form组件配置对象,看到在 created() 生命周期钩子函数中订阅“el.form.addField” & “el.form.removeField”事件做了赋值与移除:

data() {
    return {
        fields: [],
        potentialLabelWidthArr: [] // use this array to calculate auto width
    };
},
created() {
    this.$on('el.form.addField', (field) => {
        if (field) {
            this.fields.push(field);
        }
    });
    /* istanbul ignore next */
    this.$on('el.form.removeField', (field) => {
        if (field.prop) {
            this.fields.splice(this.fields.indexOf(field), 1);
        }
    });
}

- 有事件订阅肯定得有事件派发,查看我们的目标组件 form-item.vue (github.com/ElemeFE/ele…)可以看到:

mounted() {
    if (this.prop) {
        this.dispatch('ElForm', 'el.form.addField', [this]);
        let initialValue = this.fieldValue;
        if (Array.isArray(initialValue)) {
            initialValue = [].concat(initialValue);
        }
        Object.defineProperty(this, 'initialValue', {
            value: initialValue
        });
        this.addValidateEvents();
    }},
beforeDestroy() {
    this.dispatch('ElForm', 'el.form.removeField', [this]);
}

找到了!在 mounted 生命周期钩子函数中使用了 this.dispatch('ElForm', 'el.form.addField', [this]);  在form-item被挂载完成时,向 ‘ELForm’ 派发了 ‘el.form.addField’ 事件,‘ELForm’即是form.vue组件的componentName,这样form组件的this.fields就存放着所有的表单项了!

同时,在 form-item 将被销毁时,派发 ‘el.form.removeField’ 事件,移除form组件中已经存放的当前 form-item。

OK,那现在的问题是 dispatch在哪里定义的,做了些什么?

- element 封装了一个 mixin模块 emitter 专门用来广播与派发事件到具体的组件身上

emitter.js (github.com/ElemeFE/ele…):

function broadcast(componentName, eventName, params) {
    this.$children.forEach(child => {
        var name = child.$options.componentName;
        if (name === componentName) {
            child.$emit.apply(child, [eventName].concat(params));
        } else {
            broadcast.apply(child, [componentName, eventName].concat([params]));
        }
    });
}
export default {
    methods: {
        dispatch(componentName, eventName, params) {
            var parent = this.$parent || this.$root;
            var name = parent.$options.componentName;
            while (parent && (!name || name !== componentName)) {
                parent = parent.$parent;
                if (parent) {
                    name = parent.$options.componentName;
                }
            }
            if (parent) {
                parent.$emit.apply(parent, [eventName].concat(params));
            }
        },
        broadcast(componentName, eventName, params) {
            broadcast.call(this, componentName, eventName, params);
        }
    }};

其思想,依旧是递归遍历。

递归查找 componentName 的组件,将事件派发到其身上

此过程在多层嵌套的情况下,比较消耗性能,这可能也是 form.vue 中没有再采用 broadcast 去寻找 form-item 的原因

至此,可以从 form 组件中调用 form-item 的validate 方法了。

- 查看 input.vue 源码 (github.com/ElemeFE/ele…),在watch 选型中,我们可以找到:

watch: {
    value(val) {
        this.$nextTick(this.resizeTextarea);
        if (this.validateEvent) {
            this.dispatch('ElFormItem', 'el.form.change', [val]);
        }
    },
    // 省略
},

在输入框内容发生变化时,向 form-item 组件 派发了 'el.form.change' 时间,告知 form-item ,用户输入内容发生变化,让其去校验输入内容。

总体机制如下图所示:


>改进我们自己的表单

- 增加源码中的emitter.js 【很简单的逻辑,从源码中直接copy 就行】

- 在 z-form-item.vue, z-form-input.vue 需要 dispatch 方法的组件中混入 emitter,并给z-form,z-form-item 增加 componentName :

import emitter from "../../utils/emitter";

// z-form
export default {
  componentName: "ZForm",
}
// z-form-item
export default {
  componentName: "ZFormItem",
  mixins: [emitter],
}
// z-form-inputexport
 default {
  mixins: [emitter],
}

 - 在 z-form.vue 的 created() 中增加z.form.addField 与 z.form.removeField事件订阅,并更改 validate方法:

  created() {
    this.fields = [];
    this.$on("z.form.addField", field => {
      if (field.prop) {
        this.fields.push(field);
      }
    });
    this.$on("z.form.removeField", field => {
      if (field.prop) {
        this.fields.splice(this.fields.indexOf(field),1)
      }
    });
  },
  methods: {
    validate(cb) {
      const tasks = this.fields.map(item => item.validate());
      Promise.all(tasks).then(() => cb(true)).catch(() => cb(false));
    }
    // 省略 resetForm 方法
  },

与源码中不同,没有将fileds放入data选项中,因为此处仅用来记录 z-form-item,不需要响应式,直接放在 z-form组件实例上即可。

- 在 z-form-item.vue  mounted() 中向 'ZForm'派发z.form.addField ,在 beforeDestroy() 中向 'ZForm'派发z.form.removeField

  mounted() {
    this.$on("validate", () => {
      this.validate();
    });
    this.dispatch("ZForm", "z.form.addField", [this]);
  },
  beforeDestroy() {
    this.dispatch("ZForm", "z.form.removeField", [this]);
  },

- 在 z-form-input.vue onInput方法中向 ’ZFormItem‘ 派发 validate 事件:

  methods: {
    onInput(e) {
      this.$emit("input", e.target.value);
       this.dispatch('ZFormItem','validate',[])
      // this.$parent.$emit('validate')
    }
  }

与源码中不同,我们简化版的代码只需在 z-form-input中监听输入变化即可,因此可以直接在此处派发 z-form-item 中的 validate 事件即可

>运行并查看结果


nice~ 一切正常!恭喜,完成了一次 element 自定义校验规则表单组件的设计与实现!

四、结语

本文并未去注重表单的样式实现,将重心全部放在element 自定义校验规则表单的剖析与实现上。

先从表单组件应用时的结构剖析,灵活运用 vm.$parent、vm.$children、vm.$attrs、provide/inject、事件订阅等实现组件之间的相互通信以及对用户输入与自定义校验规则的优雅处理,再深入到源码补齐自定义表单的漏洞,抽丝剥茧,层层递进。

如果想继续实现动态表单、多类型表单项(单选、多选、下拉选择),也完全可以继续在此文实现的组件基础上进一步开发。

望此文能或多或少帮助诸君阅读理解element UI源码,并在组件化开发设计思想上有所启发。