仿 ElmentUI 實現一個 Form 表單

李等等扣丁發表於2019-04-22

使用元件就像流水線上的工人;設計元件就像設計流水線的人,設計好了給工人使用。

完整專案地址:仿 ElmentUI 實現一個 Form 表單

一. 目標

仿 ElementUI 實現一個簡單的 Form 表單,主要實現以下四點:

  • Form
  • FormItem
  • Input
  • 表單驗證

我們先看一下 ElementUI 中 Form 表單的基本用法

 <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 或者 Button

二. 建立專案

我們通過 Vue CLI 3.x 建立專案。

使用 vue create e-form 建立一個目錄。

使用 npm run serve 啟動專案。

三. Form 元件設計

ElementUI 中的表單叫做 el-form ,我們設計的表單就叫 e-form

為了實現 e-form 表單,我們參考 ElementUI 的表單用法,總結出以下我們需要設計的功能。

  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>
    <!-- 1、繫結 value 
    2、響應 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。

仿 ElmentUI 實現一個 Form 表單

相關文章