基于element-ui框架封装一个更好用的表格组件

8,478 阅读7分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。

前言

在我们做后台管理系统的时候,其实我们接触最多的组件就是表格组件了,所以表格组件的好用与否,直接关系到我们做后台管理系统的效率了. 那么今天我们讲一下,如何对element-ui的表格组件做一层封装,集成更多的功能进去,让我们可以写更少的代码实现更多并且更稳定的功能。 废话不多说,下面我们就开始吧.

功能

首先我们先整理一下我们需要实现哪一些功能.

  1. 分页功能 结合Pagination组件来实现表格的分页
  2. 表格添加插槽,可以自定义样式、数据到表格里边
  3. 实现多表头
  4. 实现跨页勾选数据
  5. 查询功能 使用[输入框、多选框、单选框、日期框]等组件实现查询交互
  6. 序号累加排序
  7. 更好操作的升降序功能
  8. 实现查询条件展示, 可能表格查询可以根据很多的条件查询,有了查询条件展示,可以一目了然,看到列表是根据哪一些条件查询的结果.
  9. 动态表头实现, 有可能表格有很多的表头,用户一眼很难找到想看的某一些表头. 所以可以需要一个可以实现表头过滤的功能.

基础实现

在实现我们上述讲的这些feture的时候,我们肯定得先把element-ui本身自有的功能给加上。 那么我们就先把这些基础功能给放到我们自己的组件上.

<template>
    <el-table v-bind="$attrs" v-on="$listeners">
        <template v-for="(item) in columns">
          <el-table-column
            :key="item.prop"
            v-bind="item"
            show-overflow-tooltip> 
          </el-table-column>
        </template>
    </el-table>
</template>

<script>
 export default {
  name: 'miniTable',
  props: {
    columns: {
      type: Array,
      default: () => [],
    },
  }
}
</script>

<mini-table border :columns="columns"></mini-table>
columns: [
    { label: '姓名', prop: 'name', align: 'center' },
    { label: '年龄', prop: 'age', align: 'center' },
    { label: '爱好', prop: 'hobby', align: 'center' },
    { label: '学历', prop: 'education', align: 'center' },
    { label: '籍贯', prop: 'nativePlace', align: 'center' },
    { label: '备注', prop: 'remark', align: 'left', width: 200 }
],

image.png 以上就实现了我们在element-ui里边的基础功能了. 以上主要依靠vue提供的$attr$listeners来实现的功能.

listeners包含了父作用域中的(不含 .native 修饰器的) von 事件监听器。它可以通过 von="listeners 包含了父作用域中的 (不含 `.native` 修饰器的) `v-on` 事件监听器。它可以通过 `v-on="listeners" 传入内部组件——在创建更高层次的组件时非常有用 $attrs 包含了父作用域中不作为 prop 被识别 (且获取) 的 attribute 绑定 (class 和 style 除外)。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定 (class 和 style 除外),并且可以通过 v-bind="$attrs"` 传入内部组件——在创建高级别的组件时非常有用。

以上是vue官方文档对这两个属性的介绍. 大致意思就是可以依靠这两个属性,对已有的组件做一层更高级的封装。 让用户可以在用我们的组件的时候,透传属性或者方法到element-ui的组件去. 当然如果有些功能,我们需要在element-ui的事件里边再做一些处理的话,我们可以再对代码做一下改造。这个后边我们会说到。 然后还有一点,由于需要把属性透传给element-ui的组件,为了避免后边会造成一些不必要的冲突,所以后边在我们的组件里边用到的属性,我们会在命名前加上__,来表示是我们自己的组件私有的属性.

集成分页

先上代码

<el-pagination
  class="pagination"
  background
  v-if="hasPagination"
  @size-change="sizeChange"
  @current-change="currentChange"
  :total="pagination.totalRow"
  :current-page="pagination.pageIndex"
  :page-size="pagination.pageSize"
  :page-sizes="pageSizes"
  :layout="layout">
</el-pagination>

// methods
/**
 * 切换分页数量
 * @param { Number } pageSize 页数
 */
sizeChange (pageSize) {
  this.pagination.pageIndex = 1
  this.pagination.pageSize = pageSize
  this.queryData()
},
/**
 * 切换页码
 * @param { Number } pageIndex 页码
 */
currentChange (pageIndex) {
  this.pagination.pageIndex = pageIndex
  this.queryData(true)
},

分页的话,没有什么特别好说的,分页逻辑基本上是和表格耦合在一起的功能。 所以pagination对象直接在组件的data里边定义好,pageSizeslayout接受外部传入。 如果不传递的话,给一个默认的值. 也有情况是不需要页码的。 所以外部还可以传入一个hasPagination,不需要页码的话,传入一个false. 默认true

