再談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.

相關文章
相關標籤/搜索