likes
comments
collection
share

微信小程序--基于Vant-Weapp 二次封装表单组件

作者站长头像
站长
· 阅读数 28

背景

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 定制级联菜单的实现

定制级联菜单的逻辑是:

  • 选择了第一级才能选择第二级,第二级,第三级是同样的逻辑。
  • 第二级的选项由第一级决定,第三级,第四级的选项同理。
  • 改变了第一级,第二级到第四级选项都要清空。第二级,第三级同理。

微信小程序--基于Vant-Weapp 二次封装表单组件

级联表单项配置数据定义为:

  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>

由于级联类型的配置数据定义是数组而非对象,所以赋值逻辑,取值逻辑,校验逻辑,设置默认选择项的逻辑,都与别的表单类型不一样,要单独处理。另外还要加上级联表单项变更时,其它相关级联项的变更逻辑。大家可以往前翻,回过头看看,凡是有级联类型判断的地方,都是针对级联类型写的特殊逻辑。

微信小程序--基于Vant-Weapp 二次封装表单组件

问题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 {}

    },

最终效果

表单项失焦校验

微信小程序--基于Vant-Weapp 二次封装表单组件

表单提交时的错误提示

微信小程序--基于Vant-Weapp 二次封装表单组件

表单校验通过时的表单数据

微信小程序--基于Vant-Weapp 二次封装表单组件

转载自:https://juejin.cn/post/7186286224707944503
评论
请登录