封装Vue Element的可编辑table表格组件

前一段时间,有博友在我那篇「封装Vue Element的table表格组件」的博文下边留言说有没有那种“表格行内编辑”的封装组件,我当时说我没有封装过这样的组件,因为一直以来在实际开发中也没有遇到过这样的需求,但我当时给他提供了一个思路。

时间过去了这么久,公司的各种需求也不停地往外冒,什么地图图表、表格行内编辑、动态新增表单等等,只有你做不到,没有产品想不到,贼鸡儿累。再加上很快又要过年了,大家工作的心态基本呈直线下滑趋势而玩忽职守、尸位素餐以致饱食终日。只是话虽如此,但越是到年底,需求开发却越是紧急,平时可能一两周的开发任务,现在却要压缩到一周左右就要完成,苦不堪言。这不公司刚刚评完了需求,年前就让开发完成并提测,说是等年后来了,测试同学搞定后就上线。

话说这表格行内编辑,不光要在表格一行内实现文字的编辑,而且还要实现可新增一行或多行表格行内编辑的功能,同时还希望实现表格行内表单的正则验证。听着复杂,想着实现起来也复杂,其实不然,我们完全可以参照element「动态增减表单项」的模式来搞。原理大概其就是利用每一行的索引来设置每一个表单所需的prop和v-model,如果需要新增一行可编辑的表格行,只需往数据源数组中push一行相同的数据即可。

多说一句,年底了,这马上就要放假了,公司里很多人已经回老家了,我们这些留下来的人有一个算一个实在是没心思工作,但你以为这就可以放松了?可以摸摸鱼、划划水了?美得你。听没听说过一个女人:亚里士多德的妹妹,珍(真)妮(你)玛(妈)士(事)多?

不过话又说回来,我们作为打工人,本职工作就是打工。你不工作,你还有脸称自己是打工人吗?你不是打工人,你连饭都吃不到嘴里,你还有脸说自己是“干饭人”?你还真想“十年一觉扬州梦”?东方不亮西方亮,二哈啥样你啥样。好好干活吧你!!!

这两天,趁着中午休息的时候,就把前一段时间加班加点完成的开发需求中的一个表格行内编辑的封装组件给发出来,兹当是给大家又提供了一个轮子亦或是多了一种选择吧。

----------下边封装的代码于2021年02月19日有所更新----------

照例先来张效果图:

1、封装的可编辑表格组件TableForm.vue

<template>
  <el-form :model="form" ref="form" size="small" :rules="rules">
    <el-form-item v-if="!isDetail">
      <el-button type="primary" @click="add">新增</el-button>
    </el-form-item>
    <el-table :data="form.list" border>
      <el-table-column v-for="x in columns" :key="x.prop" :label="x.label" :prop="x.prop" v-bind="x.attr">
        <template slot-scope="{row, $index}">
          <t-text v-if="!x.edit" :row="{x, row}" />
          <template v-else>
            <t-text v-if="isDetail" :row="{x, row}" />
            <template v-else>
              <t-input v-if="x.prop !== 'opt'" v-model="row[`${x.prop}`]" v-bind="componentAttrs(x, row, $index)" class="width100" />
              <template v-else>
                <el-link type="primary" :underline="false" @click="save(row, $index)">保存</el-link>
                <el-link type="primary" :underline="false" v-if="row.isAdd" @click="del($index)">删除</el-link>
                <el-link type="primary" :underline="false" @click="resetField($index)">重置</el-link>
              </template>
            </template>
          </template>
        </template>
      </el-table-column>
    </el-table>
    <el-form-item v-if="!isDetail">
      <template v-if="isSubmit">
        <el-button type="primary" @click="submit">提交</el-button>
        <el-button @click="reset">重置</el-button>
      </template>
    </el-form-item>
  </el-form>
</template>

