什么?都2022年了,你还在一遍又一遍重复写form表单?

18,382 阅读4分钟

前言

  在日常工作中,当需要处理的form表单很多时,我们没有必要一遍又一遍地重复写form表单,直接封装一个组件去处理就好。其实很早之前就有涉猎通过使用类似配置json方法写form表单的文章,虽然当时也没怎么认真看...我们前端组也是使用这种思想配置的。然而,这个思想和方法很早就有出现过,并不怎么新颖,还望不喜勿喷...在此我封装了一个最最最基础的form表单,离我们前端组封装的组件差距还很大,有兴趣朋友们的可以继续往下完善。有封装不好或者值得改进的地方,欢迎各路大佬在评论区里指点江山。

核心思想:

  • 通过配置js文件的变量,使用vueis属性动态切换组件,默认显示的组件为el-input
  • 通过element的分栏和栅格属性,对form表单进行响应式布局
  • baseForm在组件初始化时,需要动态添加校验规则请求接口以及初始化form的部分值
  • 正统思想是对element组件的各个组件进行二次封装,然后通过is属性切换二次封装后的组件,在此不做过多描述,有兴趣的朋友可以自行研究
  • 更好的思想是将页面请求、搜索项、表格、分页封装到一起,形成一个整体,这也是我们前端小组目前的处理思路

实现重点:

  • 任何标签或者组件都可以通过vueis属性来动态切换组件, 本组件使用component
  • 12.27新增: 对于component动态组件,我们可以通过在父动态组件上绑定v-bind="column.props"v-on="getEvents(column)"。将各小子组件需要接收的props属性及事件传递,配合各小组件上的v-bind="$attrs"v-on="$listeners",全部挂载到最小的积木子组件上。
  • 当为对象添加不存在的字段属性时,需要使用$set实现数据的响应式
  • 如果form表单中只有一个输入框,在输入框中按下回车会提交表单,刷新页面。为了阻止这一默认行为,需要在el-form标签上添加@submit.native.prevent
  • 使用lodash中的get方法获取对象的属性值,如果属性值不存在,可以给一个默认值
  • baseForm父组件可以向子组件传一个form对象。那么添加或者编辑form对象,就都可以在父组件中进行。
`表单双向绑定的方式有两种`: 

1.使用v-model进行双向绑定

<component
  v-else
  v-model="form[column.prop]"
  v-bind="column.props"
  v-on="getEvents(column)"
  :column="column"
  :placeholder="column.placeholder || getPlaceholder(column.type, column.label)"
  :label-width="get(column, 'size', column || defaultFormSize)"
  :disabled="get(column, 'disabled', false)"
  :is="get(column, 'type', 'baseInput')"
  @change="changeHandler(column)"
>
</component>

2.使用v-model的语法糖(`:value以及@input`)进行双向绑定

<component
  v-else
  :value="form[column.prop]"
  v-bind="column.props"
  v-on="getEvents(column)"
  :column="column"
  :placeholder="column.placeholder || getPlaceholder(column.type, column.label)"
  :label-width="get(column, 'size', column || defaultFormSize)"
  :disabled="get(column, 'disabled', false)"
  :is="get(column, 'type', 'baseInput')"
  @input="input($event,column.prop)"
  @change="changeHandler(column)"
>
</component>

methods: {
    input(e,prop) {
      this.$set(this.form, prop, e)
    }
}

配置项(本组件写得比较基础,目前仅支持element的五个常用组件):

整体字段:

  • formSize (表单中各element组件的整体大小)
  • labelWidth (表单label整体宽度大小,优先级:子项label宽度 > 表单整体label宽度 > 默认宽度)
  • labelPosition (表单label整体位置)

