使用元件就像流水線上的工人;設計元件就像設計流水線的人,設計好了給工人使用。
完整專案地址:仿 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 的表單用法,總結出以下我們需要設計的功能。
e-form
負責全域性校驗,並提供插槽;e-form-item
負責單一項校驗及顯示錯誤資訊,並提供插槽;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>
複製程式碼
2. FormItem 的設計
<el-form-item label="使用者名稱" prop="name">
<el-input v-model="ruleForm.name"></el-input>
</el-form-item>
複製程式碼
在 ElementUI 的 formItem
中,我們可以看到:
- 需要
label
來顯示名稱; - 需要
prop
來校驗當前項; - 需要給
input
或button
預留插槽;
根據上面的需求,我們可以建立出自己的 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>
複製程式碼
3. Form 的設計
到現在,我們已經完成了最內部的 input
以及中間層的 FormItem
的設計,現在我們開始設計最外層的 Form
元件。
當層級過多並且元件間需要進行資料傳遞時,Vue 為我們提供了 provide
和 inject
API,方便我們跨層級傳遞資料。
我們舉個例子來簡單實現一下 provide
和 inject
。在 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>
複製程式碼
根據上圖,我們可以看到無論跨越多少層級,provide
和 inject
可以非常方便的實現資料的傳遞。
理解了上面的知識點後,我們可以開始設計 Form
元件了。
<el-form :model="ruleForm" :rules="rules" ref="loginForm">
</el-form>
複製程式碼
根據 ElementUI 中表單的用法,我們知道 Form
元件需要實現以下功能:
- 提供資料模型 model;
- 提供校驗規則 rules;
- 提供槽位,裡面放我們的
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
進來,型別為 Object
。rules
為可傳項。
解釋二: 為了讓各個層級都能使用 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>
複製程式碼
到目前為止,我們的基本功能就已經實現了,除了提交與驗證規則外,所有的元件幾乎與 ElementUI 中的表單一模一樣了。下面我們就開始實現校驗功能。
4. 設計校驗規則
在上面設計的元件中,我們知道校驗當前項和展示錯誤資訊的工作是在 FormItem
元件中,但是資料的變化是在 Input
元件中,所以 FormItem
和 Input
元件是有資料傳遞的。當 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 的 true
或 false
。將所有的結果,放在一個陣列 eachFiledResultArray
中。
解釋四: 獲取所有的結果,統一進行處理,其中有一個結果為 false
,驗證就不能通過。
至此,一個最簡化版本的仿 ElementUI 的表單就實現了。
四. 總結
當然上面的程式碼還有很多可以優化的地方,比如說 dispatch
函式,我們可以寫一遍,使用的時候用 mixin
匯入。由於篇幅關係,這裡就不做處理了。
通過這次實現,我們首先總結一下其中所涉及的知識點。
- 父元件傳遞給子元件用
props
- 子元件派發事件,用
$emit
- 跨層級資料互動,用
provide
和inject
- 用
slot
可以預留插槽
其次是一些思想:
- 單項資料流:父元件傳遞給子元件的值,子元件內部只能用,不能修改。
- 元件內部的
name
屬性,可以通過this.$parent.$options.name
查詢。 - 想要批量處理很多非同步的結果,可以用
promise
物件。
如果文章中錯誤或表述不嚴謹的地方,歡迎指正。
最後,文章會首先發布在我的 Github ,以及公眾號上,歡迎關注,歡迎 star。