微信小程序--基于Vant-Weapp 二次封装表单组件
背景
Vant Weapp微信小程序组件库没有提供表单的校验功能,另外在wxml文件中直接用van-field,van-picker,van-switch,van-datetime-picker等组件生成表单,没有通过数据配置表单项生成表单效率高,因此决定封装一个基础表单组件。初步想法就是遍历一个数组,数组中的每项是表单项的配置数据,根据每一项配置的类型生成不同的表单元素,循环生成表单中所有的内容。
问题1. 用数组还是对象进行遍历生成表单?
80%的人都会凭直觉,选择用数组定义表单配置数据,就像这样
formConfig: [
{
type: 'text',
field: 'borrowerName',
label: '借款人姓名',
value: '',
errMsg: '',
...
},
]
然而凭直觉写出来的东西,并不一定好用,比如在数组中查找或修改某一表单项的值,就不如对象查找方便快捷,因此最终选择了用对象定义表单配置数据。
formConfig: {
borrowerName: {
type: 'text',
label: '借款人姓名',
value: '',
errMsg: '',
...
},
问题2 表单项校验功能如何实现?
表单校验分为两种场景,第一种是失焦时的校验,第二种是提交表单数据时的校验,先看第一种
2.1 表单失焦时的校验实现
van-field组件提供了失焦事件回调函数的配置,可供我们利用。 在表单配置项中增加一项配置-校验函数, 将校验项定义成函数是为了方便扩展,表单项最基础的校验就是判空校验,以及常见的正则校验,其它特殊的校验规则,可以在完成基础校验之后,在validator函数中继续添加校验规则。
formConfig: {
borrowerName: {
// ...
validator: (context: TLooseObj) => baseValidator(context, 'formConfig', 'borrowerName')
},
我们先实现最基本的判空和常见的正则校验,把这段逻辑封装成一个函数,是因为这是每个表单项的通用逻辑,通过巧妙地传递上下文context,可以在业务文件外部改变业务文件中的响应数据,触发视图显示错误文案。细心的同学可能会好奇, 这里regExp[reg].rule.test(value)为什么不直接传正则表达式进来,而要通过导入一个正则对象来获取正则表达式,这是因为微信小程序有瑕疵,向方法传递参数,会对参数进行JOSN.stringify处理,那么表单配置项中配置的正则表达式,就是变成空对象{}, 获取不到配置的正则表达式 所以只能曲线救国。
import { regExp } from './index';
/**
* 表单项校验
* @param context Page对象上下文
* @param formKey 表单字段名
* @param field 表单项字段名
*/
export const baseValidator = (context: TLooseObj, formKey: string, field: string) => {
const { [formKey]: baseFormConfig } = context.data;
// console.log(baseFormConfig[field]);
const { value, label, reg = '' } = baseFormConfig[field];
// 空值判断
if (`${value}`.trim() === '') {
baseFormConfig[field].errMsg = `${label}不能为空`;
context.setData({ [formKey]: baseFormConfig });
return false;
}
// 正则校验--直接传正则表达式,值不正确
if (reg && !regExp[reg].rule.test(value)) {
baseFormConfig[field].errMsg = `${label}输入不正确`;
context.setData({ [formKey]: baseFormConfig });
return false;
}
baseFormConfig[field].errMsg = '';
context.setData({ [formKey]: baseFormConfig });
return true;
}
在失焦事件中执行triggerFormItemValidate方法,触发校验规则
wx:if="{{item.type === 'text' || item.type === 'number' || item.type === 'tel' }}"
label="{{item.label}}"
placeholder="{{tools.placeholder(item)}}"
value="{{ item.value }}"
data-field="{{key}}"
// ...,
bind:blur="triggerFormItemValidate"
/>
在triggerFormItemValidate方法中,获取一些关键参数如表单项的键名,表单项的类型(级联类型cascade交互逻辑比较特殊,是一种业务定制类型,下文会讲),调用表单项的validator方法,formConfig[field]?.validator(this)
,完成表单项的校验。
// 校验表单项
triggerFormItemValidate(event: TLooseObj) {
// console.log(event);
const field = event.currentTarget.dataset.field;
const cascadeIndex = event.currentTarget.dataset.cascadeIndex;
const { formConfig } = this.data as TLooseObj;
const type = formConfig[field].type;
// 执行表单项配置的校验规则
if (type === 'cascade') {
const enable = formConfig[field].cascade[cascadeIndex].disabled === false;
// 当前层级可选时,才进行表单校验
enable && formConfig[field].cascade[cascadeIndex]?.validator(this);
} else {
formConfig[field]?.validator(this);
}
},
2.2 提交表单时的校验实现
思路很简单,把每个表单项的校验函数遍历一遍,就完成了对整个表单的校验。
formValidate(){
const { formConfig } = this.data as TLooseObj;
const isAllPass = Object.keys(formConfig).map((key) => {
const type = formConfig[key].type;
// 级联选择是个数组,每项都有校验器
if (type === 'cascade') {
return formConfig[key].cascade.map((item: TLooseObj) => item.validator(this)).every((isPass: boolean) => isPass);
} else if (type === 'switch') {
// 开关有默认选中项,无需校验
return true;
}
else {
// console.log(formConfig[key]);
return formConfig[key].validator(this);
}
}).every((isPass: boolean) => isPass);
}
问题3 不同表单项的渲染如何实现?
纯输入类型和开关类型的表单项实现没有难度,直接按照vant-weapp相关组件配置即可,主要是级联类型,选型类型,日期类型,要通过输入组件van-field和弹窗组件van-popup组合,才能实现。实现思路是:监听van-field组件的click-input事件bind:click-input="handleTriggerPicker"
,在handleTriggerPicker显示van-picker弹窗,这里有两点要注意,第一要将van-field设置成readonly,第二要绑定click-input事件,只有这样模拟选择框的交互效果才比较逼真,van-field不会出现输入光标。
还有一段逻辑比较巧妙,就是通过定义一个选择器对象pickerConfig,记录处于激活态的picker的键名,拿到键名后,就能从表单配置对象中拿到这个表单项的所有配置信息,给弹窗中不同类型的表单项各种参数赋值,实现选项类型select,级联类型cascade,日期类型date,这三种表单类型共用一个弹窗,以及复用弹窗的取消和确定绑定的方法逻辑。
data: {
formConfig: {},
// 滚轮选项弹窗配置,所有滚轮选项复用
pickerConfig: {
// 处于激活状态的滚轮选择框
activeKey: 'custType',
// 级联选项索引序号
cascadeIndex: '',
// 控制选项弹窗的显隐
visible: false,
},
},
<wxs src="../../wxs/tools.wxs" module="tools" />
<view class="form-group">
<block wx:for="{{formConfig}}" wx:key="key" wx:for-index="key">
<!-- 输入框 -->
<van-field
wx:if="{{item.type === 'text' || item.type === 'number' || item.type === 'tel' }}"
label="{{item.label}}"
placeholder="{{tools.placeholder(item)}}"
value="{{ item.value }}"
data-field="{{key}}"
maxlength="{{item.maxlength || -1}}"
title-width="{{formLabelWidth || '4em'}}"
error-message="{{item.errMsg}}"
bind:change="viewToModel"
bind:blur="triggerFormItemValidate"
required="{{item.required === false ? false :true}}" />
<!-- 开关按钮 -->
<van-field
wx:elif="{{item.type === 'switch'}}"
label="{{item.label}}"
readonly
title-width="{{formLabelWidth || '7.2em'}}"
required="{{item.required === false ? false :true}}">
<van-switch
slot="right-icon"
checked="{{ item.value }}"
size="40rpx"
active-color="#f74444"
inactive-color="#f0f0f0"
data-field="{{key}}"
bind:change="viewToModel" />
</van-field>
<!-- 选项框 -->
<van-field
wx:elif="{{item.type === 'select'}}"
label="{{item.label}}"
placeholder="{{tools.placeholder(item)}}"
value="{{ tools.findOptionText(item.options,item.value) }}"
data-field="{{key}}"
title-width="{{formLabelWidth || '8em'}}"
error-message="{{item.errMsg || ' '}}"
readonly
data-visible="{{true}}"
right-icon="arrow"
required="{{item.required === false ? false :true}}"
bind:click-input="handleTriggerPicker" />
<!-- 级联选择 -->
<block wx:elif="{{item.type === 'cascade'}}">
<van-field
wx:for="{{item.cascade}}" wx:key="index" wx:for-item="cascadeItem" wx:for-index="cascadeIndex"
label="{{cascadeIndex === 0 ? item.label :' '}}"
placeholder="{{tools.placeholder(cascadeItem)}}"
value="{{ tools.findOptionText(cascadeItem.options,cascadeItem.value) }}"
data-field="{{key}}"
data-cascade-index="{{cascadeIndex}}"
data-visible="{{cascadeItem.disabled ? false : true}}"
title-width="{{formLabelWidth || '4em'}}"
error-message="{{cascadeItem.errMsg || ' '}}"
readonly
right-icon="arrow"
required="{{cascadeIndex === 0 ? true :false}}"
bind:click-input="handleTriggerPicker" />
</block>
<!-- 日期选择 -->
<van-field
wx:elif="{{item.type === 'date'}}"
label="{{item.label}}"
placeholder="{{tools.placeholder(item)}}"
value="{{ tools.formatDate(item.value,'yyyy-mm-dd') }}"
data-field="{{key}}"
title-width="{{formLabelWidth || '4em'}}"
error-message="{{item.errMsg}}"
readonly
data-visible="{{true}}"
right-icon="arrow"
required="{{item.required === false ? false :true}}"
bind:click-input="handleTriggerPicker" />
</block>
<!-- 滚轮选项弹窗 -->
<van-popup show="{{ pickerConfig.visible }}" round position="bottom" custom-class="picker-modal">
<!-- picker类型 -->
<van-picker
wx:if="{{formConfig[pickerConfig.activeKey].type === 'select'}}"
id="picker"
columns="{{ formConfig[pickerConfig.activeKey].options }}"
data-field="{{pickerConfig.activeKey}}"
show-toolbar="true"
custom-class="base-picker"
confirm-button-text="确定"
bind:confirm="handlePickerConfirm"
bind:cancel="handleTriggerPicker" />
<!-- 级联选择类型 -->
<van-picker
wx:elif="{{formConfig[pickerConfig.activeKey].type === 'cascade'}}"
id="picker"
columns="{{ formConfig[pickerConfig.activeKey]['cascade'][pickerConfig.cascadeIndex].options }}"
data-field="{{pickerConfig.activeKey}}"
data-cascade-index="{{pickerConfig.cascadeIndex}}"
show-toolbar="true"
custom-class="base-picker"
confirm-button-text="确定"
bind:confirm="handlePickerConfirm"
bind:cancel="handleTriggerPicker" />
<!-- 日期类型 -->
<van-datetime-picker
wx:elif="{{formConfig[pickerConfig.activeKey].type === 'date'}}"
type="{{formConfig[pickerConfig.activeKey].type}}"
value="{{ formConfig[pickerConfig.activeKey].value }}"
data-field="{{pickerConfig.activeKey}}"
min-date="{{ formConfig[pickerConfig.activeKey].minDate }}"
max-date="{{ formConfig[pickerConfig.activeKey].maxDate }}"
bind:confirm="handlePickerConfirm"
bind:cancel="handleTriggerPicker" />
</van-popup>
</view>
<wxs src="../../wxs/tools.wxs" module="tools" />
文件中定义的两个方法,一个用来生成placeholder,一个用来查找选项值对应的文本值。在wxs中写方法限制很大,许多js的方法和语法都不能使用。如const, 箭头函数无法使用,数组的许多方法用不了。很坑爹。
/**
* 获取表单项提示语
* @param item
*/
var placeholder = function(item) {
var prefix = item.type === 'select' || item.type === 'cascade' ? '选择' : '输入';
return '请' + prefix + item.label;
};
/**
* 根据值查找其所对应的文本值
* @param options 选项数组
* @param value 选项值
* @return 选项值对应的文本值
*/
var findOptionText = function(options, value) {
var len = options.length;
for (var i = 0; i < len; i++) {
if (options[i].value === value) return options[i].text;
}
return '';
};
打开选项弹窗之后,要做两件事情,第一是同步视图数据到模型,第二是触发表单校验。
// 处理滚轮选择确认事件
handlePickerConfirm(event: WechatMiniprogram.CustomEvent) {
this.viewToModel(event);
// 关闭picker弹窗并触发表单项校验
this.handleTriggerPicker(event);
},
同步视图输入选择数据到模型,这个方法有点长,长是因为级联表单项属于定制逻辑,需要做特殊处理。其它类型实现逻辑比较简单,从事件对象拿到表单项的字段名和值之后,给表单对象项的value字段赋值即可。这里要吐槽一下,给van-field组件设置model:value只能实现浅层次的数据双向绑定,如model:value="{{testA}}",如果超过一级,如model:value="{{testB.testC}}",则没有数据双向绑定效果。只能通过表单项对应的事件来同步视图数据。 text,number,tel,switch类型是change事件,select,cascade, date类型是弹窗的confirm事件。
data:{
testA:'',
testB:{
testC:''
}
}
// 同步视图数据到模型
viewToModel(event: WechatMiniprogram.CustomEvent) {
// console.log(event);
const field = event.currentTarget.dataset.field;
const { formConfig } = this.data as TLooseObj;
const type = formConfig[field].type;
if (type === "select") {
const { value: item } = event.detail;
// console.log({ item });
formConfig[field].value = item.value;
} else if (type === "cascade") {
const { value: item } = event.detail;
const len = formConfig[field].cascade.length;
const cascadeIndex = event.currentTarget.dataset.cascadeIndex;
formConfig[field].cascade[cascadeIndex].value = item.value;
// 清空及禁用当前层级下一层级之后的级联选项
for (let i = cascadeIndex + 1; i < len; i++) {
formConfig[field].cascade[i].value = '';
formConfig[field].cascade[i].disabled = true;
}
// 不是最后一项的话,设置当前层级的下一层级可选以及选项列表
if ((cascadeIndex + 1) < len) {
formConfig[field].cascade[cascadeIndex + 1].disabled = false;
formConfig[field].cascade[cascadeIndex + 1].options = [];
}
} else {
formConfig[field].value = event.detail;
}
this.setData({ formConfig });
// console.log(formConfig)
},
handleTriggerPicker方法的逻辑是,打开选项弹窗时,设置默认选中项,关闭选项弹窗时,执行表单项校验。执行表单项校验的方法triggerFormItemValidate在问题2有提及。
// 触发滚轮选择弹窗显隐和表单项校验
handleTriggerPicker(e: WechatMiniprogram.CustomEvent) {
const { field, visible, cascadeIndex } = e.currentTarget.dataset;
const { formConfig, pickerConfig } = this.data as TLooseObj;
// console.log({ field, cascadeIndex, visible });
pickerConfig.visible = visible;
pickerConfig.activeKey = field;
pickerConfig.cascadeIndex = cascadeIndex;
this.setData({ pickerConfig }, () => {
// console.log(formConfig[field].type);
if (['select', 'cascade'].includes(formConfig[field].type)) {
// picker打开时,设置默认选中项
if (visible) {
this.setPickerDefaultIndex(formConfig, field, cascadeIdex);
} else {
// 关闭时触发选项校验
this.triggerFormItemValidate(e);
}
}
});
},
setPickerDefaultIndex设置选项弹窗打开时的默认选中项时,有三点要注意:
- 动态设置van-picker的default-index属性是无效的,必须通过van-picker实例的setColumnIndex设置默认选中项。
- 在setData的回调函数中,也就是视图更新完成之后,才能获取到van-picker实例。
- 根据选项值从选项列表中查找选项的序号,有可能找不到,设置选中项之前要对意外情况进行判断。
/**
* 设置选项框打开时的默认选中项
* @param formConfig 表单配置对象
* @param field 表单字段名
* @param cascadeIndex 级联层级序号
*/
setPickerDefaultIndex(formConfig: TLooseObj, field: string, cascadeIndex: number) {
let defaultIndex = 0;
const picker = this.selectComponent("#picker");
const type = formConfig[field].type;
if (type === 'select') {
defaultIndex = findOptionIndex(formConfig[field].options, formConfig[field].value);
} else if (type === 'cascade') {
const item = formConfig[field].cascade[cascadeIndex];
defaultIndex = findOptionIndex(item.options, item.value);
}
// 找到此选项,设置默认选中
if (defaultIndex !== -1) {
picker.setColumnIndex(0, defaultIndex);
}
},
问题4 定制级联菜单的实现
定制级联菜单的逻辑是:
- 选择了第一级才能选择第二级,第二级,第三级是同样的逻辑。
- 第二级的选项由第一级决定,第三级,第四级的选项同理。
- 改变了第一级,第二级到第四级选项都要清空。第二级,第三级同理。
级联表单项配置数据定义为:
job: {
type: 'cascade',
label: '选择行业',
value: '',
cascade: [{
value: '',
disabled: false,
label: '一级行业',
options: getPickerOptions('custType'),
errMsg: '',
validator: (context: TLooseObj) => cascadeValidator(context, 'formConfig', 'job', 0)
},
{
value: '',
label: '二级行业',
disabled: true,
options: getPickerOptions('custType'),
errMsg: '',
// 真实的数据是动态获取的,为了快速调试,没有造真实的数据
validator: (context: TLooseObj) => cascadeValidator(context, 'formConfig', 'job', 1)
},
{
value: '',
label: '三级行业',
disabled: true,
options: getPickerOptions('custType'),
errMsg: '',
validator: (context: TLooseObj) => cascadeValidator(context, 'formConfig', 'job', 2)
},
{
value: '',
label: '四级行业',
disabled: true,
options: getPickerOptions('custType'),
errMsg: '',
validator: (context: TLooseObj) => cascadeValidator(context, 'formConfig', 'job', 3)
}]
},
级联的视图渲染是通过遍历数组实现,级联数组的查找,需要索引号,所以比起select类型,要多传data-cascade-index="{{cascadeIndex}}"
属性。这里有个小细节要注意,wx:elif要使用block而不是view标签,这也是官方推荐的写法,渲染性能较好。
<!-- 级联选择 -->
<block wx:elif="{{item.type === 'cascade'}}">
<van-field
wx:for="{{item.cascade}}" wx:key="index" wx:for-item="cascadeItem" wx:for-index="cascadeIndex"
label="{{cascadeIndex === 0 ? item.label :' '}}"
placeholder="{{tools.placeholder(cascadeItem)}}"
value="{{ tools.findOptionText(cascadeItem.options,cascadeItem.value) }}"
data-field="{{key}}"
data-cascade-index="{{cascadeIndex}}"
data-visible="{{cascadeItem.disabled ? false : true}}"
title-width="{{formLabelWidth || '4em'}}"
error-message="{{cascadeItem.errMsg || ' '}}"
readonly
right-icon="arrow"
required="{{cascadeIndex === 0 ? true :false}}"
bind:click-input="handleTriggerPicker" />
</block>
由于级联类型的配置数据定义是数组而非对象,所以赋值逻辑,取值逻辑,校验逻辑,设置默认选择项的逻辑,都与别的表单类型不一样,要单独处理。另外还要加上级联表单项变更时,其它相关级联项的变更逻辑。大家可以往前翻,回过头看看,凡是有级联类型判断的地方,都是针对级联类型写的特殊逻辑。
问题5.父组件如何获取表单组件的表单值?
获取子组件实例,然后调用子组件获取表单数据的方法。这里有个细节要注意一下,绑定tap事件优先要用catchtap而不是bindtap, 大多数情况下,我们都不需要事件冒泡,要catchtap性能更好。
<base-form id="co-borrower-form" config="{{formConfig}}" form-label-width="7em" />
<view class="form-btn">
<view catchtap="goBack" class="btn back">退回</view>
<view catchtap="handleSaveFormData" class="btn primary-btn save">暂存</view>
</view>
handleSaveFormData() {
const coBorrowerForm = this.selectComponent("#co-borrower-form")
const formData = coBorrowerForm.getFormData();
console.log(formData);
},
goBack() {
wx.navigateBack();
},
子组件获取表单数据的方法
getFormData() {
const { formConfig } = this.data as TLooseObj;
const isAllPass = Object.keys(formConfig).map((key) => {
const type = formConfig[key].type;
// 级联选择是个数组,每项都有校验器
if (type === 'cascade') {
return formConfig[key].cascade.map((item: TLooseObj) => item.validator(this)).every((isPass: boolean) => isPass);
} else if (type === 'switch') {
// 开关有默认选中项,无需校验
return true;
}
else {
// console.log(formConfig[key]);
return formConfig[key].validator(this);
}
}).every((isPass: boolean) => isPass);
// console.log({ isAllPass });
// 表单校验通过,遍历表单对象赋值
if (isAllPass) {
return Object.keys(formConfig).reduce((formData: TLooseObj, key: string) => {
const type = formConfig[key].type;
if (type === 'cascade') {
formData[key] = formConfig[key].cascade.map((item: TLooseObj) => item.value).join(',');
} else {
formData[key] = formConfig[key].value;
}
return formData;
}, {});
}
return {}
},
最终效果
表单项失焦校验
表单提交时的错误提示
表单校验通过时的表单数据
转载自:https://juejin.cn/post/7186286224707944503