column数组中每一个对象对应的字段(非请求接口):

  • label (表单label的名称)
  • span (这个表单项占据的份数,一行为24,默认为12)
  • labelWidth (这个表单项的label宽度,默认为90px)
  • labelHeight (这个表单项占据的高度,默认为50px)
  • slotName (表单内容插槽名)
  • prop (这个表单项绑定的属性名称)
  • size (这个表单项组件的大小,默认为small)
  • disabled (是否禁用这个表单项)
  • type (使用二次封装的element组件,功能不全,有兴趣的可以完善,默认为baseInput)
  • dic (非接口请求的静态表单数据,使用{label以及value字段}表示的数组形式)
  • placeholder (组件显示的placeholder内容)

新增/优化功能:

  • callback(小积木组件change事件的回调函数,第一个参数为表单的值,第二个参数为配置项。ps: 当时封装组件时,由于项目比较赶搁置了,后续因为自己的惰性一直没有处理。于12.17下午看到这篇文章,应广大掘友们的建议,新增回调函数)
  • validate(校验方法,用来校验表单是否可以正常使用)
  • props(用于挂载各小积木组件需要接收的props属性和事件传递。其中,props里面非嵌套on属性的地方用于接收props属性,嵌套on属性的地方用于接收小积木组件上的事件传递。)
  • defaultValue(设置表单的默认值)
  • controls(以函数的形式,返回一个对象,用于控制其他表单项的显示与隐藏)
  • labelSlotName(label插槽, 可用于配置一些自定义样式,比如tooltip)

column数组中每一个对象对应的字段(请求接口):

  • url (接口的api地址)
  • requestParams (非必填项,需要额外传入的传参)
  • requestLabel (接口返回对应的id)
  • requestValue (接口返回对应的value)
  • handleDic (格式化下拉列表所展示数据的方法,有两个参数。第一个参数为接口返回的数据,第二个参数为当前配置项)

Tips: 当配置项中同时存在dicurl、requestLabel、requestValue这三件套时,会以三件套请求的结果为准,因为在baseForm子组件初始化时,有重新覆盖dic的值。

效果浏览

image.png

image.png

image.png

