再谈table组件:固定表头和表列

前言

本文介绍内容包括:javascript

  • Element UI 实现表头表列固定思考与总结
  • translate3d如何实现表头表列固定

书承上文,在前文【Vue进阶】青铜选手,如何自研一套UI库中介绍了Vue组件库的开发细节,举例实现了button、table等组件的开发。在Ange这个UI库中,我实现了一个内容高可定制的表格组件:可固定表头和表列,内容则自行定义。html

首先要认可,这个table组件实现的功能很简单:java

  • 建立表格展现数据
  • 可固定表头
  • 可固定表列
  • 可实现简易版多级表头

表格组件是UI库里面最为复杂的组件之一,项目中使用表格的场景特别多,咱们很难覆盖全部人的需求,比较常见的就有:git

  • 固定表头
  • 固定表左/右侧列
  • 多级表头
  • 勾选行数据
  • 展开行数据
  • 数据排序

从做用对象来看,这些需求又可归为影响布局(Eg: 固定表头表列)和影响数据(Eg: 勾选数据)两个大类。在Ange UI的table组件中,仅仅实现了影响布局这个类下面的部分功能,该组件不操做数据,甚至具体到使用tr、td标签(以及td里面如何包裹数据)展现数据也是由使用者本身定义的。狠狠点击这里在线查看示例,或者查看代码:github

<ag-table offsetTop="57.5">
    <tr slot="thead">
        <!-- 定义表头列 -->
        <th v-if="isExpand">姓名</th>
        <th v-for="(each, index) in singleTableHead" :key="index">{{ each }}</th>
    </tr>
    <tr v-for="(each, index) in singleTableBody" slot='tbody' :key="`tbody-${index}`">
        <!-- 渲染表体内容 -->
        <td v-if="isExpand">{{ each.name }}</td>
        <td>{{ each.verdict }}</td>
        <td>{{ each.song }}</td>
    </tr>
</ag-table>
复制代码

经过插槽slot指定thead或是tbody。简单就意味着精细和可拓展性强,同时带来的问题就是用户的使用成本高了(好比实现数据选择功能,固然ag-table在不操做源数据的原则下也能拓展出这个功能)。浏览器

谈谈element的固定表头表列

从浏览器中审查Element table组件的渲染效果看,Element实现固定表头表列的方式是:将固定的部分(如表头)和不固定的部分(如表体)拆分放在不一样区域(不一样的div下),设置表体所在区域可滚动便可,而后再经过必定的手段(如阴槽、表格数据备份)去同步不一样区域之间的布局。app

在一篇饿了么专题的文章中,详细阐述了固定表头表列的实现。下面简单总结并整理其中存在的问题。布局

1.1 固定表头的思路

从浏览器中审查table组件的渲染效果看: post

表头和表体分别放在了 两个不一样的div区域el-table__header-wrapper & el-table__body-wrapper,如此表体内容超出容器高度时,会出现滚动条,只在本身区域内滚动,达到了表头固定的效果。这样的实现 致使了两个问题

  • 两个表格宽度不一致:表体所在的区域多出了一条滚动条
  • 两个表格之间的列宽如何保持一致

针对上面的问题,element也作了处理,引用饿了么文中一张图片: 性能

在表头部分增长一个 Gutter元素,虚拟成滚动条去占据必定宽度(图片右上角粉色的竖条),宽度一致的处理则是要求用户使用的时候个 传入每一个列的宽度

这种实现方式有什么缺点呢?

  • 额外维护新增元素(Gutter);
  • 自定义每列宽度增长用户使用成本,理想状况应该能根据文本内容自适应;
  • 表体的滚动条上不去(滚不到表头的顶部),这个让我很捉急;
  • 表头仅是相对于表体的固定,能实现相对于窗口的fixed吗?

1.2 固定表列的思路

实现固定表列相对比较复杂,为实现这个功能,element可谓是付出了“巨大的成本”。在这个左右列固定的渲染效果中:

