后台系统table组件的封装和细节实现,提高开发效率

1,679 阅读4分钟

前不久针对公司的后台系统,开发了一套后台系统的组件库。对于table组件的实现思路和细节想分享一下。如果大家有其它的思路和想法,可以评论区留言讨论。

组件基于 Element UI

组件传递属性和事件方法

在开始编写组件之前我们要先思考一下,el-table 组件可以传递很多属性和事件方法,我们不可能把它们都一个个罗列到组件中,那有什么办法可以解决这个问题呢?这就引申出vue中两个属性 $attrs$listeners,它们在封装高级别组件时非常有用。我们先来了解一下这两个属性:

  • $listeners:包含了父作用域中的(不含 .native 修饰器的)v-on 事件监听器,可以通过

    v-on="$listeners" 传入内部组件

  • $attrs: 包含了父作用域中不作为prop被识别和获取的attribute绑定(classstyle 除外),并且可以通过 v-bind="$attrs" 传入内部组件。通常会和 inheritAttrs 一起使用。

    inheritAttrs 属性默认值为 true$attrs 中的属性会被作为普通的属性回退给子组件的根元素。

    // 父组件
    <son name="张三" :age="18" say="Hello World"></son>
    
    // 子组件
    export default {
      props: {
        name: String,
      },
      created () {
        console.log(this.$attrs)
      }
    }
    
    

    当把 inheritAttrs 设置为 false 时,根元素上就不会绑定 agesay 属性了。

组件的实现

column 需要包含的属性

因为 el-table 被封装起来,所以标题列就需要变成数组columns传递到组件内部进行遍历渲染了。

column 中包含的属性可以分成 原始属性自定义属性

  • 原始属性就是 el-table-column 包含的属性,比如:prop、label、width 等。这些原始属性可以放在名为 attrs 的对象中。

    attrs: {
      prop: '',
      label: '',
      width: '',
      'render-header': function(){}
    }
    
  • 自定义属性包含的情况:

    • 标题列可以通过 slot 设置自定义展示内容,所以要设置一个 slot:'slotName' 属性
    • 标题列可能存在子项,所以要设置一个 children:[] 属性
    • 嵌套的子项也包含通过 slot 设置的自定义展示内容,所以要设置一个 slotChildren: ['slotName', 'slotName'] (ps:之后会解释如此设置的原因)
    • 通过 slot='header' 自定义表头内容,需要设置一个 slotHeader=true 属性

    所以标题列包含的属性有:

    columns: [
      {
        attrs: {},
        slot: slotName,
        children: [],
        slotChildren: [slotName],
        slotHeader: true
      }
    ]
    

render渲染函数登场

因为 column 存在子项的情况,使用 template 模板无法渲染子项,而 render 渲染函数可以发挥JavaScript的编程能力,可以很好的代替模板形式。不熟悉 render 渲染函数的,可以到官方文档了解一下使用教程。

话不多说直接上代码:

const RenderTableColumn = {
  props: {
    column: {
      type: Object,
    }
  },
  render(createElement) {
    // createElement函数的第二个参数是模板中 attribute 对应的数据对象,包含一个scopedSlots属性
    // createScopedSlots方法会生成这个属性的值
    const createScopedSlots = (slot, slotHeader) => {
      let scopedSlots = {}
      // 自定义header存在
      // 在element-ui中的el-table-column组件上,可以设置名为header的slot
      // 所以相应的数据对象中属性名要为header
      if (slotHeader) {
        Object.assign(scopedSlots, {
          // $scopedSlots.header是对应渲染时的v-slot:header
          header: scope => this.$scopedSlots.header(scope)
        })
      }
      // 自定义内容slot存在
      // 在element-ui中的el-table-column组件上,可以设置非具名slot
      // 解析时非具名的slot对应的名称是default,所以内部属性名就是default
      if (slot) {
        Object.assign(scopedSlots, {
          default: scope => {
            // 因为在调用组件时可能传入多个具名插槽,所以要使用动态属性名
            if (this.$scopedSlots[slot]) {
              return this.$scopedSlots[slot](scope)
            }
          }
        })
      }
      return { scopedSlots }
    }
    // 生成最终column组件的方法
    const renderColumn = column => {
      // 获取属性
      const { attrs, slot, slotHeader, children } = column
      // 保存在createElement函数第二个对象参数中,经测试用props和attrs都可以
      let props = { props: { ...attrs } } // attrs: {...attrs}
      // 获取自定义slot
      Object.assign(props, createScopedSlots(slot, slotHeader))
      // 递归遍历嵌套表头
      const hasChildren = Array.isArray(children) && children.length
      // nodes是子级虚拟节点数组
      const nodes = hasChildren ? children.map(col => {
        return renderColumn(col)
      }) : []
      
      return createElement(TableColumn, props, nodes)
     }
     return renderColumn(this.column)
   }
}

