最近實現了一個vue-awesome-form
組件,主要功能是根據json
來生成一個表單,支持同時渲染多個表單,表單嵌套,表單驗證,對於一個簡單的項目,生成表單只須要一個json
就能夠完成。並且有時候表單項不是前端寫死的,而是由後端控制的,這個時候咱們這個組件就派上用場了。javascript
項目地址。html
項目demo。前端
本文主要介紹組件的實現方式及踩過的一些坑。vue
咱們的json對象是可能有多層嵌套的,因此這裏要用遞歸的方式來實現。關於vue的遞歸組件參考了官網的作法cn.vuejs.org/v2/examples…,在項目中實現方式以下java
<template>
<div class="jf-tree">
<the-title :title="title" :level="objKey.length"></the-title>
<div class="jf-tree-item">
<component v-for="item in orderProperty(properties)" :key="item.key" :is="item.val.type" :objKey="getObjKeys(objKey, item.key)" :objVal="getObjVal(item.key)" v-bind="item.val">
</component>
</div>
</div>
</template>
複製代碼
對應的json數據格式是這樣的:git
"register": {
"type": "TheTree",
"title": "註冊",
"properties": {
"name": {
"type": "TheInput",
"title": "姓名",
"rules": {
"required": true,
"message": "The name cannot be empty"
}
},
"location": {
"type": "TheTree",
"title": "地址信息",
"propertyOrder": 3,
"properties": {
"province": {
"type": "TheInput",
"title": "省份",
"rules": {
"required": true,
"message": "The 省份 cannot be empty"
}
},
"city": {
"type": "TheInput",
"title": "市",
"rules": {
"required": true,
"message": "The 市 cannot be empty"
}
}
}
}
}
}
複製代碼
最終的渲染效果以下:github
json對象的每一項都要一個type
字段,表示當前對象的渲染類型,目前支持支持的組件有:vuex
TheTree
表示該項是個樹形組件,它應該有一個properties
字段來包含它的子組件。它渲染出來是一個TheTitle
組件和properties屬性下的全部表單項。json
TheTitle
會渲染成一個h2,隨着層級的深度font-size遞減後端
TheInput
會渲染成一個input
TheTextarea
會渲染成一個textarea
ThePassInput
會渲染成一個type='password'的input
TheCheckbox
會渲染成一個 type ='checkbox'的input
TheRadio
會渲染成一個type=‘radio’的input
TheSelect
會渲染成一個下拉列表組件
TheAddInput
會渲染成一個能夠動態增長,刪除一個TheInput
組件的組件
TheTable
會渲染成一個能夠動態增長上述除TheTree
和TheAddInput
組件的組件
上面的demo中包含了全部可能的渲染結果
tip: 由於咱們的組件是根據type
字段動態渲染的,因此這裏使用Vue
內置的動態組件component
,它能夠根據傳入的is
屬性來自動渲染對應的組件,咱們就不須要寫一大堆的v-if
來判斷應該渲染哪一個組件了。
由於咱們的表單項是一個json
對象,因此咱們使用v-for
渲染的時候沒法保證數據的渲染順序,若是我想要某一個表單項先渲染,你把它寫在前面可能並無用。就像你沒法在for-in
遍歷對象中保證遍歷的順序同樣。這是一個例子。
因此咱們須要在每一項數據中加一個propertyOrder
字段表示它在同一層級中的順序。而後咱們根據propertyOrder
字段把對象轉成數組而後從小到大排序,若是沒有這個字段的話默認值爲999,代碼以下:
// 根據propertyOrder 從小到大排序
orderProperty(oldObj) {
// 先遍歷對象,生成數組
// 對數組排序
const keys = Object.keys(oldObj);
// 若是對象只有一個字段,不須要排序
if(keys.length <= 1) return oldObj;
return keys.map(key => {
return {
key,
val: oldObj[key]
};
}).sort((pre, cur) => {
return (pre.val.propertyOrder || 999) - (cur.val.propertyOrder || 999);
});
}
複製代碼
tip: 這裏在排序的時候有一個運算符優先級的問題-
優先級高於||
,因此若是不肯定運算符優先級的話要用()
把想要先運算的表達式包起來。
咱們的組件結構是這樣設計的:
以TheTable
組件爲例,咱們的數據是這樣傳遞的SchemaForm->TheTree->TheTable->TheInput等表單組件
,咱們把表單的值從SchemaForm
一層層傳遞到TheInput
組件,綁定爲TheInput
組件的v-model
,而後當咱們在TheInput
組件中執行輸入的時候,咱們但願在SchemaForm
組件中拿到新的值,從而更新數據,而後新的數據會再次經過props
傳遞到TheInput
組件中。對於這種組件的通訊,我想到三種方式:
第一種方式實現太過繁瑣,不推薦。
對於第二種方式,vuex的文檔中有這樣一句話:
若是您不打算開發大型單頁應用,使用 Vuex 多是繁瑣冗餘的。確實是如此——若是您的應用夠簡單,您最好不要使用 Vuex。一個簡單的 global event bus 就足夠您所需了。可是,若是您須要構建一箇中大型單頁應用,您極可能會考慮如何更好地在組件外部管理狀態,Vuex 將會成爲天然而然的選擇。
顯然咱們的組件並不複雜,沒必要要使用vuex,因此根據上面這句話裏面提到的global event bus
,咱們採用第三種方式實現。
首先咱們須要一個global對象,代碼以下
import Vue from "vue";
export const EventBus = new Vue();
複製代碼
是的,它什麼也沒作,就只是返回了一個Vue的實例對象。
而後咱們在TheInput
組件中是這樣使用的:
<template>
<input class="jf-input" type="text" v-model="msg" />
</template>
<script> import { EventBus } from '../utils' export default { ..... computed: { msg: { get: function() { return this.objVal; }, set: function(value) { EventBus.$emit('on-set-form-data', { key: this.keyName, value }); } } } ..... } </script>
複製代碼
這裏的objVal
就是經過SchemaForm
傳過來的表單項的值,這裏的keyName
是一個表示當前屬性鏈的一個數組,好比這樣一個json對象:
{
SchemaForm: {
TheTree: {
TheTable: {
TheInput: 123
}
}
}
}
複製代碼
TheInput
的objVal
就是123,keyName
就是['SchemaForm', 'TheTree', 'TheTable', 'TheInput']
回到組件通訊的問題,咱們在TheInput組件中觸發了一個on-set-form-data
的事件,而後在SchemaForm
咱們是這樣接收的:
import { EventBus } from '../utils'
export default {
.....
created: function() {
EventBus.$on('on-set-form-data', payload => {
this.setFormData(payload);
});
},
methods: {
setFormData(payload) {
const { key, value } = payload;
key.reduce((pre, cur, curIndex, arr) => {
// 若是是最後一項,就是咱們要改變的字段
if(curIndex === arr.length - 1) {
// Vue 不能檢測直接用索引設置數組某一項的值
if(typeof(cur) === 'number') {
return pre.splice(cur, 1, value);
} else {
return pre[cur] = value;
}
}
return pre[cur] = pre[cur] || {}
}, this.formValue);
}
}
.....
}
複製代碼
咱們經過$on監聽on-set-form-data
事件,而後觸發setFormData方法,進而修改formValue
的值,而後新的formValue
就會傳遞給子組件的objVal
,從而實現狀態更新。
咱們將表單提交控制權交給使用者,在SchemaForm
組件中暴露validate
方法用來驗證整個表單,使用者能夠這樣調用:
handleSubmit() {
this.$refs.schemaForm.validate((err, values) => {
if(err) {
console.log('驗證失敗');
} else {
// values是表單的值,你能夠用來提交表單或者其餘任何事情
console.log('驗證成功', values);
}
})
}
複製代碼
表單驗證咱們使用的是async-validator,它的驗證是異步的,咱們只能在回調函數中獲取到驗證結果,咱們在SchemaForm
中須要驗證全部的表單項,就要拿到每一項的驗證結果,咱們使用Promise
來完成這個功能,首先是每一個表單項的驗證函數:
validate() {
return new Promise((resolve, reject) => {
if(!this.rules) resolve({title: this.title, status: true});
let descriptor = {
name: this.rules
};
let validator = new schema(descriptor);
validator.validate({name: this.msg}, (err, fields) => {
if(err) {
resolve({
title: this.title,
status: false
});
}else {
resolve({
title: this.title,
status: true
});
}
})
})
}
複製代碼
而後是SchemaForm的validate函數:
validate(cb) {
let err = false;
// 這裏的fields是全部表單組件組成的數組
let len = this.fields.length;
this.fields.forEach((field, index) => {
field.validate().then(res => {
const { title, status } = res;
if(!status) {
err = true;
}
if((index + 1) === len) {
cb(err, this.formValue);
}
}).catch(err => {
console.log(err);
})
})
}
複製代碼
對於須要使用v-for
來渲染的元素,好比checkbox
的options
,select
的options
,我都是用value
做爲每一項的key
,由於能夠保證惟一(其實用index
做爲key
也沒有什麼影響,由於這些數據不會發生改變)。可是對於TheAddInput
組件和TheTable
組件來講,它們所包含的表單項是能夠動態增刪的,因此不存在能夠惟一標識的字段。因此這裏咱們使用index
做爲key
,可是這樣會產生一些問題,vue的文檔中是這樣說的:
當 Vue.js 用 v-for 正在更新已渲染過的元素列表時,它默認用「就地複用」策略。若是數據項的順序被改變,Vue 將不會移動 DOM 元素來匹配數據項的順序, 而是簡單複用此處每一個元素,而且確保它在特定索引下顯示已被渲染過的每一個元素。這個相似 Vue 1.x 的 track-by="$index" 。
這個默認的模式是高效的,可是隻適用於不依賴子組件狀態或臨時 DOM 狀態 (例如:表單輸入值) 的列表渲染輸出。
關於依賴臨時 DOM 狀態的列表渲染會遇到的問題我寫了一個demo。
打開demo,在姓名,年齡,地址後面的輸入框中輸入一些信息,而後點擊下面的按鈕刪除第一項,這時候你會發現,雖然第一項變成了年齡,可是年齡後面的輸入內容卻變成了原來姓名的輸入內容,地址後面的輸入內容變成了原來年齡的輸入內容。這就是由於使用了index
作爲key
,第一次的時候三個列表項的key分別是0,1,2;當咱們刪除第一項以後,新的列表的的key
變成了0,1。就會形成真正刪除的實際上是key
爲2的元素,這時候每一項的label根據數據渲染出來仍是正確的,可是後面input
的內容是複用以前的input
因此並無相應發生變化。
而咱們這裏使用index
做爲key
就屬於依賴子組件的狀態。以TheAddInput
組件爲例,這個組件內部調用了TheInput
組件,而TheInput
組件內部有一個本身的data
: validateState
用來控制驗證信息的渲染。若是咱們用index
做爲key,
會存在這樣一種狀況:咱們先增長一個input
,而後它的校驗規則是不能爲空,當咱們鼠標離開的時候觸發校驗,這時候validateState
變成了error
,校驗信息就會顯示在這個input
下面,而後咱們再增長一個input,在裏面輸入一些內容,這時候咱們鼠標離開,第二個input
的輸入內容是符合校驗規則的,因此它的validateState
是success,
不會顯示校驗信息,這時候咱們刪除第一個input
,咱們會發現第一個input
的輸入內容變成了第二個,可是校驗信息卻還在這個input
下面。
對於這種狀況,個人處理方式是這樣的:將TheInput
的校驗信息交由TheAddInput
組件管理,在TheAddInput
組件中新增一個data
: validateArray
;用來保存子組件的validateState
,當咱們新增一個表單項的時候咱們就向validateArray
中push
一個validateState
,而後使用v-for
渲染TheInput
組件的時候根據數據的index
取到validateArray
中對應的驗證信息,每次TheInput
組件觸發驗證的時候將事件傳遞給TheAddInput
組件來更新validateArray
的對應指定項,當咱們刪除的時候把validateArray
中對應index的驗證信息刪除。這樣的話當咱們刪除第0項的時候,雖然實際刪除的是key爲1的dom,可是對應的validateArray
第0項也被刪除,新的validateArray
的第0項保存的是原來第1項的驗證信息,這樣數據就能對應上了。
接着上面TheInput
的驗證問題,一開始我是這樣作的,在TheInput
觸發驗證以後
this.dispatch('on-input-validate', {
index: index,
validateState: state
})
複製代碼
而後在TheAddInput
組件中監聽
this.$on('on-input-validate', obj => {
this.validateArray[obj.index] = obj.validateState;
})
複製代碼
寫完以後發現並無效果,鼠標離開以後觸發了驗證,可是驗證信息並無顯示出來。經過vue-devtools
發現TheAddInput
的validateArray
已經更改了,可是TheInput
組件的props
並無更新。忽然想起來好像在vue的文檔裏面看到過這個,去找了找,果真發現了緣由:
因爲 JavaScript 的限制,Vue 不能檢測如下變更的數組:
當你利用索引直接設置一個項時,例如:vm.items[indexOfItem] = newValue
當你修改數組的長度時,例如:vm.items.length = newLength
根據文檔的解決方案,改爲了下面這種寫法:
this.$on('on-input-validate', obj => {
this.validateArray.splice(obj.index, 1, obj.validateState);
})
複製代碼
相似的,對於對象的更新檢測也是有問題的,詳細能夠參考vue文檔,這裏不作贅述。
對於TheTable
組件,當咱們點擊新增一行的時候咱們會根據表單schema
的addDefault
字段來生成一行默認的數據,這是demo中表格的addDefault
字段:
"addDefault": {
"type": "",
"name": "",
"gender": "",
"interests": []
}
複製代碼
當咱們點擊添加一行的時候會觸發TheTable
組件的add
方法:
add() {
this.msg.push(this.addDefault);
}
複製代碼
看上去沒什麼問題,可是在測試的時候發現了這樣一個問題:
形成這種狀況的緣由就是由於後面每個新增的數據使用的數據都共享了同一個addDefault
,因此保持數據的不可變是很重要的,稍不注意就可能發生這種錯誤,對於大型項目的話可使用immutable.js,我這個組件自己數據並不複雜,因此對這個addDefault
實現了一層淺拷貝來解決這個問題:
add() {
this.msg.push({...this.addDefault});
}
複製代碼
對於TheInput
組件,咱們在onInput
的時候將新的輸入值傳遞給SchemaForm
組件,而後在blur
的時候來觸發驗證,這時候組件內的objVal
是新的值,可是對於TheRadio
組件和TheCheckbox
組件,咱們是在onChange
事件中將新的值傳給SchemaForm
組件,而且同時進行驗證,這時候咱們拿到的objVal
其實並非新的值,而是當前的值,因此這裏的驗證要等待數據更新以後再觸發,我寫了一個asyncValidate
來解決這個問題:
asyncValidate() {
this.$nextTick(() => {
this.validate();
});
}
複製代碼
以上是我的開發vue-awesome-form
的實現方式與總結,若有錯誤,歡迎指正,對組件有什麼建議或者發現組件的bug歡迎交流,謝謝。