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

發表於2019-03-04

最近實現了一個vue-awesome-form元件,主要功能是根據json來生成一個表單,支援同時渲染多個表單,表單巢狀,表單驗證,對於一個簡單的專案,生成表單只需要一個json就可以完成。而且有時候表單項不是前端寫死的,而是由後端控制的,這個時候我們這個元件就派上用場了。

專案地址

專案demo

本文主要介紹元件的實現方式及踩過的一些坑。

元件實現

遞迴元件

我們的json物件是可能有多層巢狀的,所以這裡要用遞迴的方式來實現。關於vue的遞迴元件參考了官網的做法cn.vuejs.org/v2/examples…,在專案中實現方式如下

    <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資料格式是這樣的:

    "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"
                        }
                    }
                }
            }
        }
    }
複製程式碼

最終的渲染效果如下:

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

json物件的每一項都要一個type欄位,表示當前物件的渲染型別,目前支援支援的元件有:

TheTree表示該項是個樹形元件,它應該有一個properties欄位來包含它的子元件。它渲染出來是一個TheTitle元件和properties屬性下的所有表單項。

  • 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: 這裡在排序的時候有一個運算子優先順序的問題-優先順序高於||,所以如果不確定運算子優先順序的話要用()把想要先運算的表示式包起來。

元件間通訊

我們的元件結構是這樣設計的:

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

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下面。

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

對於這種情況,我的處理方式是這樣的:將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);
}
複製程式碼

看上去沒什麼問題,但是在測試的時候發現了這樣一個問題:

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

造成這種情況的原因就是因為後面每一個新增的資料使用的資料都共享了同一個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歡迎交流,謝謝。

相關文章