vue-awesome-form的实现及踩坑记录

2,817 阅读7分钟

最近实现了一个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"
                        }
                    }
                }
            }
        }
    }

最终的渲染效果如下:

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: 这里在排序的时候有一个运算符优先级的问题-优先级高于||,所以如果不确定运算符优先级的话要用()把想要先运算的表达式包起来。

组件间通信

我们的组件结构是这样设计的:

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