渲染出了 3份表格el-table__header-wrapper & el-table__body-wrapper 是表体区域, el-table__fixed 是左固定列区域、 el-table__fixed-right 是右固定列区域),每一份表格又有2个table, 一共是6个table;经过设置左右区域绝对定位和宽度实现固定的效果。

这样实现会有什么问题呢?

  • 一份表格数据被渲染成三份,放大了三倍的DOM开销。(这也是element -table在数据量大或者未分页的状况下,页面卡顿,性能下降的根本缘由)
  • 同步鼠标的scroll事件:在一个区域内滚动须要在其余两个区域做同步滚动
  • 额外维护固定列样式和内容(如宽度等)

基于此,Ange UI的table实现考虑用另一种方式去实现,达到了最低的DOM成本

getBoundingClientRect

在介绍固定表头表列实现方法以前,先科普下getBoundingClientRect这个API。

getBoundingClientRect()方法返回元素的大小及其相对视口的位置,它的返回值是一个DOMRect对象。DOMRect对象包含了一组用于描述边框的只读属性:left、right、top、bottom,单位为像素。除了width和height外的属性都是相对于视口的左上角而言的。

以下图:

实现固定表头

在一个table中分别用thead和tbody展现表头表体,以下代码:

<template>
  <div class="ange-table">
    <table ref="middle-table">
      <thead class="thead-middle" :style="theadStyle">
          <slot name="thead" />
      </thead>
      <tbody>
        <slot name="tbody" />
      </tbody>
    </table>
  </div>
</template>
复制代码

监听页面滚动事件,计算table的位移,使用translate3d反向设置thead的y轴位移值,达到固定表头的效果。以下图:

滚动页面滚动条,table由 top1(正值)位置移动到 top2(负值)位置,那么,thead在触碰到 页面顶端时(即top=0),继续移动,thead就要设置成 translate3d(0px, -top2, 0px)。这样,thead就一直处在页面顶端位置了。 在某些场景下,thead达到Header的位置时就应该被fixed了,那们咱们能够设置一个 offsetTop参数,用户自定义偏移值,thead在 top=0 - offsetTop时被fixed。看关键实现代码:

export default {
  data () {
    return: {
      fixed: { // fixed状态
        top: false
      },
      clientRect: { // 位移值
        top: 0
      }
    }
  },
  computed: {
    theadStyle () {
      const { top } = this.clientRect
      return {
        transform: `translate3d(0px, ${this.fixed.top ? -top : 0}px, 1px)`
        }
    }
  },
  watch: {
    'clientRect.top': function (val) {
      // 判断到DOMRect的top值小于0时,开始fixed
      this.fixed.top = val < 0
    }
  },
  mounted () {
    // 监听页面滚动事件,获取table对象的DOMRect属性
    window.addEventListener('scroll', this.scrollHandle, {
      capture: false,
      passive: true
    })
  },
  methods: {
    scrollHandle () {
      const $table = this.$refs.table
      if(!$table) return

      const { top } = $table.getBoundingClientRect()
      this.clientRect.top = Math.floor(top - parseInt(this.offsetTop, 10))
    }
  }
}
复制代码

结合 @前言 部分ag-table的使用示例,在<ag-tbale>中传入一个offsetTop参数,便可实现thead在指定位置的fixed。另因为thead和tbody在同一个table中,不须要维护每一列的宽度,它能够根据内容自适应。查看demo

实现固定表列

固定列的实现须要三个表格(分别固定左列和右列),以下代码:

<template>
  <div class="ange-table">
    <!-- left table -->
    <table v-if="hasLeftTable" ref="leftTable" :style="leftStyle">
      <thead class="thead-left" :style="theadStyle">
          <slot name="leftThead" />
      </thead>
      <tbody>
          <slot name="leftBody" />
      </tbody>
    </table>
    <!-- middle table -->
    <table ref="table" class="table-middle">
      <thead class="thead-middle" :style="theadStyle">
          <slot name="thead" />
      </thead>
      <tbody>
          <slot name="tbody" />
      </tbody>
    </table>
    <!-- right table -->
    <table v-if="hasRightTable" ref="rightTable" :style="rightStyle">
      <thead class="thead-right" :style="theadStyle">
          <slot name="rightThead" />
      </thead>
      <tbody>
          <slot name="rightBody" />
      </tbody>
    </table>
  </div>
