表单
个人使用el-form组件开发表单编辑场景已有一年之余,先后基于el-form组件几次封装了动态表单组件,封装的动态表单组件说白了就是预先写了一套全量的form模版,并内置了基础的校验逻辑,增加了一些定制的插槽,这样使用动态表单组件的时候,可以传入配置项来选择性的加载想要的基础表单组件。同时,也对基础表单组件进行了一些简化,比如同化了select、radioGroup、checkGroup这些复合组件,包装成了一个高阶组件。
解决的问题:开发一些交互不是特别复杂的编辑场景的时候,可以忽略校验,忽略复合组件,直接定制配置项。
弊端:复杂的编辑场景,如策略的编辑、商品的编辑等等这种交互特别复杂的场景就支撑不住了,原因就是
-
没办法全量的内置所有的逻辑、校验规则和模版。
-
层次深的组件难以校验
为什么难以校验呢?校验是表单场景非常头疼的一个问题,总体可以分为:同步校验、异步校验、联动校验。如果使用el-form都清楚,el-form如果实现校验的话,需要给el-form-item组件传入绑定变量相较于model大对象的路径(prop),这样el-form内置的校验逻辑才能找到值,并校验。这样设计会存在一个问题:
- 页面都会因为需要满足交互而设计为动态联动的,所以prop很可能需要动态计算,组件层次一深,prop找不到,校验失败是常有的事情。
- 灵活的场景都会分割成独立维护的组件,然后还需要在父组件维护一个大对象,这个对象可能需要同时满足UI和数据的设计要求,然后还需要组件间双向数据绑定(编辑时灌数据),难度较大。
- 抽象难度高,如果想把校验和基础组件绑定很难,比如把年级、学科、体系这三个表单及他们的业务关系绑定到一起,同时要求它们可以内置进任何一个表单场景,同时还不影响宿主表单的布局等等,难度也很大。
-
DDD驱动,可复用性低,公共逻辑仅用mixin不够。
以上问题既有el-form的问题也有vue自身的问题,关于vue的问题,在vue下表单场景的思考有总结。
综上,使用el-form去写大型表单场景确实令我十分不爽,重复的工作较多,且配置十分分散,因此比较迫切需要提出设计切合公司业务的表单解决方案。
el-form是如何实现的
element-ui作为应用广泛的开源库,它的每一个组件的设计一定会有它存在的原因,首先来了解一下el-form组件是如何设计的:
源码结构:
——form-item.vue
——form.vue
来看核心的几个部分:
// template
<form class="el-form" :class="[
labelPosition ? 'el-form--label-' + labelPosition : '',
{ 'el-form--inline': inline }
]">
<slot></slot>
</form>
首先form是一个组件,它有根节点和真实的dom,有自己的样式,而不是一个不会实例化的函数组件,可以称为功能组件,因此在表单场景中,form组件既是功能组件又是容器组件,form组件如果不替换样式会影响到UI布局。
// provide
provide() {
return {
elForm: this
};
},
provide是Vue提供的一个语法糖,与之配合的是inject: ['elForm']
,使用这个属性form组件就可以向后代的所有组件注入依赖(form组件中注入的依赖其实是form组件本身的实例),
form组件有两个重要的props,分别为:model
和rules
,重要的方法:clearValidate、validate(validateField)、resetFields
。
重置
我们首先来看form是如何实现重置的:
// form.vue
resetFields() {
...
this.fields.forEach(field => {
field.resetField();
});
}
这里的field其实对应的是form-item组件,resetField方法:
// form-item.vue
resetField() {
// 重置校验状态
this.validateState = '';
this.validateMessage = '';
// 拿到form接受的model值
let model = this.form.model;
// form-item会接受一个prop路径,
// form-item会根据路径去获取值
// fieldValue其实是旧值
let value = this.fieldValue;
let path = this.prop;
if (path.indexOf(':') !== -1) {
path = path.replace(/:/, '.');
}
// getPropByPath返回一个对象:
// o: tempObj,绑定值所在父级的引用
// k: keyArr[i]
// v: tempObj ? tempObj[keyArr[i]] : null
let prop = getPropByPath(model, path, true);
//
this.validateDisabled = true;
if (Array.isArray(value)) {
prop.o[prop.k] = [].concat(this.initialValue);
} else {
prop.o[prop.k] = this.initialValue;
}
...
}
首先完成重置逻辑的其实是form-item组件,form-item组件在实例化阶段时会先把prop绑定的值缓存起来,既fieldValue。其次,form-item这里通过getPropByPath获取了一个对象,比如我定义了一个对象obj,内容:
let obj = {
a: {
b: {
c: 1
}
}
}
将obj连同c所在的相对位置a.b.c
路径传入该函数,就会得到:
{
o: { c:1 },
v: 1,
key: 'c'
}
注意,这里的o是obj中b属性的引用。
从上面的思路不难看出,form使用了一个比较hack的方法实现了重置功能(引用传递),,重要的是它并没有遵循vue提倡的单向数据流机制。通过model传入form组件的对象,其挂载的值被隐形改变了。
这样做的好处是简单暴力做到重置,但坏处也有:
- 不能重置复杂类型,对象或者对象数组无能为力,比如我们重置一块面板数组,很可能只能手动重置。
- 非常依赖vue提供的依赖注入功能,当出现form组件嵌套form组件时,最外层的form组件会失去内层组件的控制权。
- 需要手动传入路径,当然这是因为form组件和基础组件分离导致的设计。
不难想象,因为prop的存在,form-item在开发复杂表单的时候,表现实在是差强人意。但这么做但目的其实也符合一个组件库的设计,form-item意在对基础组件功能进行增强,假如我们把重置校验等功能集成进基础组件,就破坏了单一职责,不利于用户对组件进行扩展,这也是一个很严重的问题。
校验
校验是整个表单场景一个非常大的瓶颈问题,首先,使用element-ui提供的原生校验方式的话,那么很多固定的输入项都得手撸正则,难以复用。其次,校验本身的功能也严重依赖prop属性。
首先来看一下form-item
跟校验有关的比较重要的props:prop、rules
。这里的prop的功能同上面重置,rules就是开发者传入的字段校验规则。格式可以有:
{ type: "string", required: true,validator: (rule, value) => valu=== 'muji'}, trigger: 'blur'
// 或者
[
{ type: "string", required: true,validator: (rule, value) => valu=== 'muji',trigger:'blur'}
]
el-form底层校验是借助async-validate这个校验库实现的,了解这个库的开发者可能知道,rules参数的格式不完全符合该库的要求,所以form-item首先对rule做了一次处理,然后再调用了该库进行校验:
首先,来看一下form-item中执行校验功能的主体逻辑:
// trigger代表当前函数触发的事件类型
validate(trigger, callback = noop) {
this.validateDisabled = false;
// 根据trigger过滤一次数据
const rules = this.getFilteredRule(trigger);
// 未定义校验规则的都视为校验通过
if ((!rules || rules.length === 0) && this.required === undefined) {
callback();
return true;
}
// 校验中
this.validateState = 'validating';
// 将数据处理成async-validate可接受的格式
const descriptor = {};
if (rules && rules.length > 0) {
rules.forEach(rule => {
delete rule.trigger;
});
}
descriptor[this.prop] = rules;
const validator = new AsyncValidator(descriptor);
const model = {};
model[this.prop] = this.fieldValue;
// 验证
validator.validate(model, { firstFields: true }, (errors, invalidFields) => {
// 变更验证状态
// 获取验证提示
this.validateState = !errors ? 'success' : 'error';
this.validateMessage = errors ? errors[0].message : '';
// 执行回调
callback(this.validateMessage, invalidFields);
// 通知el-form组件完成校验,并传入校验结果和prop
this.elForm && this.elForm.$emit('validate', this.prop, !errors, this.validateMessage || null);
});
}
validate方法的逻辑比较简单,就是根据trigger拿到对应rules,然后调用validator,传入rule和value进行校验,最后将校验结果统一返回给el-form组件,这里其实很关键,就是el-form所有的子孙form-item组件都会将校验结果返回给el-form组件,这也就是为什么调用el-form组件的validate方法,可以拿到整个表单校验结果来。
再来看一下el-form是如何拿到所有子孙组件的校验结果的:
this.fields.forEach(field => {
field.validate('', (message, field) => {
if (message) {
valid = false;
}
invalidFields = objectAssign({}, invalidFields, field);
if (
typeof callback === 'function' &&
++count === this.fields.length
) {
callback(valid, invalidFields);
}
});
});
这里做了一个累加操作,当所有子孙form-item组件校验通过后,再调用回调函数,逻辑非常简单。
我们再回到form-item组件中的校验功能,这里还有一个比较重要的逻辑需要了解一下,我们知道,rules是定义字段校验规则的属性,但rules可以定在el-form上,也可以定义再form-item上,那么如果同时在两个位置定义了rules字段,form-item是如何处理的呢?
rules规则的处理是在getFilteredRule函数内部完成的,来看一下该函数的内部逻辑:
// 获取绑定字段对应的rules
getRules() {
let formRules = this.form.rules;
const selfRules = this.rules;
const requiredRule = this.required !== undefined ? { required: !!this.required } : [];
const prop = getPropByPath(formRules, this.prop || '');
formRules = formRules ? (prop.o[this.prop || ''] || prop.v) : [];
return [].concat(selfRules || formRules || []).concat(requiredRule);
},
// 根据trigger过滤rules
getFilteredRule(trigger) {
const rules = this.getRules();
// 浅拷贝,防止删除原对象的trigger字段
return rules.filter(rule => {
if (!rule.trigger || trigger === '') return true;
if (Array.isArray(rule.trigger)) {
return rule.trigger.indexOf(trigger) > -1;
} else {
return rule.trigger === trigger;
}
}).map(rule => objectAssign({}, rule));
}
对rules的处理主要是先根据指定的字段(字段路径)拿到el-form组件定义的对应的rule。再根据trigger进行过滤,拿到最终的校验规则。
以上就是el-form的校验流程。