<script>
export default {
  props: {config: Object},
  components: {
    TInput: {
      functional: true,
      props: ['prop', 'rules', 'type', 'options', 'row', 'cb'],
      render: (h, {props: { prop, rules, type = 'default', options = [], row, cb = () => {} }, data, listeners: {input = () => {}}}) => {
        const children = {
          checkbox: h => h('el-checkbox-group', {props: {...data.attrs}, on: {input(v) {input(v)}}}, options.map(o => h('el-checkbox', {props: {...o, label: o.value, key: o.value}}, [o.label]))),
          select: h => h('el-select', {class: 'width100', props: {...data.attrs}, on: {change(v){input(v)}}}, options.map(o => h('el-option', {props: {...o, key: o.value}}))),
          date: h => h('el-date-picker', {props: {type: 'date', valueFormat: 'yyyy-MM-dd'}, ...data}),
          switch: h => h('el-switch', {props: {activeColor: '#13ce66'}, ...data}),
          mixInput: h => h('el-input', data, [h('el-button', {slot: 'append', props: {icon: 'el-icon-search'}, on: {click(){cb(row)}}})]),
          opt: () => '-',
          default: h => h('el-input', data),
        }

        return h('el-form-item', {props: {prop, rules}}, [children[type](h)])
      }
    },
    TText: {
      functional: true,
      props: ['row'],
      render: (h, {props: { row: { x, row } }}) => {
        if(!row[`${x.prop}`]) return h('span', '-')
        else if(x.format && typeof x.format == 'function') return h('span', x.format(row))
        else return h('span', row[`${x.prop}`])
      }
    },
  },
  data(){
    const { columns = [], isDetail = false, isSubmit = false } = this.config || {}

    return {
      form: {
        list: [columns.reduce((r, c) => ({...r, [c.prop]: c.type == 'checkbox' ? [] : (c.type == 'switch' ? false : '')}), {isAdd: true})]
      },
      columns,
      rules: columns.reduce((r, c) => ({...r, [c.prop]: c.rules ? c.rules : { required: c.required == false ? false : true, message: c.label + '必填'}}), {}),
      isDetail,
      isSubmit,
    }
  },
  methods: {
    componentAttrs(item, row, idx){
      const {type, label} = item, attrs = Object.fromEntries(Object.entries(item).filter(n => !/^(prop|edit|label|attr|format)/.test(n[0]))),
      placeholder = (/^(select|el-date-picker)/.test(type) ? '请选择' : '请输入') + label
      Object.assign(attrs, {prop: `list.${idx}.${item.prop}`, rules: this.rules[item.prop]})
      return {...attrs, row, placeholder}
    },
    add(){
      const { columns = [] } = this.config || {}, obj = columns.reduce((r, c) => ({...r, [c.prop]: c.type == 'checkbox' ? [] : (c.type == 'switch' ? false : '')}), {isAdd: true})
      this.form.list.push(obj)
    },
    save(row, idx){
      let ret = Object.keys(row).map(r => `list.${idx}.${r}`).filter(r => !/isAdd|opt/g.test(r)), { $refs: { form } } = this, num = 0

      form.validateField(ret, valid => {
        if(valid) {
          num++
        }
      })

      if(num == 0) this.$emit('submit', Object.fromEntries(Object.entries(row).filter(n => !/^(isAdd|opt)/.test(n[0]))))
    },
    del(idx){
      this.form.list.splice(idx, 1)
      this.$refs.form.fields.forEach(n => {
        if(n.prop.split(".")[1] == idx){
          n.clearValidate();
        }
      })
    },
    submit(){
      this.$refs.form.validate(valid => {
        if(valid){
          this.$emit('submit', this.form.list.map(m => Object.fromEntries(Object.entries(m).filter(n => !/^(isAdd|opt)/.test(n[0])))))
        }
      })
    },
    resetField(idx){
      this.$refs.form.fields.forEach(n => {
        if(n.prop.split(".")[1] == idx){
          n.resetField();
        }
      })
    },
    reset(){
      this.$refs.form.resetFields();
    },
    // 回显数据
    setData(form){
      form = (form && form.length > 0) ? form.map(n => this.columns.reduce((r, c) => ({...r, [c.prop]: n[c.prop] == false ? n[c.prop] : (n[c.prop] || (c.type == 'checkbox' ? [] : ''))}), {})) : [columns.reduce((r, c) => ({...r, [c.prop]: c.type == 'checkbox' ? [] : (c.type == 'switch' ? false : '')}), {isAdd: true})]
      Object.assign(this.form, {list: form})
      setTimeout(()=> {this.$refs.form.clearValidate()})
    },
  }
}
</script>
<style scoped>
.width100{width: 100%;}
</style>

本次封装的可编辑的表格组件,基本把大家在表格中内嵌的一些常用表单如:input输入框、select下拉框/选择器、日期选择器、checkbox复选框、switch开关等都封装进去了,大家只需根据自己的实际需求去添加不同的type就可以了,如果你还有其他的表单组件需要加进去,你自己按照我这个套路给封装进去就完事了。

另外,本次封装有几个点,大家注意下:

1)本次封装的组件,不光可以实现表格行内的编辑,同样当你下次需要回显这些数据当详情展示的时候,你只需多传一个isDetail参数就可以了,该参数默认为false。
纯详情列表展示的效果如图:

页面当中也加了这个isDetail的判断。另外还有一个isSubmit,它主要的使用场景是可能有些公司的需求会要求在表格中所有的表单都填完后统一提交给接口而不是逐行提交数据,如果你们公司没有这个实际需求,那么就直接把封装的组件中的下边这段代码去掉就可以了,记得把data中的isSubmit也去掉,还有methods中的submit方法和reset方法也去掉:

