书写一个管理平台开发经常使用的通用table组件

来如今这公司一年了,一年时间里经手作的项目有六七个,不过呢大部分都是一些管理平台的功能,而管理平台作的最多的就是各类表格的展现了,因此在开发过程当中,为了提升开发效率,封装一些通用的功能组件是十分有必要的,在这里我就把我在开发过程当中所封装的表格组件分享一下,固然确定是有不少不足的,由于到目前为止我仍是有一些想法没有实现的,也但愿能够互相交流一下,就当抛砖引玉了,砸到谁也别怨我啊0.0javascript

开发环境

我这边使用的是vue全家桶+ElementUI,毕竟管理平台,没那么高的要求,版本的话随意,毕竟只是说明一种设计方法,通用的css

需求分析

管理平台的表格页面通常包含这几部分功能:1.操做按钮(添加,批量删除等);2.表格数据筛选项(经常使用的有select过滤,时间过滤,搜索过滤等);3.表格主体;4.分页。html

1.操做按钮设计

操做按钮的添加我这边想到的有两种方法,第一种:直接使用vue提供的插槽功能,直接在外部定义按钮、样式,以及按钮的操做事件,经过插槽插入组件内部;第二种:组件外部定义一个按钮的对象,经过父子组件通讯传递到table组件内部,组件内部对按钮的对象进行解析、渲染,大概格式以下:前端

[
    {
        name: 'addBtn',
        text: '新增', // 按钮文案
        icon: 'el-icon-plus', // 按钮图标
        style: 'primary', // 按钮样式(这里取element的按钮样式)
        class: 'addBtn', // 自定义按钮class
        func: 'toAdd' // 按钮点击事件
    }, {
        name: 'multiDelBtn',
        text: '批量删除',
        icon: 'el-icon-delete',
        style: 'danger',
        class: 'multiDel',
        func: 'toMultiDel'
    }
]
复制代码

2.表格数据筛选项

筛选项的设计没想到有什么好的,就内设几个经常使用的就上面说的那些,而后经过参数判断是否展现,其余若是有定制需求能够外部定义,而后经过插槽插入vue

3.表格主体

表格主要包括两部分:表头和表格体,分开分析java

表格头我这边设计是在组件外部定义配置项传递组件内部,组件内进行解析,格式以下:git

[
    {
        prop: 'name', // 表格数据对应的字段
        label: '用户姓名', // 表格头展现的信息
        sortable: false,  // 这一列是否支持排序,true|false|'custom',能够不传(为true是前端排序,不过前端排序没什么意义,一半排序的话仍是传‘custom’进行服务端排序)
        minWidth: '100', // 这一列的最小宽度(用minWidth是由于在表格宽度不够的时候有个限制不会变形,在宽度过大的时候又可以按照各列的比例进行伸展,perfect!)
    }, {
        prop: 'address',
        label: '住址',
        minWidth: '170'
    }, {
        prop: 'age',
        label: '年龄',
        sortable: 'custom',
        minWidth: '80'
    }
]
复制代码

表格体没什么,就参看element-ui的就行github

4.分页

分页也没什么好说的,内置在组件内,少于一页不显示,在element组件库选一个本身须要的功能的分页element-ui

组件开发

根据需求对组件进行了封装后端

1.操做按钮设计

<div class="header-operate">
    <!-- 操做按钮插槽 -->
    <slot name="operateBtn"></slot>
    <el-button v-for="(btnItem, btnIndex) in propConfig.btnList" :key="btnIndex" :class="item.class" :type="item.style" :icon="item.icon" @click="toEmitFunc(item.func)" >{{item.text}}</el-button>
</div>
复制代码

2.筛选项设计

<div class="header-filter">
    <!-- 筛选项插槽 -->
    <slot name="filter"></slot>
    <el-select class="filter-iterm" v-if="propConfig.showFilter" v-model="filter" size="small" :placeholder="propConfig.filterPlaceholder" :clearable="true" filterable @change="handleSelect" >
      <el-option v-for="(item, index) in propConfig.filterOptions" :key="index" :label="item.label" :value="item.value" ></el-option>
    </el-select>
    <el-date-picker v-if="propConfig.showDatePick" v-model="dateRange" class="filter-iterm" align="right" size="small" format="timestamp" value-format="timestamp" :type="propConfig.datePickType" :clearable="true" :start-placeholder="propConfig.startPlaceholder" :end-placeholder="propConfig.endPlaceholder" @change="handleTimerange" ></el-date-picker>
    <el-input class="table-search filter-iterm" v-if="propConfig.showSearch" size="small" :placeholder="propConfig.searchPlaceholder" v-model="search" @keyup.13.native="toSearch" >
      <i slot="suffix" class="el-input__icon el-icon-search" @click="toSearch"></i>
    </el-input>
