Vue

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

Google+ Pinterest LinkedIn Tumblr

最近實現了一個 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歡迎交流,謝謝。

Write A Comment