<el-form-item v-if="!isDetail" :style="{marginTop: isSubmit ? '18px' : 0}">
   <template v-if="isSubmit">
     <el-button type="primary" @click="submit">提交</el-button>
     <el-button @click="reset">重置</el-button>
   </template>
</el-form-item>

多说一句,展示详情时的表格列表中的数据如果比较简单,把isDetail设置为true即可,如果需要做很多的格式化如千分位、时间戳转日期格式、map映射等等,单纯的只把isDetail设置为true怕是不能够满足你的需求,此时给你推荐我的另一篇技术分享博文「封装Vue Element的table表格组件」,仅供参考。

2)封装的代码中还有这么一个判断<t-text v-if="!x.edit" :row="{x, row}" />,这个判断也有点意思。大家在使用可编辑表格的时候,每一行的数据并不都是需要编辑的,例如本文开头贴出的效果图中表格的前两列就不需要编辑,就只是文字展示,但其他列基本都是需要编辑的,那么这个判断就是用在这里的。

3)也许有同学已经注意到了,在本次封装所用到的data数据对象中,有一串实现方法:

list: [columns.reduce((r, c) => ({...r, [c.prop]: c.type == 'checkbox' ? [] : (c.type == 'switch' ? false : '')}), {})]

这段代码是干嘛滴的呢?它是用来初始化table表格及表格中的表单的。

4)如果你使用的是逐行保存数据的方式,那么在保存时就需要注意了,如下代码:

save(row, idx){
   let ret = Object.keys(row).map(r => `list.${idx}.${r}`).filter(r => !/isAdd|opt/g.test(r)), { $refs: { form } } = this, num = 0

   form.validateField(ret, valid => {
     if(valid) {
        num++
     }
   })

   if(num == 0){
      this.$emit('submit', Object.fromEntries(Object.entries(row).filter(n => !/^(isAdd|opt)/.test(n[0]))))
   }
}

这些代码干了两件事,第一件事是单行校验validateField,这个方法接收两个参数,第一个参数的类型是array|string,第二个参数是一个回调函数,具体api请自行查阅官网,在校验的回调函数中我们将定义的num变量累加1,为什么这么做呢?是因为一行表格中可能有多个表单,多个表单的校验就需要传给validateField的第一个参数是一个数组,那么validateField的回调就会是一个类似for循环的多个函数,如果你把逐行调接口保存数据的方法写在这里边,结果就是一行中有几个表单需要校验,最后就会调几次接口,那酸爽酸么酸得嘞~;第二件事就是在第一件事的基础上我们只取num等于0的时候将填好的表单数据$emit到父组件,然后在父组件中去调接口保存数据即可,这样就可以保证在保存数据时只调一次接口,还可以保证封装的组件只是一个公共组件而不用参与到接口的联调过程。

5)还有一个非常隐秘的角落需要跟大家说清楚,先看代码:

del(idx){
  this.form.list.splice(idx, 1)
  this.$refs.form.fields.forEach(n => {
    if(n.prop.split(".")[1] == idx){
       n.clearValidate();
    }
  })
}

很显然这些代码是用来删除新增的单行表单的,那么为什么还要使用clearValidate方法呢?举个例子你就知道了。

比如我们新增了三行可编辑的表单,然后在第二行中直接点击保存,会在第二行中的表单下边出现红色的必填提示,这没有问题,接着我在第三行的任意一个表单中输入数据,这个有数据的表单是不会出现红色必填提示的,这也没有问题,但是如我此时把第二行给删除了呢?你猜会出现什么问题呢?问题就是第二行的校验结果跑到了原本的第三行上,而原本第三行上有数据的那个表单也出现了必填的校验了,这肯定就不对了。所以我们就加了一个clearValidate方法来清除原本是第二行最后却跑到了原本是第三行(现在变成了第二行)上的校验。

为什么会出现上述的问题呢?我猜应该是vue的异步更新策略导致的,我们删除了第二行,使得原本的第三行变成了第二行,然后在vue的异步更新策略的牛逼效果下,原本是第二行的校验结果自动就落在了原本是第三行上。有小伙伴儿可能会说了,如果原本的第三行也有校验提示,你使用clearValidate方法岂不是把原本第三行的校验也给清除了。我觉得这不是问题,因为clearValidate方法只是清除校验结果,并不清除重置所填写的数据,而且你在点击保存时,还有一道校验呢。

说到这儿,再来看上边的代码,我们还拿上边的例子来说:第二行被删除后,第三行会自动填充到被删除的第二行的位置,那么自动填充上来的第三行的索引就变成了被删除的第二行的索引,所以forEach循环体中那个idx的判断不需要加1就可以完美解决上述问题。

