🎉 已支持 Vue3.0 | 实现可配置化的 element-ui 表格: hoc-el-table

3,555 阅读9分钟

前奏

每次在实现后台功能时,几乎都会有一个数据列表作为该功能的入口页。

一般情况下,在编写这个数据列表页时,都需要写一堆 <el-table-column/>, 复杂一些的,还会用到 slot 去自定义列的内容。

这么做看似问题不大,但其实我们可以发现,当你的功能越来越复杂,或者类似这样的数据列表越来越多时,难免会出现这样或那样的问题。

本文主要讲解如何用配置化的方式去渲染出简单的 el-table,以及思考如何用更抽象的思维去分离组件。

篇幅文字较多,请耐心阅读 ~

📦 仓库地址:hoc-el-table,欢迎 ✨ star ✨ ~~

🎊 效果演示:Demo

🎉 更新

2022年01月01日,现已支持 Vue 3.0 🎉 🎉 🎉

如果有好的想法也可以留言或发 issue 提问啦 ~~

💭本文首发掘金: 实现可配置化的 element-ui 表格: hoc-el-table

场景 🎑

开始之前,先看几个场景,再慢慢开始想如何解决期间所遇到的问题。

场景一

开发前:

🐶:需要一个列表页展示本次上线的内容,原型发群里了

🐒:拿到原型开始研究,完后立刻在项目里找了一个之前写过的列表页,复制之,然后充当此次功能的页面,开始了编写...

场景二

功能上线后,又提了几个需求:

🐶:第二列的宽度增大下;把第三、四列的内容合成一列展示

🐒:找到对应 <el-table-column/> 修改其 width 值;删掉第四列的代码,同时更改第三列为 slot 展示方式,然后填充原本三和四列的内容

场景三

功能再次上线后,又双叒提了几个需求:

🐶:第二列的内容需要根据这一条数据里面的xxx字段的值动态的去展示 内容1内容2

🐒:找到第二列对应的 <el-table-column/> 先更改成 slot的展示方式,其次在里面通过 v-ifcomputed 等逻辑去区分 内容1内容2 的展示

场景四

功能再次上线后,又双叒叕提了几个需求:

🐶 最后一列加个按钮,每点击一次就会禁用/解除禁用这条数据,并且对应按钮的文字和颜色也要随着禁用的状态做出相应的反馈

🐒:找到最后一列,依然通过 slot 的方式去添加两个按钮(或者一个),用 v-if 的方式去控制它们的展示,用 :class 或者 style 去控制按钮的颜色

...

问题发现 🔎