</template>
复制代码

table横向滚动时,计算容器的横向滚动距离scrollLeft,使用translate3d反向设置左table的x轴位移值,固定左列;对于右table,先要将其初始位置设置在容器的最右端,横向滚动时再结合scrollLeft设置x轴的位移值。以下图:

初始化时, rightTable的横向位移值: $rightTable.right - $container.rightleftTable就是0;发生横向滚动时, leftTable的横向位移值: scrollLeftrightTable的位移值: 初始位移 - scrollLeft。看关键实现代码:

export default {
  computed: {
    leftStyle () { // 左侧表格位移
      const { left } = this.clientRect
      return {
        transform: `translate3d(${this.fixed.left ? left : 0}px, 0px, 1px)`
      }
    },
    rightStyle () { // 右侧表格位移
      const { right } = this.clientRect
      return {
          transform: `translate3d(${-right}px, 0px, 1px)`
      }
    }
  },
  watch: {
    'clientRect.left': function (val) {
        // 横向滚动距离为正,开始设置fixed
        this.fixed.left = val > 0
      }
  },
  mounted () {
    // 存在由表格时设置其初始位移
    if(this.hasRightTable) {
        const container = this.$refs.container.getBoundingClientRect()
        const rightTable = this.$refs.rightTable.getBoundingClientRect()
        this.clientRect.right = Math.floor(rightTable.right  - container.right)
        // 记录右表格初始位移值
        this.initRight = this.clientRect.right
    }
    // 监听表格容器的滚动事件
    this.$refs.container.addEventListener('scroll', this.scrollXHandle, {
      capture: false,
      passive: true
    })
    
    // ...
  },
  methods: {
    scrollXHandle () {
      // ...
      this.clientRect.left = Math.floor(this.$refs.container.scrollLeft)

      const right = Math.floor(this.initRight - this.$refs.container.scrollLeft)
      this.clientRect.right = right
    }
  }
}
复制代码

按照这个思路实现左右列固定,效果以下(在线查看):

同步Hover效果

最后一步,由于这个表格是由三份table组成,所以当鼠标hover在其中一个table行上时,须要在剩余两个table的对应行中同步hover效果。看关键代码的实现:

export default {
  mounted () {
    if(this.hasLeftTable || this.hasRightTable) {
      // 定义鼠标hover事件
      this.$el.addEventListener('mouseover', this.mouseOver, false)
      this.$el.addEventListener('mouseout', this.mouseLeave, false)
    }
  },
  methods: {
    mouseOver (e) {
      this.hoverClass(e, 'add')
    },
    mouseLeave(e) {
      this.hoverClass(e, 'remove')
    },
    hoverClass(e, type) {
      const tr = e.target.closest('tr')
      if(!tr) {
          return
      }
      const idx = tr.rowIndex // 当前hover行的编号
      const trs = querySelectorAll(`tbody tr:nth-child(${idx})`, this.$el)
      if(trs.length === 0) {
          return
      }
      // 对全部tbody下同一编号的tr添加hover类
      trs.forEach(each => {
          each.classList[type]('hover')
      })
    }
  }
}
复制代码

经过translate3d设置左右列的位移实现固定列的效果,避免了:

  • 多余的DOM开销:不须要新增额外DOM元素(Gutter),更须要复制多份DOM数据,将DOM开销减小到最小;
  • 不须要维护不一样表格之间列宽行高问题,彻底自适应;
  • 不须要在多个表格之间同步scroll事件

结语

table组件一直是开发复杂度较高的组件,既要考虑性能,又要考虑尽量地对开发者使用友好。在此抛砖引玉,提供另外一种开发思路,只为给有计划开发table组件的你提供一点帮助。

固然你有其余的想法欢迎评论一块儿交流~

The end.

相关文章
相关标签/搜索