表格自定义内容

表格里边不一定是纯文本的数据。 有可能需要渲染一个按钮,或者一个图片也可能是一个百分比进度条. 然后到底是什么,在我们这个组件里边是不得而知的。 所以我们需要在组件里,给用户提供一个插槽,让用户可以自定义内容。 保证组件的灵活性. 那么需要怎么做呢? 我们往下看代码实现吧:

// 添加slot
<el-table-column
  :key="item.prop"
  v-bind="item"
  show-overflow-tooltip> 
    <template v-if="item.__slotName" v-slot="scope">
      <slot :name="item.__slotName" :data="scope"></slot>
    </template>
</el-table-column>

// data
columns: [{ label: '头像', prop: 'avatar', align: 'center', __slotName: 'avatar' }]

// 使用miniTable组件
<mini-table size="small" border :columns="columns">
  <template slot="avatar" slot-scope="scope">
    <img slot="avatar" width="40" :src="scope.data.row.avatar" />
  </template>
</mini-table>

实现其实也不难,我们添加一个__slotName属性传递给组件,然后组件里边添加判断,如果传递了__slotName的话,则使用插槽,并传递scope给插槽,插槽的名字就使用传递过来的__slotName. 这样的话,我们在使用组件的时候,就可以定义一个slot="avatar"的插槽,并且拿到组件给的行数据。

image.png 然后,我们就可以写入一条有头像数据的行数据到表格了.

多表头

实际项目中,我们还会遇到多表头的问题. 可能是二级表头,可能有三级表头,可能更多。 为了满足所有情况,我们实现的方式采用组件递归的方式,实现多表头.

<template>
<el-table-column
v-bind="item"
:key="item.prop"
show-overflow-tooltip> 
  <template v-for="obj in item.__children">
    <my-table-column v-if="obj.__children" :item="obj" v-bind="obj" :key="obj.prop"></my-table-column>
    <el-table-column
      v-else
      :key="obj.prop"
      v-bind="obj"
      show-overflow-tooltip> 
      <template v-if="obj.__slotName" v-slot="scope">
        <slot :name="obj.__slotName" :data="scope"></slot>
      </template>
    </el-table-column>
  </template>
</el-table-column>
</template>

<script>
import MyTableColumn from './tableColumn'

export default {
 name: 'MyTableColumn',
 components: {
   MyTableColumn
 },
 props: {
   item: {
     type: Object,
     default: () => {}
   }
 }
}
</script>

上面创建了一个tableColumn文件,如果表头存在__children则递归组件,如果不存在,则是一个二级表头. 我们在使用的时候,添加一个这样的数据测试

{ label: '地址信息',
  prop: 'address', 
  align: 'center',
  __children: [
    {
      label: '省份',
      prop: 'province',
      align: 'center'
    },
    {
      label: '城市',
      prop: 'city',
      align: 'center',
      __children: [
        {
          label: '区',
          prop: 'area',
          align: 'center',
        },
        {
          label: '县',
          prop: 'county',
          align: 'center',
        }
      ]
    }
  ]
}

image.png 最后我们根据我们的json数据描述,得到我们想要的样子.

跨页勾选数据

跨页勾选,其实也是我们比较常见的一个需求了。 用户想要勾选的数据,在第一页的页尾和第二页的页头. 一般情况下,如果勾选了第一页的数据,再点击第二页,由于数据刷新了。 勾选也就没有了. 需要做跨页勾选的话,其实也很简单,element-ui其实提供了这个功能. 主要用到了两个属性.