CustomTable组件的实现

先上 CustomTable 组件代码(如果大家要使用的话,可以在此基础上添加自己想要的功能):

<template>
  <div class="CustomTable">
    <el-table v-bind="$attrs" v-on="$listeners" ref="CustomTable">
      <template v-for="(column, index) in columns">
        <render-table-column
        v-if="column.slotHeader || column.slot || column.slotChildren"
        :key="column.attrs.prop"
        :column="column">
        <!-- 自定义header -->
          <template
          v-if="column.slotHeader"
          v-slot:header="scope">
            <slot :name="column.attrs.prop + 'Header'" :scope="{...scope, index}" />
          </template>
          <!-- 设置slot属性 -->
          <template
          v-if="column.slot"
          v-slot:[column.slot]="scope">
            <slot :name="column.slot" :scope="{...scope, index}">
              <!-- 没有传slot时的默认值 -->
              {{scope.row[column.attrs.prop]}}
            </slot>
          </template>
          <!-- 子表头有自定义slot -->
          <template
          v-if="column.slotChildren && column.slotChildren.length"
          v-for="name in column.slotChildren"
          v-slot:[name]="scope">
            <slot :name="name" :scope="{...scope, index}"></slot>
          </template>
        </render-table-column>
        <render-table-column
        v-else
        :column="column"
        :key="column.attrs.prop">
        </render-table-column>
      </template>
    </el-table>
  </div>
</template>

<script>
import { Table } from 'element-ui'

export default {
  name: 'CustomTable',
  components: {
    ElTable: Table,
    RenderTableColumn
  },
  inheritAttrs: false,
  props: {
    // 传入属性同el-table-column组件属性
    columns: {
      type: Array,
      required: true,
      default: () => {
        return []
      }
    },
    tableRef: {
      type: Object
    },
  },
  mounted () {
    this.$emit('update:tableRef', this.$refs.CustomTable)
  }
}
</script>

Ps: 对于 el-table 的ref可以在使用组件时,通过 :tableRef.sync="tableRef" 获取,然后通过 this.tableRef 调用 Table Methods里的方法。

组件的使用方法如下:

<template> 
  <custom-table
  :data="tableData"
  :columns="columns"
  :tableRef.sync="tableRef">
    <template v-slot:name="{scope}">
      <span style="color:#ff8f0a">{{scope.row.detail}}</span>
    </template>
    <template v-slot:city="{scope}">
      <el-tag>{{scope.row.city}}</el-tag>
    </template>
  </custom-table>
</template>

<script>
export default {
  component: {
    CustomTable
  },
  data () {
    return {
      tableRef: null,
      tableData: [
        {
          date: '2016-05-03',
          name: '张三',
          province: 'XXX省',
          city: 'XXX市',
          detail: 'XXX区',
          address: 'XXX省XXX市XXX区'
        },
      ],
      columns: [
        {
          attrs: {prop: "date", label: "日期", width: "100"},
          slotHeader: true
        },
        {
          attrs: {prop: "name", label: "姓名", width: "80"},
          slot: 'name'
        },
        {
          attrs: {prop: "address", label: "地址", width: '260'},
          // 对应着子项中的slot属性
          slotChildren: ['city'],
          children: [
            {attrs: {prop: "province", label: "省份", width: "70"}},
            {
              attrs: {prop: "city", label: "城市", width: "80"},
              slot: 'city'
            }
          ]
        }
      ],
    }
  }
}
</script>

生成的结果为:

为什么子项有自定义内容时设置slotChildren

先看一下上面使用组件时的代码,所有的 **slot **插槽都是在 CustomTable 的作用域下定义的,了解了它以后我们往下看不同的情况。

  • 不设置 slotChildren 属性:

    <template
    v-if="column.slotChildren && column.slotChildren.length"
    v-for="name in column.slotChildren"
    v-slot:[name]="scope">
      <slot :name="name" :scope="{...scope, index}"></slot>
    </template>
    

    上面这段代码不会执行。

    <template v-slot:city="{scope}">
      <el-tag>{{scope.row.city}}</el-tag>
    </template>
    

    v-slot=city 插槽是在address这个列的作用域中,在渲染 address 列时不存在 slotChildren 属性,那 city 的插槽会被忽略掉不会被渲染。在列循环渲染到 city 时,因为 city 的插槽没有被添加到 $scopedSlots 属性中取不到值,所以 city 列在页面中是没有数据的。

  • 设置 slotChildren 属性:

    如果设置了 slotChildren 属性,那么上面 v-if 条件成立,city 插槽就会被渲染并添加到 $scopedSlots 属性中。在列循环渲染到 city 时,就可以在 $scopedSlots 属性中找到对应的slot。

Ps:在子项中使用 slotHeader: true 属性自定义头部是无效的,目前还没有想到解决办法。如果大家有解决的办法,欢迎指教。