学习element源码实现自己的表单控件

1,890 阅读8分钟

先贴一下本文要实现的功能,功能非常简单,就是两个输入框,一个登录按钮。输入框的内容变化时,会根据定义好的校验规则实时的进行校验,并输出校验的错误信息。当点击登录按钮的时候会对整个表单进行校验。

实现的功能界面截图

我们在工作中开发类似于管理后台的pc端页面时,经常会用到element的表单来进行数据的校验。常用的组件有el-form、el-form-item、el-input等,我们会在el-form组件里面定义一个model属性和rules属性,在el-form-item组件里定义一个prop属性,在el-input组件里用v-model双向绑定一个数据,然后还会有一个按钮点击后用来校验整个表单的数据。类似下面这样:

<jd-form :model="userInfo" :rules="rules" ref="loginForm">
   <jd-form-item label="用户名" prop="username">
       <jd-input v-model="userInfo.username" placeholder="请输入用户名"></jd-input>
   </jd-form-item>
   <jd-form-item label="密码" prop="password">
       <jd-input v-model="userInfo.password" placeholder="请输入密码"></jd-input>
   </jd-form-item>
   <!-- 提交按钮 -->
   <jd-form-item>
       <button class="login-button" @click="login">登录</button>
   </jd-form-item>
</jd-form>

一般照着官方文档写就能实现功能了,至于组件上为什么要传这些属性以及组件之间的组合方式为啥是这样一般不会去深入理解,比如el-form组件里传的model和rules是做什么用的,什么时候以及谁会使用它,如何使用它,el-form-item组件里为什么要传一个prop属性,表单里面的el-input以及el-select等组件为什么都要用el-form-item再包一层,而不是直接使用,点击按钮的时候如何实现校验整个表单。

本篇文章会通过对element表单源码的学习,实现自己封装的el-form、el-form-item以及el-input组件,完成和element表单一样的基本功能,用于加深对组件化思想的理解。实现过程中也会回答上面提到的所有问题。

本文会要用到的一些组件化技术以及要实现的组件如下:

下面会先介绍下主入口文件index.vue,然后分别介绍index.vue中用到的el-form、el-form-item以及el-input组件的实现和各自的作用,也会介绍各组件之间的组织通信方式。

主入口文件的index.vue的代码如下,定义了一个表单组件jd-form,两个输入框组件jd-input,分别输入用户名和密码,一个登录按钮,然后都用jd-form-item组件包裹起来,这里我们用jd-form代替了el-form,表示这是我们自己实现的,然后在data里面定义了需要响应式的数据userInfo和校验的规则rules,并在methods里面定义了一个login方法,用于在点击登录按钮的时候对整个表单进行校验,下面会通过实现jd-form、jd-form-item和jd-input,让下面的代码可以和element一样跑起来。

<jd-form :model="userInfo" :rules="rules" ref="loginForm">
   <jd-form-item label="用户名" prop="username">
       <jd-input v-model="userInfo.username" placeholder="请输入用户名"></jd-input>
   </jd-form-item>
   <jd-form-item label="密码" prop="password">
       <jd-input v-model="userInfo.password" placeholder="请输入密码"></jd-input>
   </jd-form-item>
   <!-- 提交按钮 -->
   <jd-form-item>
       <button class="login-button" @click="login">登录</button>
   </jd-form-item>
</jd-form>
data() {
    return {
      // 响应的数据
      userInfo: {
        username: "",
        password: ""
      },
      // 校验的规则
      rules: {
        username: [{required: true, message: '请输入用户名称'}],
        password: [{required: true, message: '请输入密码'}, {type: 'string', min: 6, message: '密码长度不能少于6位'}]
      }
    };
},
methods: {
  // 对整个表单进行校验
  login() {
    this.$refs.loginForm.validate((success) => {
        if (success) {
            alert('校验成功~');
        } else {
            alert('校验失败~');
        }
    });
  }
}

首先来看一下上面的一个问题,为什么在表单里面el-input、el-select这些组件都要在外面再用el-form-item组件包一层:

这里的原因是为了实现组件的高内聚和低耦合,el-input、el-select主要的功能是用来更新数据以及实现数据的双向绑定,而不用来做数据的校验,数据校验的功能交给el-form-item,这样职责划分就很清楚了,el-input负责维护数据,el-form-item负责数据校验和显示错误信息,el-form-item和el-input都可以单独使用也可以组合起来使用。如果把数据校验也放到el-input和el-select这些组件里面做,那组件的高内聚和低耦合的特性就会受到破坏,组件的可复用性也会降低。

jd-input组件的实现:

具体实现看下方代码,可以看到jd-input组件的功能只是实现了一个双向绑定,在组件上定义v-model,并在组件内部将input事件派发出来。这样内部input元素的内容变化就会同步更新userInfo.username。同时在内容变化的时候还会调用this.dispatch方法通知父级执行校验,这里的父级就是jd-form-item组件。jd-form-item的实现后面会详细讲解,这里先看jd-input。