reserve-selection: 仅对 type=selection 的列有效,类型为 Boolean,为 true 则会在数据更新之后保留之前选中的数据(需指定 row-key

row-key: 行数据的 Key,用来优化 Table 的渲染;在使用 reserve-selection 功能的情况下,该属性是必填的。类型为 String 时,支持多层访问:user.info.id,但不支持 user.info[0].id,此种情况请使用 Function

// 对组件外部暴露一个 isCheckMemory 属性,默认false, 需要跨页,设置成true
props: {
     /**
     * 是否需要跨页勾选
     */
    isCheckMemory: {
      type: Boolean,
      default: false
    },
    /**
     * 表格行数据的唯一键
     */
    idKey: {
      type: String,
      default: 'id'
    }
}
// 给table设置row-key,给type="selection"的tableColumn设置reserve-selection
<template>
    <el-table 
      ref="__table"
      :data="tableData"
      v-bind="$attrs"
      :row-key="idKey"
      v-on="listeners">
      <el-table-column
        v-if="isCheck"
        align="center"
        width="70"
        type="selection"
        :reserve-selection="isCheckMemory"
      >
      </el-table-column>
      ...
    </el-table>
</template>

以上就把跨页勾选给做好了。 然后我们再和之前一样. 去selection-change就能拿到勾选的所有数据了. 这里值得注意的一点是,v-on="listeners",如果组件内部需要对勾选的数据做一些操作的话,我们可以这样写:

  computed:  {
    listeners: function () {
      var vm = this
      return Object.assign(
        {}, 
        this.$listeners, {
          'selection-change': function (val) {
            // dosomething
            vm.selectData = val
            vm.$emit('selection-change', val)
          }
        }
      )
    }
  },

列排序

列排序的话,其实也算是比较常用的功能,因为表格一般都是分页的数据,前端拿不到所有的数据,所以排序大多数情况都是后台进行排序,前端负责给后台一些字段,告诉后台按照什么字段排序,是降序还是升序。

//props 外部传递一个sortArr,告诉组件哪一些字段需要排序
/**
 * 排序
 */
sortArr: {
  type: Array,
  default: () => { return [] }
}

// el-table-column中加入sortable
<el-table-column
  v-else
  :key="item.prop"
  v-bind="item"
  :sortable="sortFun(item.prop) ? 'custom' : false "
  show-overflow-tooltip> 
    <template v-if="item.__slotName" v-slot="scope">
      <slot :name="item.__slotName" :data="scope"></slot>
    </template>
</el-table-column>

// methods
sortFun (prop) {
  if (this.sortArr && this.sortArr.length > 0) {
    return this.sortArr.indexOf(prop) > -1
  } else {
    return false
  }
}

// 监听事件 sort-change
listeners: function () {
  var vm = this
  return Object.assign(
    {}, 
    this.$listeners, {
      'sort-change': function (column) {
        vm.order.sortName = column.prop
        switch (column.order) {
          case 'ascending':
            vm.order.sortOrder = 'asc'
            break
          case 'descending':
            vm.order.sortOrder = 'desc'
            break
          default:
            vm.order.sortOrder = ''
        }
        vm.$emit('sortChange', vm.order)
      }
    }
  )
}

// 用法
// 加入 sortArr 去设置哪一些需要排序
<mini-table size="small" border :sortArr="['age', 'area', 'county']" :columns="columns"></mini-table>

image.png 然后我们就能知道需要根据什么字段去做排序了. 直接把sortName,sortOrder传递给后台重新进行一次查询就ok了.

序号累加

在列表里边我们的需求上是需要对序号进行累加的。 比如第一页显示1-20的序号,如果不做处理的话,且换到第二页,显示的序号还是1-20,我们想要它展示的序号是21-40的话,就需要稍微做一点处理了。

// template 加入 :index="typeIndex"
<el-table-column
    v-if="isIndex"
    show-overflow-tooltip
    align="center"
    :index="typeIndex"
    type="index"
    :fixed="fixed">
      <template slot="header">
        <span>序号</span>
      </template>
</el-table-column>
      
// methods
typeIndex (index) {
  const tabIndex = index + (this.pagination.pageIndex - 1) * this.pagination.pageSize + 1
  return tabIndex
}

这里我们根据页码去做一下处理就可以达到我们想要的预期了.

查询数据

由于有可能表格是没有查询条件的,单就一个表格,所以我们把查询数据的功能放在表格组件里边去做。 之后还会独立做一个查询的组件,查询组件把用户输入的查询条件收集起来,然后传递到表格组件里边来. 查询的话,首先引入axios,我们这边由于没有线上的api做支撑,所以我这边用了mockjs. 首先我们先看一下axios

import axios from 'axios'

const service = axios.create({
  baseURL: '/',
  timeout: 10000
})

export const get = function (url, params) {
  return service.get(url, { params })
}

export const post = function (url, data) {
  return service.post(url, { data })
}

这里根据自己公司后台的情况修改,添加拦截器。 我这边就简单的导出一下getpost方法. mockjs的话, 大家可以去网上找一下资料看看, 我这边就直接贴一下代码,简单的讲一下

import Mock from 'mockjs'

const data = Mock.mock({
  "list|60-400": [
    {
      "id": '@increment(1)',  // 生成累加的id
      "name": "@cname()", // 生成名称
      "age|1-50": 1, // 生成1-50的数字
      "avatar": "@image('40x40', '#50B347', '#FFF', 'Mock.js')", // 生成 40*40的头像
      "hobby": "@ctitle(6)", // 生成6字废文
      "education": "@ctitle(6)", // 生成6字废文
      "nativePlace": "@ctitle(6)", // 生成6字废文
      "province": "@ctitle(6)", // 生成6字废文
      "area": "@ctitle(6)", // 生成6字废文
      "county": "@ctitle(6)", // 生成6字废文
      "remark": "@csentence(20)", // 生成20字废文
    }
  ]
})

// 拦截axios发出的getList接口, 返回上边定义的data数据
Mock.mock(/\/getList/, 'get', (options) => {
   // 获取传递的参数pageindex
   const pagenum = getQuery(options.url,'pageOffset')
   // 获取传递的参数pagesize
   const pagesize = getQuery(options.url,'pageSize')
   // 截取数据的起始位置
   const start = (pagenum-1)*pagesize
   // 截取数据的终点位置
   const end = pagenum*pagesize
   // 计算总页数
   const totalPage = Math.ceil(data.list.length/pagesize)
   // 数据的起始位置:(pageindex-1)*pagesize  数据的结束位置:pageindex*pagesize
   const list = pagenum>totalPage?[]:data.list.slice(start,end)

  return {
    status: 200,
    success: true,
    message: '获取新闻列表成功',
    list: list,
    total: data.list.length
  }
})

// 拿到query接口?和&符号后边的参数
const getQuery = (url,name)=>{
  const index = url.indexOf('?')
  if(index !== -1) {
    const queryStrArr = url.substr(index+1).split('&')
    for(var i=0;i<queryStrArr.length;i++) {
      const itemArr = queryStrArr[i].split('=')
      if(itemArr[0] === name) {
        return itemArr[1]
      }
    }
  }
  return null
}

export default Mock

mockJS的拦截和定义数据我在上边代码的注释简单说明了一下。 那么查询方法的话,如下

    async queryData (isReset) {
      if (this.query && this.query.url) {
        this.loading = true
        this.pagination.pageIndex = isReset ? this.pagination.pageIndex : 1
        let param = Object.assign(
          {},
          this.query.queryParam, {
            [this.pageParam.pageOffset]: this.pagination.pageIndex,
            [this.pageParam.pageSize]: this.pagination.pageSize
          })
        if (this.order.sortName && this.order.sortOrder) {
          param.sortName = this.order.sortName
          param.sortOrder = this.order.sortOrder
        }
        let result = null
        try {
          switch(this.query.method) {
            case 'get':
              result = await get(this.query.url, param)
              break
            case 'post':
              result = await post(this.query.url, param)
              break
            default:
              result = await get(this.query.url, param)
              break
          }
        } catch(e) {
          console.warn(e)
        } finally {
          this.loading = false
          const { data } = result
          if (data && data.success) {
            this.pagination.totalRow = data.total
            this.tableData = data.list
            this.$emit('fetchData', data)
          } else {
            this.$message({
              type: 'warning',
              message: data ? data.message : '查询失败!'
            })
            this.tableData = []
            this.pagination.pageIndex = 1
            this.pagination.totalRow = 0
          }
        }
      }
    }

其中有几处说明一下, 请求接口,为了良好的体验。添加loading,在请求结束后loading设置成false. 还有一点pageIndexpageSize,大家的后台都不一样,所以大家的页码的字段可能也叫的不一样。 我们可以在props里边传入自己后台的页码的字段名称. 然后这边就会对应修改成你要的字段.

/**
 * 分页需要传入后台的字段
 */
pageParam: {
  type: Object,
  default: () => {
    return {
      pageOffset: 'pageOffset',
      pageSize: 'pageSize'
    }
  }
},

然后method支持postget两种请求方式.只需要在用组件的时候,传入即可

    <mini-table :isAutoQuery="true" :query="query" size="small" border :sortArr="['age', 'area', 'county']" :columns="columns">...</mini-table>
  
// data
query: {
    url: '/getList',
    method: 'get',
    queryParam: {}
},

ok, 那么我们看一下最后实现的效果:

13.gif

写在最后

ok, 那么常用的一个表格需要有的一些功能,我们基本上都把它给集成到我们自己写的组件里边了。 使用如果出现一些小问题,我们也只需要在组件内部做一些修改,在项目中使用一段时间后,组件会越来越稳定,并且在稳定的同时,也兼具灵活性。项目做到后边,越能体现我们做这么一个组件的价值。 后边我们会在这个基础上,再加上表单搜索功能和动态表头功能. 大家如果觉得文章最自己有一些用的话,不妨点赞关注一波。另外后边在完整我提到的所有的功能后。我会把代码提交到gitee上给有需要的人克隆下来查看. 那么下次见咯!