vue-awesome-form的實現及踩坑記錄

最近實現了一個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會渲染成一個能夠動態增長上述除TheTreeTheAddInput 組件的組件

上面的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組件中。對於這種組件的通訊,我想到三種方式:

  • 經過父子組件通訊的方式,將數據一層層傳回到Schema組件中
  • 使用Vuex統一管理組件間通訊
  • 使用一個EventBus實現事件的統一監聽和派發

第一種方式實現太過繁瑣,不推薦。

對於第二種方式,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
                }
            }
        }
    }
複製代碼

TheInputobjVal就是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中的key

對於須要使用v-for來渲染的元素,好比checkboxoptions,selectoptions,我都是用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的輸入內容是符合校驗規則的,因此它的validateStatesuccess,不會顯示校驗信息,這時候咱們刪除第一個input,咱們會發現第一個input的輸入內容變成了第二個,可是校驗信息卻還在這個input下面。

對於這種狀況,個人處理方式是這樣的:將TheInput的校驗信息交由TheAddInput組件管理,在TheAddInput組件中新增一個data: validateArray;用來保存子組件的validateState,當咱們新增一個表單項的時候咱們就向validateArraypush一個validateState,而後使用v-for渲染TheInput組件的時候根據數據的index取到validateArray中對應的驗證信息,每次TheInput組件觸發驗證的時候將事件傳遞給TheAddInput組件來更新validateArray的對應指定項,當咱們刪除的時候把validateArray中對應index的驗證信息刪除。這樣的話當咱們刪除第0項的時候,雖然實際刪除的是key爲1的dom,可是對應的validateArray第0項也被刪除,新的validateArray的第0項保存的是原來第1項的驗證信息,這樣數據就能對應上了。

vue更新檢測

接着上面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發現TheAddInputvalidateArray已經更改了,可是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組件,當咱們點擊新增一行的時候咱們會根據表單schemaaddDefault字段來生成一行默認的數據,這是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});
}
複製代碼

nextTick

對於TheInput組件,咱們在onInput的時候將新的輸入值傳遞給SchemaForm組件,而後在blur的時候來觸發驗證,這時候組件內的objVal是新的值,可是對於TheRadio組件和TheCheckbox組件,咱們是在onChange事件中將新的值傳給SchemaForm組件,而且同時進行驗證,這時候咱們拿到的objVal其實並非新的值,而是當前的值,因此這裏的驗證要等待數據更新以後再觸發,我寫了一個asyncValidate來解決這個問題:

asyncValidate() {
    this.$nextTick(() => {
        this.validate();
    });
}
複製代碼

最後

以上是我的開發vue-awesome-form的實現方式與總結,若有錯誤,歡迎指正,對組件有什麼建議或者發現組件的bug歡迎交流,謝謝。

相關文章
相關標籤/搜索