Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
lostvita opened this issue Jun 13, 2019 · 1 comment
Open

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

lostvita opened this issue Jun 13, 2019 · 1 comment

Comments

@lostvita
Copy link
Owner

前言

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

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

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

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

  • 固定表头

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

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

<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下),设置表体所在区域可滚动即可,然后再通过一定的手段(如阴槽、表格数据备份)去同步不同区域之间的布局。

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

1.1 固定表头的思路

从浏览器中审查table组件的渲染效果看:
image
表头和表体分别放在了两个不同的div区域el-table__header-wrapper & el-table__body-wrapper,如此表体内容超出容器高度时,会出现滚动条,只在自己区域内滚动,达到了表头固定的效果。这样的实现导致了两个问题

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

针对上面的问题,element也做了处理,引用饿了么文中一张图片:
image
在表头部分增加一个Gutter元素,虚拟成滚动条去占据一定宽度(图片右上角粉色的竖条),宽度一致的处理则是要求用户使用的时候个传入每个列的宽度

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

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

1.2 固定表列的思路

实现固定表列相对比较复杂,为实现这个功能,element可谓是付出了“巨大的成本”。在这个左右列固定的渲染效果中:
image
渲染出了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外的属性都是相对于视口的左上角而言的。

如下图:
image

实现固定表头

在一个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轴位移值**,达到固定表头的效果。如下图:
image
滚动页面滚动条,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轴的位移值。如下图:
image
初始化时,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
    }
  }
}

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

同步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.

@rongbaizhai
Copy link

写得很好,即使现在看,也是很详尽的blog

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants