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欢迎交流,谢谢。

相关文章
相关标签/搜索