从刚才的场景中,我们可以发现以下几个问题:

  • 产品老是改需求(逃
  • 每次都要写一堆 <el-table-column/>slot 代码,不能忍受这样没效率的事情,且违背了 DRY 原则
  • 每当有一列的内容需要调整时,就得手动定位并修改对应的 <el-table-column/>slot,有可能一不小心改坏其他地方,那么此次修改就变成侵入式的了
  • 当需要展示复杂内容时,往往需要在 slot 里面写,而且之后再添加新东西的话 slot 就会变得越来越臃肿;

思考 💭

问题有了,那么我们就可以思考:

  • 能否不使用 <el-table-column/>slot 代码,而是通过配置化的方式来达到生成表格的目的呢?
  • 若表格列对应的键值需要做一些处理再展示,该怎么做呢?
  • 表格列能否支持任意的复杂数据展示呢?
  • 一般情况下最后一列会有一些操作的按钮,那么这些按钮和其对应的事件能否灵活的配置出来呢?

开始设计 🎈

有了前面的思考,那么该如何设计表格的配置项呢?

我们可以按照下面的步骤这样来做:

当然,如果你有更好的思路,欢迎评论区留言 ~

  1. 首先实现最简单的配置:仅提供 labelprop 这两个列属性 attributes,就可以渲染出列表
  2. 支持绑定其他的 attributes
  3. 支持渲染对默认 prop 值做一些微处理的场景,其实就相当于支持渲染任意字符串了
  4. 支持渲染任意组件,针对渲染复杂数据的场景
  5. 支持渲染像最后一列那样的操作按钮
  6. 支持 pagination 分页

实现 💡

我们将实现分成两块来做:

  • 配置项的表示
  • 通过配置项生成表格的逻辑(具体实现)

配置项的实现

通常情况下 配置项是由一串 json 构成的,而 table 的每一列又肯定是通过遍历渲染出来的,即配置项大致长这样:

[
  { /* 第一列 */ },
  { /* 第二列 */ },
  { /* 第三列 */ },
  { /*  ...  */ },
]

了解了大概的 json 结构,我们就可以开始了。

列属性

对于列属性的表示,先怎么简单怎么来

只需提供 labelprop就可以了,像下面这样:

[
  {
    label: '编号',
    prop: 'id'
  },
  {
    label: '姓名',
    prop: 'name'
  },
  {
    label: '生日',
    prop: 'birth'
  },
  {
    label: '评级',
    prop: 'rate'
  }
  //...
]

TODO: 我们不可能挨个去绑定,所以需要用 v-bind 来做

那么我想添加其他的列属性怎么办?

为了便于今后的维护,可以将他们集中起来管理,

所以这里“削微”改下格式,都放到了 attrs 里:

[
  {
    attrs: {
      label: '编号',
      prop: 'id',
      width: 100,
      fixed: true
    }
  },
  {
    attrs: {
      label: '姓名',
      prop: 'name',
      width: 150,
      align: 'center'
    }
  },
  {
    attrs: {
      label: '生日',
      prop: 'birth',
      align: 'center'
    }
  },
  {
    attrs: {
      label: '评级',
      prop: 'rate'
    }
  }
  //...
]

render

假如说,接口数据里返回的生日字段是这种格式的:

2000-01-12

但我们想看到的是这种:

2000年01月12日

也就是说需要在展示的时候做一些数据处理。

我们希望通过一个 render 函数来表达这种需求:

[
  {
    attrs: {
      label: '编号',
      prop: 'id',
      width: 100,
      fixed: true
    }
  },
  {
    attrs: {
      label: '姓名',
      prop: 'name',
      width: 150,
      align: 'center'
    }
  },
  {
    attrs: {
      label: '生日',
      prop: 'birth',
      align: 'center'
    },
    render (birth) {
      // 为方便展示,这里假装引入了日期转换库 moment.js
      return moment(birth).format('YYYY年MM月DD日')
    }
  },
  {
    attrs: {
      label: '评级',
      prop: 'rate'
    }
  }
  //...
]

有了 render 函数,我们就可以随意的对 prop 数据做一些处理再进行展示了

renderComponent

render 只能展示简单的字符串,对于复杂的情况,如:文本框、下拉框,表单、甚至是一个响应用户操作的模块,render 是做不到的。

于是就有了 renderComponent 函数 ,顾名思义,我们可以把复杂的逻辑抽到组件里,再通过函数的返回值去渲染

函数的返回值为一个数组,支持渲染多个组件

[
  {
    attrs: {
      label: '编号',
      prop: 'id',
      width: 100,
      fixed: true
    }
  },
  {
    attrs: {
      label: '姓名',
      prop: 'name',
      width: 150,
      align: 'center'
    }
  },
  {
    attrs: {
      label: '生日',
      prop: 'birth',
      align: 'center'
    },
    render (birth) {
      // 为方便展示,这里假装引入了日期转换库 moment.js
      return moment(birth).format('YYYY年MM月DD日')
    }
  },
  {
    attrs: {
      label: '评级',
      prop: 'rate'
    },
    renderComponent (rate) {
      return [
        { name: 'el-rate', data: rate },
        { name: 'componentA', data: this.xxx }
      ]
    }
  }
  //...
]

值得注意的是,renderComponent绑定 data 的方式为 v-model,也就意味着渲染的组件内部默认会用 value 接收 props

当 data 为非引用类型时的场景需要注意,详见这个 issues

renderHTML

那么最后一列的操作按钮该如何渲染呢,到这有人就会说了:renderComponent 不是可以渲染任何组件么,把那些按钮抽成组件再渲染出来不就可以了么。

其实这样做是没问题的,但是编写按钮是不是也得写一堆 <el-button> ,略微有点麻烦对吧?

于是乎我们又加了个针对这种场景的 render 方法: renderHTML,其返回值也是个数组

[
  {
    attrs: {
      label: '编号',
      prop: 'id',
      width: 100,
      fixed: true
    }
  },
  {
    attrs: {
      label: '姓名',
      prop: 'name',
      width: 150,
      align: 'center'
    }
  },
  {
    attrs: {
      label: '生日',
      prop: 'birth',
      align: 'center'
    },
    render (birth) {
      // 为方便展示,这里假装引入了日期转换库 moment.js
      return moment(birth).format('YYYY年MM月DD日')
    }
  },
  {
    attrs: {
      label: '评级',
      prop: 'rate'
    },
    renderComponent (rate) {
      return [
        { name: 'el-rate', data: rate },
        { name: 'componentA', data: this.xxx }
      ]
    }
  },
  {
    attrs: {
      label: '操作'
    },
    renderHTML (row) {
      return [
        {
          attrs: {
            label: '查看',
            type: 'text',
            size: 'medium'
          },
          el: 'button',
          click () {
            this.$message(JSON.stringify(row))
          }
        },
        {
          attrs: {
            label: '编辑',
            type: 'text',
            size: 'medium'
          },
          el: 'button',
          click () {
            this.$router.push({ path: `/xxxxxx/${row.id}/edit` })
          }
        },
        // 对于文章最前面说的「场景四」的写法,可以用三目运算符来表示
        !row.isForbid ? {
          attrs: {
            label: '禁用',
            type: 'text',
            size: 'medium'
          },
          el: 'button',
          click () {
            this.setForbid(row)
          }
        } : {
          attrs: {
            label: '解除禁用',
            type: 'text',
            size: 'medium',
            style: {
              color: '#e6a23c'
            }
          },
          el: 'button',
          click () {
            this.setForbid(row)
          }
        }
      ]
    }
  }
  //...
]

由于当初设计的原因,renderHTML 只满足了 el-button ,后续会跟进需求变化再做进一步调整。

小结

至此,对于配置项的表示方法我们已了解,主要由一些 attrs 属性和三个函数 render, renderComponentrenderHTML 去表示 table 的渲染结构。

template 的实现

配置项有了,那么如何通过配置项生成表格呢?

整体概览

我们希望最终用户使用的时候大概是这个样子的:

<hoc-el-table
  :source="sourceList.data"
  :pagination="sourceList.pagination"
  :config="config"
  :loading="loading"
  @getList="getList"
>
</hoc-el-table>

解释下这几个参数:

  • source 数据源
  • pagination 分页的参数
  • config 配置项
  • loading 加载动画
  • getList 获取数据源的回调方法

所以 hoc-el-table 可以先这么实现:

<div class="hoc-el-table-container">
  <el-table
    :data="source"
    v-loading="loading"
    v-bind="$attrs"
  >
  </el-table>
  
  <el-row
    justify="end"
    type="flex"
  >
    <el-pagination
      layout="total, sizes, prev, pager, next ,jumper"
      :current-page="getPagination.currentPage"
      :page-size="getPagination.pageSize"
      :total="getPagination.total"
      @size-change="handleSizeChange"
      @current-change="handlePageChange"
    />
  </el-row>
  
</div>
export default {
  computed: {
    getPagination () {
      const params = {
        currentPage: 1,
        pageSize: 10,
        total: 0
      }
      return Object.assign({}, params, this.pagination)
    }
  },
  methods: {
    handlePageChange (val) {
      this.$emit('getList', { page: val })
    },
    handleSizeChange (val) {
      this.$emit('getList', { pageSize: val })
    }
  }
}

配置项的使用

现在我们已经用了 sourcepaginationloadinggetList 这几个 props,还差 config 配置项没有用到。

之前提过 config 是一个数组,所以需要将每一列遍历出来,像下面这样:

<el-table-column
  v-for="(item, index) in config"
  :key="index"
>
  <template v-slot="scope"></template>
</el-table-column>

el-table-column 上绑定的属性我们用 v-bind 来实现:

<el-table-column
  v-for="(item, index) in config"
  :key="index"
  v-bind="getAttrsValue(item)"
>
  <template v-slot="scope"></template>
</el-table-column>

getAttrsValue()

由于用了 v-slot 来自定义列的内容,所以这边需要先过滤掉 prop

export default {
  methods: {
    getAttrsValue (item) {
      const { attrs } = item
      const result = {
        ...attrs
      }
      delete result.prop
      return result
    }
  }
}

render 的加载

还记得我们之前说的三个渲染函数么:

  • render
  • renderComponent
  • renderHTML

这里要注意, 只有 render 函数返回值是个字符串,其他两个返回的都是个数组(需要遍历),所以伪代码可以这么写:

<el-table-column
  v-for="(item, index) in config"
  :key="index"
  v-bind="getAttrsValue(item)"
>
  <template v-slot="scope">
    <div v-if="不是 render 方法">
      <div v-if="renderComponent">
        <!-- v-for 遍历返回值  -->
      </div>
      <div v-else-if="renderHTML">
        <!-- v-for 遍历返回值 -->
      </div>
    </div>
    <div
      v-else
    >{{ 直接返回 render 的值 }}</div>
  </template>
</el-table-column>

组件的下沉

看起来刚刚的代码嵌套的 v-ifv-for 貌似有点多,稍微优化下:

<component/> 组件分发 renderComponentrenderHTML

为了降低耦合度,再分别创建用于针对这两个的下沉组件, 同样是伪代码

<el-table-column
  v-for="(item, index) in config"
  :key="index"
  v-bind="getAttrsValue(item)"
>
  <template v-slot="scope">
    <div v-if="不是 render 方法">
      <component
        :cellList="函数的返回值"
        :is="判断来自 renderComponent 还是 renderHTML"
        :row="scope.row"
        :parent="getParent"
      />
    </div>
    <div
      v-else
    >{{ 直接返回 render 的值 }}</div>
  </template>
</el-table-column>

renderComponent 对应的下沉组件:

<template>
  <div>
    <component
      v-for="(item, index) in cellList"
      :is="item.name"
      :key="index"
      :row="row"
      v-model="item.data"
    />
  </div>
</template>

renderHTML 对应的下沉组件:

说下这里为什么用到了渲染函数(此渲染函数彼渲染函数),其实刚开始的时候用的是 template 渲染的

直到有一次需求是让动态的绑定指令,那么普通的 template 目前是无法做到动态的绑定指令的(jsx除外),所以改成了渲染函数的方式

export default {
  render: function (createElement) {
    return createElement('div',
      this.getCellList.map((cellItem) => {
        return createElement(
          this.elementsMapping[cellItem.el],
          {
            on: {
              click: cellItem.click.bind(this.parent, this.row)
            },
            domProps: {
              innerHTML: this.getAttrsValue(cellItem).props.label
            },
            ...this.getAttrsValue(cellItem)
          }
        )
      })
    )
  }
}

可以看看老的实现方式是这样的:

<!-- 已废弃  -->
<template>
 <div>
   <component
     v-for="(item, index) in getCellList"
     :key="index"
     :is="elementsMapping[item.el]"
     v-bind="getAttrsValue(item)"
     @click="item.click.call(parent, row)"
   >{{ item.attrs.label }}</component>
 </div>
</template>

总结

至此,我们的实现基本上就已经完成了,可以满足现有的简单的业务逻辑。而之后的需求一定会越来越复杂,有许多地方值得我们去思考、探索,同时希望能够给阅读完这篇文章的你提供一些思路。

完整的代码可以看下方的仓库,欢迎 ✨ star ✨ ~~

📦 仓库地址:hoc-el-table

🎊 效果演示:Demo

难免有错误和疏漏的地方,还望不吝赐教。