仿 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。

相關文章
相關標籤/搜索