E6IOH8YPP48SAUVIQ07(`ET.png image.png

组件架构

image.png

源码放送

1. baseForm组件

<template>
  <el-form
    ref="form"
    v-bind="$attrs"
    v-on="$listeners"
    :model="form"
    :rules="formRules"
    :label-position="option.labelPosition"
    :size="get(option, 'formSize', defaultFormSize)"
    @submit.native.prevent
  >
    <el-row :gutter="20" :span="24">
      <el-col v-for="column in formColumn" :key="column.label" :md="column.span || 12" :sm="12" :xs="24">
      <el-form-item
          v-if="!column.hide"
          :label="`${column.label}:`"
          :prop="column.prop"
          :label-width="
            get(
              column,
              'labelWidth',
              column.labelWidth || option.labelWidth || defaultLabelWidth
            )
          "
          :style="{
            height: get(
              column,
              'labelHeight',
              column.labelHeight || option.labelHeight || defaultLabelHeight
            )
          }"
        >
        
         <template #label>
            <slot
              v-if="column.labelSlotName"
              :name="column.labelSlotName"
              :column="column"
            ></slot>
          </template>
          
          <slot
            v-if="column.slot"
            :name="column.slotName"
            :form="form"
            :prop="column.prop"
            :value="form[column.prop]"
          ></slot>
          
          <component
            v-else
            v-model="form[column.prop]"
            v-bind="column.props"
            v-on="getEvents(column)"
            :column="column"
            :placeholder="column.placeholder || getPlaceholder(column.type, column.label)"
            :label-width="get(column, 'size', column || defaultFormSize)"
            :disabled="get(column, 'disabled', false)"
            :is="get(column, 'type', 'baseInput')"
            @change="changeHandler(column)"
          >
          </component>
          
        </el-form-item>
      </el-col>
    </el-row>
  </el-form>
</template>

<script>
import baseCheckbox from './baseCheckbox'
import baseInput from './baseInput'
import baseRadio from './baseRadio'
import baseSelect from './baseSelect'
import baseTime from './baseTime'
// axios的封装,用原生axios也可以
import request from '@/service/request'
// 后文会给出定义
import { validatenull } from '@/components/avue/utils/validate'
import get from 'lodash/get'

export default {
  components: {
    baseCheckbox,
    baseInput,
    baseRadio,
    baseSelect,
    baseTime
  },

  props: {
    option: {
      type: Object,
      default: () => {}
    },

    form: {
      type: Object,
      default: () => {}
    }
  },
  
  data() {
    return {
      formRules: {},
      defaultFormSize: 'small',
      defaultLabelWidth: '90px',
      defaultLabelHeight: '50px',
      selectList: ['baseRadio', 'baseCheckbox', 'baseSelect'],
      radioList: ['baseRadio', 'baseCheckbox'],
      // page最好封装成常量,放在const文件中。因为要展示,所以直接定义在这里
      page: { pageIndex: 1, pageSize: 0 }
    }
  },

  computed: {
    formColumn() {
      return this.option?.column || []
    },
    
    controlOption({ formColumn }) {
      return formColumn.filter(({ controls }) => controls) || []
    }
  },
  
 watch: {
    form: {
      handler(val) {
        this.controlOption.forEach((item) => {
          const { controls, prop } = item
          let control = controls(val[prop], val) || {}
          Object.keys(control).forEach((key) => {
            const data = this.formColumn.find(({ prop }) => prop == key)
            const { hide } = control[key]
            Object.assign(data, { hide: validatenull(hide) ? true : hide })
          })
        })
      },
      immediate: true,
      deep: true
    }
  },

  created() {
    this.initRules()
    this.initRequest()
    this.initCheck()
    this.initDefaultValue()
  },

  methods: {
    get,
    
    async validate() {
      try {
        await this.$refs.form.validate()
        return true
      } catch (error) {
        return false
      }
    },
    
    getPlaceholder(type, label) {
      return `${type == 'el-select' ? '请选择' : '请输入'}${label}`
    },
    
    getEvents(data) {
      return data?.props?.on
    },
    
    initRequest() {
      if (!Array.isArray(this.formColumn)) return
      // 根据实际请求接口地址的前缀来判断
      const urls = this.formColumn?.filter((item) => item.url)
      const { page } = this
      urls.forEach(async (item) => {
        const data = { page, ...item.requestParams }
        `注意:之所以不在解构中,const { detail = [] }, 是因为解构出来的值,为undefined时才赋初始值`
        `如果结构出来的值为null,是不会赋初始值的`
        const { detail } = await request({
          url: item.url,
          method: 'post',
          data
        }) || []
        let finalResult
        if (item.handleDic) finalResult = item.handleDic(detail, item)
        else finalResult = detail.map((result) => ({
            label: result[item.requestLabel],
            value: result[item.requestValue]
          }))
        this.$set(item, 'dic', finalResult)
      })
    },

    initRules() {
      if (!Array.isArray(this.formColumn)) return
      this.formColumn?.forEach((item) => {
        if (item.rules) {
          item.rules.map((rule, index) => {
            if (rule.required) {
              item.rules.splice(index, 1, {
                message: `${item.label}${this.selectList.includes(item.type) ? '必选' : '必填'}`,
                ...rule
              })
            }
          })
          this.$set(this.formRules, item.prop, item.rules)
        }
      })
    },

    initCheck() {
      const selectList = this.formColumn.filter((item) => this.radioList.includes(item.type))
      selectList.forEach((item) => {
        this.$set(this.form, item.prop, item.type == 'baseRadio' ? item.dic[0].label : [item.dic[0].value])
      })
    },

    changeHandler(data) {
      data.callback && data.callback(this.form[data.prop], data)
    },
    
    initDefaultValue() {
      this.formColumn.map(({ prop, defaultValue }) => defaultValue && this.$set(this.form, prop, defaultValue))
    }
  }
}
</script>

2. baseInput组件

<template>
  <el-input v-bind="all$Attrs" v-on="$listeners"></el-input>
</template>

<script>
import formUtils from '../mixins/'

export default {
  mixins: [formUtils]
}
</script>

3. baseCheckbox组件

<template>
  <el-checkbox-group v-bind="$attrs" v-on="$listeners">
    <el-checkbox v-for="item in column.dic" :key="item.label" :label="item.value">
      {{ item.label }}
    </el-checkbox>
  </el-checkbox-group>
</template>

<script>
export default {
  props: {
    column: Object
  }
}
</script>

4. baseRadio组件

<template>
  <el-radio-group v-bind="$attrs" v-on="$listeners">
    <el-radio v-for="item in column.dic" :key="item.value" :label="item.label">
      {{ item.value }}
    </el-radio>
  </el-radio-group>
</template>

<script>
export default {
  props: {
    column: Object
  }
}
</script>

5. baseSelect组件

<template>
  <el-select size="small" v-bind="all$Attrs" v-on="$listeners">
    <el-option v-for="item in column.dic" :key="item.value" :label="item.label" :value="item.value"> </el-option>
  </el-select>
</template>

<script>
import formUtils from '../mixins/'

export default {
  mixins: [formUtils],
  props: {
    column: Object
  }
}
</script>

6. baseTime组件

<template>
  <el-date-picker
    v-bind="all$Attrs"
    v-on="$listeners"
    type="daterange"
    range-separator="-"
    start-placeholder="开始日期"
    end-placeholder="结束日期"
  >
  </el-date-picker>
</template>

<script>
import formUtils from '../mixins/'

export default {
  mixins: [formUtils]
}
</script>

7. 混入

export default {
  data() {
    return {
      `定义公共的默认配置项,当然你可以传入更多`
      `如果需要针对某些积木组件,特殊配置一些默认项,也可以不用混入,只在积木组件内单独处理`
      DEFAULT_OPTION: {
        size: 'small',
        clearable: true
      }
    }
  },

  props: {
    column: Object
  },

  computed: {
    `重构的绑定对象,后续的配置覆盖默认的`
    all$Attrs({ $attrs, DEFAULT_OPTION }) {
      return { ...DEFAULT_OPTION, ...$attrs }
      `使用下面这种需要给定一个空对象,不然DEFAULT_OPTION会被污染成为合并的结果`
      `1. 当然如果要封装得更细致些,需要定义所有支持的props(而不是随便一个属性都可以传进来),
       对传入的$attrs进行过滤,把支持的保留下来`
      `2. 甚至我们可以引入lodash中的方法,对传入的props的格式进行处理(是否驼峰)`
      `3. 更甚至,整个form组件所有需要传递的props字段,都可以在option对象上以组件名命名key,并用v-bind进行绑定`
      return Object.assign({}, DEFAULT_OPTION, $attrs)
    }
  }
}

8. 配置项

export const option = {
  column: [
    {
      label: '姓名',
      prop: 'name',
      span: 8,

      callback: (data, form) => {
        console.log('data', data)
        console.log('form', form)
      },

      props: {
        on: {
          blur: (e) => {
            console.log('e', e.target.value)
          }
        }
      },
        
      rules: [
        {
          required: true
        }
      ]
    },

    {
      label: '职业',
      prop: 'job',
      type: 'baseSelect',
      // 为el-select开启搜索功能
      props: {
        filterable: true
      },
      span: 8,
      defaultValue: 1,
      controls: value => {
        return {
          hair: {
            hide: {
              0: true,
              1: false,
              2: true,
              3: true
            }[value]
          }
        }
      },
      dic: [
        {
          label: '教师',
          value: 0
        },
        {
          label: '程序猿',
          value: 1
        },
        {
          label: '作家',
          value: 2
        },
        {
          label: '警察',
          value: 3
        }
      ],

      callback: (data, form) => {
      
      },

      rules: [
        {
          required: true
        }
      ]
    },
    
    {
      label: '发量',
      prop: 'hair',
      type: 'baseSelect',
      span: 8,

      props: {
        filterable: true
      },

      dic: [
        {
          label: 'duang',
          value: 0
        },
        {
          label: '地中海',
          value: 1
        },
        {
          label: '稀疏',
          value: 2
        },
        {
          label: '正常',
          value: 3
        }
      ],

      rules: [
        {
          required: true
        }
      ]
    },

    {
      label: '性别',
      prop: 'sex',
      span: 8,
      type: 'baseRadio',

      dic: [
        {
          label: 0,
          value: '男'
        },
        {
          label: 1,
          value: '女'
        }
      ],

      rules: [
        {
          required: true
        }
      ]
    },

    {
      label: '城市',
      prop: 'city',
      type: 'baseCheckbox',
      span: 8,
      dic: [
        {
          label: '仙桃',
          value: 0
        },
        {
          label: '泉州',
          value: 1
        },
        {
          label: '武汉',
          value: 2
        }
      ],
      rules: [
        {
          required: true
        }
      ]
    },
    
    {
      label: '出生日期',
      prop: 'data',
      type: 'baseTime',
      span: 8,
      rules: [
        {
          required: true
        }
      ]
    },
    
    {
      labelSlotName: 'testLabel',
      prop: 'test',
      type: 'baseInput',
      placeholder: 'test',
      span: 8
    },
    
   {
      // 测试接口的调用并展示数据
      label: '测试',
      prop: 'test',
      type: 'baseSelect',
      placeholder: 'test',
      span: 8,
      url: '/emes/factoryOrderService/warehouse/list',
      requestLabel: 'warehouseName',
      requestValue: 'id',
      handleDic: (data, item) => {
        return data.map((result) => ({
          label: `test——${result[item.requestLabel]}`,
          value: result[item.requestValue]
        }))
      },
      rules: [
        {
          required: true
        }
      ]
    },
    
    {
      label: '插槽使用',
      prop: 'usage',
      slot: true,
      slotName: 'usageSlot',
      span: 8,
      rules: [
        {
          required: true
        }
      ]
    }
    
  ]
}

9. 父组件

<template>
  <div class="app-container">
    <baseForm :option="option" :form="form">
      <template #usageSlot="{ form, prop }">
        <baseInput size="small" placeholder="请输入插槽使用" v-model="form[prop]" clearable></baseInput>
      </template>
    </baseForm>
  </div>
</template>

<script>
import baseForm from './module/baseForm.vue'
import baseInput from './module/baseInput.vue'
import { option } from './module/const.js'

export default {
  components: {
    baseForm,
    baseInput
  },

  props: {
    msg: String
  },

  data() {
    return {
      option,
      form: {}
    }
  }
}
</script>

10. 公共方法

/**
 * 判断是否为空
 */
export function validatenull(val) {
  if (val instanceof Date || ['boolean', 'number', 'function'].includes(typeof val)) return false
  if (val instanceof Array) {
    if (val.length === 0) return true
  } else if (val instanceof Object) {
    for (var o in val) {
      return false
    }
    return true
  } else {
    return val === 'null' ||
      val == null ||
      val === 'undefined' ||
      val === undefined ||
      val === ''
  }
  return false
}

11. 添加或编辑

  • 添加: 如果是添加状态,直接在父组件中引入就好。在点击确定按钮时,使用以下代码进行校验,校验通过后继续往下走逻辑,否则就return掉;
async clickHandler() {
    const valid = await this.$refs.form.validate()
    if(!valid) return 
}
  • 编辑: 如果是编辑状态,则需要在父组件页面初始化时,先解构后端返回的数据,再重新分配对象的内存空间。或者将后端返回的数据使用$set进行初始赋值,其余操作同添加状态。
initForm() {
    if (this.type == 'add') return
    const { categoryName, sortNumber } = this.selectData
    this.form = {
      categoryName,
      sortNumber
    }
}

结语

  因为时间有限,封装的这个组件功能也比较有限。欢迎感兴趣的小伙伴在评论区一鸣惊人!