为了方便,把引用jd-input组件处的代码也放一下:

<jd-form-item label="用户名" prop="username">
    <jd-input type="text" v-model="userInfo.username" placeholder="请输入用户名"></jd-input>
</jd-form-item>

jd-input组件内部代码:

<template>
    <div>
        <!-- v-bind="$attrs" 展开$attrs -->
        <input :type="type" :value="value" @input="onInput" v-bind="$attrs">
    </div>
</template>

<script>
    // emitter里定义了dispatch方法
    import emitter from '../../mixins/emitter.js';
    export default {
        inheritAttrs: false, // $attrs避免设置到根元素上
        componentName: 'jd-input', // 组件的名称,会被设置到this.$options.componentName上
        mixins: [emitter], // 将dispatch方法混入
        props: {
            value: { // input元素的内容
                type: String,
                default: ''
            },
            type: { // input元素的类型
                type: String,
                default: 'text'
            }
        },
        methods: {
            onInput(e) {
                // 派发一个input事件,jd-input组件的v-model展开会有一个@input事件,用来监听这里派发的input事件
                this.$emit('input', e.target.value);

                // 通知父级执行校验,校验都放在jd-form-item组件里完成
                // this.$parent.$emit('validate');
                this.dispatch('JdFormItem', 'validate');
            }
            
        },
    }
</script>

来看一下jd-input里的实现细节,jd-input里有几个关键的功能点,$attrs、mixin和dispatch,下面分别讲解一下:

v-bind="$attrs" ,这里会将在jd-input组件上定义的非prop属性全部展开,写到内部的input元素上,在有很多属性需要传到内部展开的时候非常有用,在这里placeholder就会被展开设置到内部的input标签上,如果有其他属性也同理。

mixin混入,这里的目的就是将dispatch方法混入到jd-input组件实例的methods方法中,然后在jd-input组件里面,就可以直接用this.dispatch方法进行调用。dispatch在很多组件里都会用到,所以抽离出来,各组件需要用的时候直接混入就行了。dispatch方法的作用是从当前组件实例开始,一级一级向上查找父级组件,就像原型链一样,直到找到想找的父级组件,再调用该父级组件实例的对应方法。在这里this.dispatch('jd-form-item', 'validate')就是要往上查找组件名为jd-form-item的组件,查到后再调用jd-form-item实例的validate方法对输入的内容进行校验。

有个点提一下,我们这里使用this.$parent.$emit('validate')方法也可以实现和this.dispatch('jd-form-item', 'validate')一样的功能,因为这里jd-form-item正好是jd-input的父级,如果jd-form-item是jd-input的父级的父级,那么this.$parent的写法就会有问题,所以要用dispatch方法。

下面是dispatch方法的实现,其核心原理就是用this.$parent不停的找当前实例的父级实例,并用parent.$options.componentName来判断这是不是你要找的父级,如果是,就用该父级实例的$emit派发eventName方法,在父级实例内部会有this.$on可以监听到该方法,在我们的例子中这个父级组件就是jd-form-item

  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));
        }
      }
  };

jd-form-item的实现:

看如下代码,jd-form-item主要实现的功能就是methods里定义了一个validate方法,并且在mounted的时候会用this.$on监听其子组件给它派发的validate方法,其子组件的派发方式在上文中已经讲到了是用dispatch方法。这样jd-input里的内容更新就会触发jd-form-item里的validate方法,然后我们在data里定义了一个err字段用来控制错误信息的展示,如果validate校验没通过就展示错误信息,这样这个流程就已经走通了。

但是你仔细看代码就会发现,里面的实现是不是并不是上面说的这么简单,比如这里在描述对象上用到了inject注入,在模版里使用了slot插槽,校验的时候用到了async-validator库,以及校验的字段名称和校验方法的获取,这里就会和文章一开始提到的问题对应起来了,即el-form组件里传的modelrules是做什么用的,是什么时候以及谁会使用它,如何使用它,el-form-item组件里为什么要传一个prop属性,下面让我们来对组件里的代码实现细节进行讲解

<template>
    <div class="form-item">
        <label v-if="label">{{label}}</label>
        <div class="form-item-content">
            <slot></slot>
             <!-- 错误信息 -->
            <p class="err" v-if="err">{{err}}</p>
        </div>
    </div>
</template>

<script>
    import Schema from 'async-validator';
    export default {
        componentName: 'jd-form-item',
        inject: ['form'],
        props: {
            label: {
                type: String,
                default: ''
            },
            prop: {
                type: String
            }
        },
        data() {
            return {
                err: ''
            }
        },
        mounted () {
            this.$on('validate', () => {
                this.validate();
            })
        },
        methods: {
            validate() {
                if (!this.prop) return;

                // 当前的规则
                const rules = this.form.rules[this.prop];

                // 当前值
                const value = this.form.model[this.prop];

                // 校验描述对象
                const desc = {[this.prop]: rules};

                // 创建Schema实例
                const schema = new Schema(desc);

                // 使用schema对值进行校验
                return schema.validate({[this.prop]: value}, errors => {
                    if (errors) {
                        this.err = errors[0].message;
                    } else {
                        this.err = '';
                    }
                })
            }
        },
    }