</div>
复制代码

3.table主体设计

<el-table :data="tableData.tbody" style="width: 100%" @selection-change="handleSelection" @sort-change="handleSort" >
    <el-table-column v-if="tableData.isMulti" type="selection" width="50"></el-table-column>
    <template v-for="(item, index) in tableData.thead">
      <el-table-column :key="index" :prop="item.prop" :label="item.label" :width="item.width" :min-width="item.minWidth" :sortable="item.sortable" >
        <template slot-scope="scope">
          <span class="default-cell" :title="scope.row[item.prop]">{{scope.row[item.prop]}}</span>
        </template>
      </el-table-column>
    </template>
</el-table>
复制代码

4.分页设计

<el-pagination class="table-pagination" v-if="tableData.pageInfo.total > tableData.pageInfo.size" layout="total, prev, pager, next, jumper" :current-page.sync="tableData.pageInfo.page" :page-size="tableData.pageInfo.size" :total="tableData.pageInfo.total" @current-change="pageChange" ></el-pagination>
复制代码

5.接受传参与methods

props: {
    tableConfig: {
      type: Object,
      default: () => {
        return {}
      }
    },
    tableData: {
      type: Object,
      default: () => {
        return {
          thead: [],
          tbody: [],
          isMulti: false, // 是否展现多选
          pageInfo: { page: 1, size: 10, total: 0 } // 默认一页十条数据
        }
      }
    }
},
  methods: {
    toEmitFunc (funName, params) {
      this.$emit(funName, params)
    },
    toSearch () {
      this.toEmitFunc('setFilter', { search: this.search, page: 1 })
    },
    pageChange (val) {
      this.toEmitFunc('setFilter', { page: val })
    },
    handleSelection (val) {
      let cluster = {
        id: [],
        status: [],
        grantee: [],
        rows: []
      }
      val.forEach(function (element) {
        cluster.id.push(element.id)
        cluster.status.push(element.status)
        cluster.rows.push(element)
        if (element.grantee) cluster.grantee.push(element.grantee)
      })
      this.toEmitFunc('selectionChange', cluster)
    },
    handleSort (value) {
      this.toEmitFunc('setFilter', {
        prop: value.prop,
        order: value.order
      })
    },
    handleTimerange () {
        if (this.dateRange) {
            this.eventBus('setFilter', {
              startTime: this.dateRange[0],
              endTime: this.dateRange[1]
            })
        } else {
            this.eventBus('setFilter', {
              startTime: '',
              endTime: ''
            })
        }
    },
    handleSelect () {
      this.toEmitFunc('setFilter', {
        filter: this.filter
      })
    }
  }
复制代码

看到这,你确定说这不就是element的table的使用吗?嗯...你说的颇有道理,我竟没法反驳0.0,下面我就加入一些本身的想法设计吧(づ ̄3 ̄)づ

先写demo,在写的过程当中才能一步步完善不足

<!--外部引用文件-->
<template>
  <div class="home">
    <TableComponent :loading="loading" :tableConfig="tableConfig" :tableData="tableData"/>
  </div>
</template>