6)本次封装的组件,若是还需要编辑已经回显的数据,那么需要编辑的当前行是没有删除按钮的。因为这类的删除就不是单纯的前端删除了,是需要走接口去逻辑删除或物理删除的,也是需要前端额外去判断的。如果你有实际使用场景,可以自行添加或告知我,我来告诉你如何添加和判断。

2、使用方法:

<template>
  <TableForm :config="config" @submit="submit" ref="form" style="margin:20px;" />
</template>

<script>
import TableForm from "./TableForm";

const repayTypeList = {
   averageCapital: '等额本金',
   averageInterest: '等额本息'
},
columns = [
  { prop: 'repaymentMethod', label: '还款方式', attr: {width: '180'}, format: ({ repaymentMethod }) => repayTypeList[repaymentMethod]},
  { prop: 'productPer', label: '期数', attr: {width: '180'}, format: ({ productPer }) => `${+ productPer + 1}期(${productPer}个月)` },
  { prop: 'costRate', label: '成本利率', attr: {minWidth: '110'}, edit: true, type: 'select', options: [{label: '5%', value: '5'}, {label: '10%', value: '10'}] },
  { prop: 'price', label: '单价', attr: {minWidth: '140'}, edit: true, rules: [{required: true, message: '请输入单价'}, {pattern: /^(?!0+(?:\.0+)?$)(?:[1-9]\d*|0)(?:\.\d{1,})?$/, message: '单价须大于0,若有小数,则小数点后至少1位'}] },
  { prop: 'company', label: '所属公司', attr: {minWidth: '110'}, edit: true },
  { prop: 'product', label: '产品', attr: {minWidth: '110'}, edit: true, type: 'checkbox', options: [{label: '橘子', value: 'orange'}, {label: '苹果', value: 'apple'}] },
  { prop: 'date', label: '日期', attr: {minWidth: '110'}, edit: true, type: 'date', required: false },
  { prop: 'lock', label: '锁定', attr: {minWidth: '110'}, edit: true, type: 'switch' },
  { prop: 'search', label: '搜索', attr: {minWidth: '110'}, edit: true, type: 'mixInput', cb: row => {console.log(row)}},
  { prop: 'opt', label: '操作', attr: {minWidth: '110'}, edit: true },
]

export default {
  components: {
    TableForm,
  },
  data(){
    return {
      config: {
        columns,
        data: [],
      },
    }
  },
  mounted(){
    const form = [
      {repaymentMethod: '201602', productPer: '1', price: '5', company: '谷歌上海', date: '2021-01-03', lock: false},
      {repaymentMethod: '201601', productPer: '3', costRate: '10', price: '', company: '雅虎北京', lock: true}
    ]
    // 模拟调接口回显数据
    setTimeout(() => {
      this.$refs.form.setData(form)
    }, 2000)
  },
  methods: {
    submit(res){
      console.log(res)
    }
  }
}
</script>

对于数据回显,也就是将接口返回的表单数据再回写进我们封装的这个表格组件中,这种模式大多是用来编辑数据的,那么有必要再重新换一种方式来实现,具体看上边代码。这里做一个简单的说明:
我们来看回显代码:

setData(form){
   form = (form && form.length > 0) ? form.map(n => this.columns.reduce((r, c) => ({...r, [c.prop]: n[c.prop] == false ? n[c.prop] : (n[c.prop] || (c.type == 'checkbox' ? [] : ''))}), {})) : [columns.reduce((r, c) => ({...r, [c.prop]: c.type == 'checkbox' ? [] : (c.type == 'switch' ? false : '')}), {isAdd: true})]
   Object.assign(this.form, {list: form})
   setTimeout(()=> {this.$refs.form.clearValidate()})
}

其中,n[c.prop] == false ? n[c.prop] : ...这段代码的判断或许有人会疑惑。大家要知道element的switch组件的值是非false既true的,不加这个判断,当数据回显时,switch为false的值就回显不出来。

在使用的过程中,有一个需要注意的地方就是在columns数组中有一个required的属性,该属性默认为true,这个属性主要是用来控制当前的表单是否需要必填的校验的。还有需要说明的是,本次封装只是封装了每个表单是否需要必填的正则校验rules,没有对其他的正则验证如数字类型、小数位数等加以封装,如有需要你可以自行添加。

最后,通过子组件触发父组件的submit函数的方式来获取表格中表单的输入值。

再多说一句,本次封装可能有点啰嗦,因为加入了isDetail是否详情展示的判断,还加入了isSubmit是否在所有行中的表单都填完了后统一保存的判断和重置,这也是照顾了一些可能潜在的需求,如果你的需求中没有这些,可以自行删除这些代码,如果你没有逐行保存和逐行重置的需求,你也可以自行删除相关的代码。

posted @ 2021-02-05 16:38  豫见陈公子  阅读(7977)  评论(26编辑  收藏  举报