</script>

slot插槽,在写封装的ui组件时,插槽会使用的比较多,组件会预留一个坑位,坑位里面的内容由你自己控制需要传什么,在我们的文章里传进来的是jd-input组件。复杂一点的会涉及到具名插槽和作用域插槽,这里不做详细赘述,在本文中我们只需要了解jd-form-item使用插槽接收传进来的内容,而jd-form-item本身只负责对传进来的组件里面的内容进行校验,可是校验的具体内容是什么呢,往下看prop属性。

prop属性,我们在使用jd-form-item组件的时候是这么写的<jd-form-item label="用户名" prop="username">,这里有一个prop属性,在组件内部可以通过props获取到,这个prop就是我们要校验的属性的key,如何通过这个key获取到属性值呢,继续往下看。

model和rules属性,我们在使用jd-form组件的时候会这么写<jd-form :model="userInfo" :rules="rules" ref="loginForm">,这里定义了两个属性model和rules,model里面是我们要校验的数据,rules里面是我们要校验的规则,因为在jd-form-item组件里我们已经通过prop知道要校验的属性key,所以我们只要再拿到这个key对应的值以及校验规则就可以进行校验了,这个key对应的值可以通过model[prop]拿到,对应的校验规则可以通过rules[prop]拿到。但是model和rules是在jd-form组件上的,jd-form-item里如何获取到呢,再看下我们使用组件时的模版结构:

<jd-form :model="userInfo" :rules="rules" ref="loginForm">
    <jd-form-item label="用户名" prop="username">
        <jd-input v-model="userInfo.username" placeholder="请输入用户名"></jd-input>
    </jd-form-item>
    <jd-form-item label="密码" prop="password">
        <jd-input v-model="userInfo.password" placeholder="请输入密码"></jd-input>
    </jd-form-item>
    <!-- 提交按钮 -->
    <jd-form-item>
        <button class="login-button" @click="login">登录</button>
    </jd-form-item>
</jd-form>

要让在jd-form上的model和rules在其内部的组件里可以拿到,实现的方法是使用provide/inject,我们在jd-form内部使用provide向下提供一个form属性,把这个form属性的值设为当前jd-form组件的实例(jd-form的具体实现下面会介绍),那么其子组件只需要通过inject: ['form']进行注入,接可以获取到jd-from的实例,就可以在子组件的实例上使用this.form.model[this.prop]this.form.rules[this.prop]拿到要校验的数据以及规则,拿到数据和校验规则之后就可以进行校验然后将对应的校验结果赋值给err在页面上进行提示,我们校验使用的是async-validator这个库,element里面也是用的这个库,具体用法这里就不介绍了。

到这里为止jd-inputjd-form-item就都实现了,实现了数据的双向绑定和实时校验,但是我们现在的校验都是分别针对单个input的校验,当我们要提交的时候,我们需要对整个表单进行校验,这就需要jd-form了,接下来我们来实现本文的最后一个组件jd-form

jd-form组件的实现

从如下代码可以看到,jd-form主要实现的功能是用插槽接收表单里的所有元素,用props获取到数据模型model和校验规则rules,并使用provide向其子组件提供一个form属性,值为jd-form实例,其子组件可以通过注入form属性来获取到model和rules,而不用再分别单独引入。并且定义了一个validate方法,该方法会在点击登录的时候执行,校验整个表单,在本示例中为了方便演示,直接用this.$children获取其子组件实例数组,过滤掉子组件中不包含prop属性的实例(在本示例中包裹登录按钮的jd-form-item组件不需要进行校验),然后再分别调用每个实例的validate方法进行校验,validate方法会返回一个promise对象,如果所有实例的校验都通过,则表单校验通过,有一个实例校验没通过,则表单校验不通过。获取jd-form实例并对表单进行校验的方法请看文章开头处的login方法。

<template>
    <div>
        <slot></slot>
    </div>
</template>

<script>
    export default {
        provide() {
            return {
                form: this
            }
        },
        props: {
            model: Object,
            rules: Object
        },
        methods: {
            validate(cb) {
                // 获取所有的孩子
                const task = this.$children
                    .filter(item => item.prop)
                    .map(item => item.validate());

                // 统一处理所有promise
                Promise.all(task).then(() => {
                    cb(true);
                }).catch(() => {
                    cb(false);
                });
            }
        },
    }
</script>

总结一下:

jd-form

  • 指定数据和校验规则

jd-form-item:

  • 执行校验
  • 显示错误信息

jd-input:

  • 维护数据

本文的github代码链接