最近实现了一个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
会渲染成一个能够动态增长上述除TheTree
和TheAddInput
组件的组件
上面的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
组件中。对于这种组件的通讯,我想到三种方式:
第一种方式实现太过繁琐,不推荐。
对于第二种方式,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
}
}
}
}
复制代码
TheInput
的objVal
就是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
来渲染的元素,好比checkbox
的options
,select
的options
,我都是用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
的输入内容是符合校验规则的,因此它的validateState
是success,
不会显示校验信息,这时候咱们删除第一个input
,咱们会发现第一个input
的输入内容变成了第二个,可是校验信息却还在这个input
下面。
对于这种状况,个人处理方式是这样的:将TheInput
的校验信息交由TheAddInput
组件管理,在TheAddInput
组件中新增一个data
: validateArray
;用来保存子组件的validateState
,当咱们新增一个表单项的时候咱们就向validateArray
中push
一个validateState
,而后使用v-for
渲染TheInput
组件的时候根据数据的index
取到validateArray
中对应的验证信息,每次TheInput
组件触发验证的时候将事件传递给TheAddInput
组件来更新validateArray
的对应指定项,当咱们删除的时候把validateArray
中对应index的验证信息删除。这样的话当咱们删除第0项的时候,虽然实际删除的是key为1的dom,可是对应的validateArray
第0项也被删除,新的validateArray
的第0项保存的是原来第1项的验证信息,这样数据就能对应上了。
接着上面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
发现TheAddInput
的validateArray
已经更改了,可是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
组件,当咱们点击新增一行的时候咱们会根据表单schema
的addDefault
字段来生成一行默认的数据,这是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});
}
复制代码
对于TheInput
组件,咱们在onInput
的时候将新的输入值传递给SchemaForm
组件,而后在blur
的时候来触发验证,这时候组件内的objVal
是新的值,可是对于TheRadio
组件和TheCheckbox
组件,咱们是在onChange
事件中将新的值传给SchemaForm
组件,而且同时进行验证,这时候咱们拿到的objVal
其实并非新的值,而是当前的值,因此这里的验证要等待数据更新以后再触发,我写了一个asyncValidate
来解决这个问题:
asyncValidate() {
this.$nextTick(() => {
this.validate();
});
}
复制代码
以上是我的开发vue-awesome-form
的实现方式与总结,若有错误,欢迎指正,对组件有什么建议或者发现组件的bug欢迎交流,谢谢。