<script> import TableComponent from '../components/TableComponent' export default { name: 'home', components: { TableComponent }, data () { return { tableConfig: { btnList: [ { name: 'addBtn', text: '新增', icon: 'el-icon-plus', style: 'primary', class: 'addBtn', func: 'toAdd' }, { name: 'multiDelBtn', text: '批量删除', icon: 'el-icon-delete', style: 'danger', class: 'multiDel', func: 'toMultiDel' } ], showFilter: true, filterOptions: [], showDatePick: true }, tableData: { thead: [], tbody: [], isMulti: false, pageInfo: { page: 1, size: 10, total: 0 } }, loading: false } }, created () { this.toSetTdata() }, methods: { toSetTdata () { let that = this that.loading = true that.tableData.thead = [ { prop: 'name', label: '用户姓名', minWidth: '100' }, { prop: 'age', label: '年龄', sortable: 'custom', minWidth: '92' }, { prop: 'address', label: '家庭住址', minWidth: '130' }, { prop: 'status', label: '帐号状态', minWidth: '100' }, { prop: 'email', label: '邮箱地址', minWidth: '134' }, { prop: 'createdTime', label: '添加时间', minWidth: '128' } ] setTimeout(() => { that.tableData.tbody = [ { "id": "810000199002137628", "name": "邓磊", "age": 23, "address": "勐海县", "status": "offline", "email": "y.rbuuy@ndtccqms.lv", "createdTime": 1560218008 }, { "id": "650000197210064188", "name": "蔡刚", "age": 26, "address": "四方台区", "status": "online", "email": "r.ifypqovuzl@rcvfhg.ir", "createdTime": 1500078008 }, { "id": "450000199109254165", "name": "蔡明", "age": 22, "address": "其它区", "status": "online", "email": "z.rbq@uqadfyx.ee", "createdTime": 1260078008 }, { "id": "440000198912192761", "name": "曹明", "age": 25, "address": "其它区", "status": "online", "email": "h.mkmqo@dcj.ee", "createdTime": 1260078008 }, { "id": "310000198807038763", "name": "侯静", "age": 21, "address": "莱城区", "status": "offline", "email": "u.xlkda@ckoicbhk.br", "createdTime": 1560078008 }, { "id": "310000198406163029", "name": "谭涛", "age": 29, "address": "闸北区", "status": "offline", "email": "r.wyr@hmqqlafes.no", "createdTime": 1500078008 }, { "id": "220000199605161598", "name": "罗秀兰", "age": 27, "address": "其它区", "status": "online", "email": "d.eggqvlbol@crqodjvdys.nu", "createdTime": 1560078008 }, { "id": "37000019810301051X", "name": "黎敏", "age": 27, "address": "其它区", "status": "online", "email": "s.unumfugq@qgeufl.om", "createdTime": 1560078008 }, { "id": "440000201411267619", "name": "黄强", "age": 24, "address": "合浦县", "status": "offline", "email": "q.ufollx@kdvgtb.al", "createdTime": 1560218008 }, { "id": "440000200504038626", "name": "叶艳", "age": 25, "address": "大渡口区", "status": "offline", "email": "h.trtiurcut@vnypp.sm", "createdTime": 1560078008 } ] that.tableData.isMulti = true that.tableData.pageInfo = { page: 1, size: 10, total: 135 } that.loading = false }, 500) // 模拟请求延时 } } } </script>
复制代码

demo写完就发现,不少功能都么得啊,功能非常单一,发现问题:

  • 时间那一列,后端不必定传递过来就是能够直接展现的数据,若是传过来的是个时间戳呢?这时候就须要前端来作一下格式化处理
  • 状态那一列,单纯的文字并不显眼,不少时候须要一个状态标签或者其余样式来展现
  • 我还遇到不少次交互设计要求点击列表中姓名(不必定是姓名,就是某一项点击能进入详情页面)进入该用户的详情页
  • 若是列表内有操做项呢?操做项该如何配置传递到组件内部?

有问题了,一个一个来0.0

第一个:时间那一项须要格式化,在表格中这一列都须要按照一样的方法进行格式化处理,那么咱们能够把配置信息放在表头中,而后再表格组件中解析处理,定义formatFn:

{
  prop: 'createdTime',
  label: '添加时间',
  minWidth: '128',
  formatFn: 'timeFormat'
}
复制代码

组件内添加formatFn判断

<template slot-scope="scope">
  <span class="default-cell" v-if="!item.formatFn || item.formatFn === ''" :title="scope.row[item.prop]" >{{scope.row[item.prop]}}</span>
  <span v-else :class="item.formatFn" :title="formatFunc(item.formatFn, scope.row[item.prop], scope.row)" >{{formatFunc(item.formatFn, scope.row[item.prop], scope.row)}}</span>
</template>
复制代码

添加utils类,编写格式化数据方法,并注册全局

// 格式化方法文件(format.js)(记得要在main.js注册啊)
export default {
  install (Vue, options) {
    Vue.prototype.formatFunc = (fnName = 'default', data = '', row = {}) => {
      const fnMap = {
        default: data => data,
        /** * 时间戳转换时间 * 接受两个参数须要格式化的数据 * 为防止某些格式化的规则须要表格当前行其余数据信息扩展传参row(可不传) */
        timeFormat: (data, row) => {
          return unixToTime(data) // unixToTime是我书写的一个时间戳转时间的方法,若是你项目有引用其余相似方法插件,在这里返回格式化后的数据就能够
        }
      }
      return fnMap[fnName](data, row)
    }
  }
}
复制代码

这样若是有其余格式化规则的也能够经过自定义格式化方法,而后再表头中定义须要调用的方法就能够了,同时这个格式化方法不仅是能够用在表格中,其余任何你想要进行格式化的地方均可以在这个文件中定义,而后直接使用就能够了,而不用再引入方法再使用,是否是很方便(づ ̄3 ̄)づ

看到这里应该就明白这个table组件的核心其实仍是这个格式化方法的使用

继续第二个问题,状态那一列,须要的不仅是数据的变化,更是须要将相应的状态数据转换成对应的标签,这就须要扩展一下table组件,添加新的判断逻辑

{
    prop: 'status',
    label: '帐号状态',
    minWidth: '100',
    formatFn: 'formatAccountStatus',
    formatType: 'dom'
}
复制代码
<template slot-scope="scope">
  <!--不须要处理-->
  <span class="default-cell" v-if="!item.formatFn || item.formatFn === ''" :title="scope.row[item.prop]" >{{scope.row[item.prop]}}</span>
  <!--须要处理为标签-->
  <span v-else-if="item.formatType === 'dom'" :class="item.formatFn" v-html="formatFunc(item.formatFn, scope.row[item.prop], scope.row)" ></span>
  <!--单纯的数据处理-->
  <span v-else :class="item.formatFn" :title="formatFunc(item.formatFn, scope.row[item.prop], scope.row)" >{{formatFunc(item.formatFn, scope.row[item.prop], scope.row)}}</span>
</template>
复制代码

我本意是想着format成element-ui的标签,如

可是在写format方法的时候发现直接返回el-tag标签,而后通过v-html解析为html标签可是后面发现并不能按照预期的那样解析成element的标签,思考一番没有发现什么好的方法,没办法只能本身返回原生标签,定义class而后本身书写样式修改成标签的样子

定义状态关系表,添加format方法

const accountStatusMaps = {
  status: {
    online: '在线',
    offline: '离线'
  },
  type: {
    online: 'success',
    offline: 'warning'
  }
}
// 用户帐号状态转标签
formatAccountStatus: (data, row) => {
  return `<span class="status-tag ${accountStatusMaps.type[data]}">${accountStatusMaps.status[data]}</span>`
}
复制代码

这样通常的样式format是能够知足了,可是一些比较复杂的需求本身手写样式就比较不方便了,可是我本身一时也没想到好的解决办法,再加上这样比较难搞的需求比较少,因此就一直放着了(可能这就是我技术成长有限的缘由吧0.0),各位看官若是有什么好的建议方法的话欢迎来提啊

继续第三项,这个需求在后台管理平台很容易遇到,点击名称进入详情,原本我仍是想使用format,format出一个a标签,而后href是想要跳往的地址,可是功能虽然是实现了,可是使用a标签有个很大问题就是页面跳出感太强,只能放弃这个方法,后来没什么好的办法,就想着在表头上作文章,新定义一种formatType => 'link',可选参数linkUrl定义跳转连接,修改table组件的template

<span v-if="item.formatType === 'link'" :class="item.formatClass || 'to-detail-link'" :title="scope.row[item.prop]" @click="toLink(item.linkUrl, scope.row)" >{{scope.row[item.prop]}}</span>
复制代码
toLink (url, row) {
  if (url) {
    this.$router.push(`${url}/${row.id}`)
  } else {
    this.$router.push(this.$route.path + '/detail/' + row.id)
  }
}
复制代码
.to-detail-link {
  color: #1c92ff;
  cursor: pointer;
  &:hover {
    color: #66b1ff;
  }
}
复制代码

需求知足了,可是只是个妥协之计,该怎么在format返回的标签字符串上面绑定方法呢,若有想法,不胜感激,解决了这个问题能让这组件功能提升一大步,由于这个详情功能还算通用,能够在组件兼容,可是若是是其余点击方法呢?总不能一点点的都兼容,这样就失去了封装组件的意义,由于兼容是兼容不完的。

继续继续,操做项在后台管理平台是不可缺乏的,主要的问题就在于怎么传递、抛出点击方法,我这边是这么实现的

先修改template

<span class="table-operation" v-if="item.prop === 'operation' && scope.row.hasOwnProperty('operation')">
    <span class="text-btn" v-for="(item, index) in scope.row.operation" :class="item.type" :key="index" @click="toEmitFunc(item.event, scope.row)" >{{item.text}}</span>
</span>
复制代码

设置操做项数据信息

computed: {
    operateConfig () {
      return {
        optType: {
          toEdit: {
            event: 'toEdit', // 操做按钮调用的方法
            text: '编辑', // 操做按钮展现的文案
            type: 'primary' // 操做按钮展现的样式
          },
          toDel: {
            event: 'toDel',
            text: '删除',
            type: 'danger'
          }
        },
        optFunc: function (row) {
        // 在线状态用户不能删除
          if (row.status === 'offline') {
            return ['toEdit', 'toDel']
          } else {
            return ['toEdit']
          }
        }
      }
    }
}
复制代码

把一些通用的属性、方法抽离出来放在tableMixins里面,减小每次调用的书写

// tableMixins.js
// 表格方法的mixins
export default {
  data() {
    return {
      // 表格数据,具体参考接口数据
      tableData: {
        thead: [],
        tbody: [],
        isMulti: false,
        pageInfo: { page: 1, size: 10, total: 0 }
      },
      // 表格是否处于loading状态
      loading: true,
      // 多选,已选中数据
      selection: [],
      // 查询条件,包括排序、搜索以及筛选
      searchCondition: {}
    }
  },
  mounted: function () { },
  methods: {
    // 多选事件, 返回选中的行及每行的当前状态
    selectionChange(value) {
      this.selection = value
    },
    接口请求到数据后将数据传入这个方法进行thead、tbody、pageInfo等信息的赋值
    afterListSet(res) {
      let formData = this.setOperation(res)
      if (formData.thead) {
        this.tableData.thead = JSON.parse(JSON.stringify(formData.thead))
      }
      this.tableData.tbody = formData.tbody
      if (formData.pageInfo) {
        this.tableData.pageInfo = JSON.parse(JSON.stringify(formData.pageInfo))
      }
      formData.isMulti && (this.tableData.isMulti = formData.isMulti)
      let query = JSON.parse(JSON.stringify(this.$route.query))
      this.$router.replace({
        query: Object.assign(query, { page: this.tableData.pageInfo.page })
      })
      this.loading = false
    },
    // 遍历原始数据,塞入前端定义好的操做项方法序列,设置操做项
    setOperation(res) {
      let that = this
      let tdata = JSON.parse(JSON.stringify(res))
      if (that.operateConfig && that.operateConfig.optFunc) {
        for (let i in tdata.tbody) {
          let temp = that.operateConfig.optFunc(tdata.tbody[i])
          let operation = []
          for (let j in temp) {
            operation.push(that.operateConfig.optType[temp[j]])
          }
          that.$set(tdata.tbody[i], 'operation', operation)
        }
      }
      return tdata
    }
  }
}
复制代码

这样一套组合拳下来,管理平台经常使用的表格功能基本都实现了,贴一下成果图(样式没怎么写,这个就根据本身的项目风格进行调整吧)

我把这个组件的源码上传到了个人GitHub上了,同时写的有demo,有兴趣的朋友能够clone到本地琢磨琢磨,同时欢迎提出代码的不足或者bug,不胜感激0.0,贴上 源码地址

思考

组件写完了,文章也水完了,反思一些不足,一个是怎么渲染出UI组件的标签,另外一个是怎么在format出来的标签上面绑定方法,诚然组件开发过程当中有不少妥协,这些妥协就是自身水平或者视野没能达到致使的恶果,把组件抛出来就是为了集思广益,这个文章对你有帮助,能帮助你提升那我会很高兴,若是你帮忙解决了我遇到的困难,帮助到个人提升那我会更加高兴,我为的不就是这个嘛,因此欢迎你们帮忙想解决方案

固然组件开发过程当中我仍是作了一些很人性化的设计,一个是提供了多冲筛选条件同时做用的方法,就是维护一个searchCondition,每次筛选起做用就添加到searchCondition中,而后把searchCondition传递给数据查询方法,这个方法能够在demo中尝试,控制台会有输出,另外一个小功能就不得不吐槽一下不少开源组件的一个反人类设计,富文本编辑器不少人都用过吧,会有一个menu的配置项,若是这个配置项你什么也不穿他就默认展现所有的菜单项,可是若是你不想要其中一个菜单项,你在menu的配置中把这一项置为false.......而后全部的菜单全没了,我看过一些源码是直接使用传递进去的menu覆盖默认的menu配置项,那么我只是想取消其中一项,我就得把所有的菜单项所有配置一遍且所有置为true,这...不坑爹呢嘛,因此我在向组件内部传参的时候添加了一个方法,而是使用解构赋值,维护一个computed,代码以下:

computed: {
    propConfig () {
      // defaultConfig是默认配置项,tableConfig是父组件传递进来的配置项
      return { ...this.defaultConfig, ...this.tableConfig }
    }
}
复制代码

这样默认配置项只会在父组件某一项有修改的时候进行变动,其余不变

就这些吧,但愿会对你们有所帮助

相关文章
相关标签/搜索