a-virtual-table

基于Ant-Design-Vue的 Table 组件开发的虚构滚动组件,反对动静高度,解决数据量大时滚动卡顿的问题。

demo & 源码:https://xiaocheng555.github.i...

<a-virtual-table> 组件

<template>  <div>    <a-table      v-bind="$attrs"      v-on="$listeners"      :pagination="false"      :columns="tableColumns"      :data-source="renderData">      <template v-for="slot in Object.keys($scopedSlots)" :slot="slot" slot-scope="text">        <slot :name="slot" v-bind="typeof text === 'object' ? text : {text}"></slot>      </template>    </a-table>    <div class="ant-table-append" ref="append" v-show="!isHideAppend">      <slot name="append"></slot>    </div>  </div></template><script>import throttle from 'lodash/throttle'import Checkbox from 'ant-design-vue/lib/checkbox'import Table from 'ant-design-vue/lib/table'// 判断是否是滚动容器function isScroller (el) {  const style = window.getComputedStyle(el, null)  const scrollValues = ['auto', 'scroll']  return scrollValues.includes(style.overflow) || scrollValues.includes(style['overflow-y'])}// 获取父层滚动容器function getParentScroller (el) {  let parent = el  while (parent) {    if ([window, document, document.documentElement].includes(parent)) {      return window    }    if (isScroller(parent)) {      return parent    }    parent = parent.parentNode  }  return parent || window}// 获取容器滚动地位function getScrollTop (el) {  return el === window ? window.pageYOffset : el.scrollTop}// 获取容器高度function getOffsetHeight (el) {  return el === window ? window.innerHeight : el.offsetHeight}// 滚动到某个地位function scrollToY (el, y) {  if (el === window) {    window.scroll(0, y)  } else {    el.scrollTop = y  }}// 表格body class名称const TableBodyClassNames = ['.ant-table-scroll .ant-table-body', '.ant-table-fixed-left .ant-table-body-inner', '.ant-table-fixed-right .ant-table-body-inner']let checkOrder = 0 // 多选:记录多选选项扭转的程序export default {  inheritAttrs: false,  name: 'a-virtual-table',  components: {    ACheckbox: Checkbox,    ATable: Table  },  props: {    dataSource: {      type: Array,      default: () => []    },    columns: {      type: Array,      default: () => []    },    // key值,data数据中的惟一id    keyProp: {      type: String,      default: 'id'    },    // 每一行的预估高度    itemSize: {      type: Number,      default: 60    },    // 指定滚动容器    scrollBox: {      type: String    },    // 顶部和底部缓冲区域,值越大显示表格的行数越多    buffer: {      type: Number,      default: 100    },    // 滚动事件的节流工夫    throttleTime: {      type: Number,      default: 10    },    // 是否获取表格行动静高度    dynamic: {      type: Boolean,      default: true    },    // 是否开启虚构滚动    virtualized: {      type: Boolean,      default: true    },    // 是否是树形构造    isTree: {      type: Boolean,      default: false    }  },  data () {    return {      start: 0,      end: undefined,      sizes: {}, // 尺寸映射(依赖响应式)      renderData: [],      // 兼容多选      isCheckedAll: false, // 全选      isCheckedImn: false, // 管制半选款式      isHideAppend: false    }  },  computed: {    tableColumns () {      return this.columns.map(column => {        // 兼容多选        if (column.type === 'selection') {          return {            title: () => {              return (                <a-checkbox                  checked={this.isCheckedAll}                  indeterminate={this.isCheckedImn}                  onchange={() => this.onCheckAllRows(!this.isCheckedAll)}>                </a-checkbox>              )            },            customRender: (text, row) => {              return (                <a-checkbox                  checked={row.$v_checked}                  onchange={() => this.onCheckRow(row, !row.$v_checked)}>                </a-checkbox>              )            },            width: 60,            ...column          }        } else if (column.index) {          // 兼容索引          return {            customRender: (text, row, index) => {              const curIndex = this.start + index              return typeof column.index === 'function' ? column.index(curIndex) : curIndex + 1            },            ...column          }        }        return column      })    },    // 计算出每个item(的key值)到滚动容器顶部的间隔    offsetMap ({ keyProp, itemSize, sizes, dataSource }) {      if (!this.dynamic) return {}      const res = {}      let total = 0      for (let i = 0; i < dataSource.length; i++) {        const key = dataSource[i][keyProp]        res[key] = total        const curSize = sizes[key]        const size = typeof curSize === 'number' ? curSize : itemSize        total += size      }      return res    }  },  methods: {    // 初始化数据    initData () {      // 是否是表格外部滚动      this.isInnerScroll = false      this.scroller = this.getScroller()      this.setToTop()      // 首次须要执行2次handleScroll:因为第一次计算renderData时表格高度未确认导致计算不精确;第二次执行时,表格高度确认后,计算renderData是精确的      this.handleScroll()      this.$nextTick(() => {        this.handleScroll()      })      // 监听事件      this.onScroll = throttle(this.handleScroll, this.throttleTime)      this.scroller.addEventListener('scroll', this.onScroll)      window.addEventListener('resize', this.onScroll)    },    // 设置表格到滚动容器的间隔    setToTop () {      if (this.isInnerScroll) {        this.toTop = 0      } else {        this.toTop = this.$el.getBoundingClientRect().top - (this.scroller === window ? 0 : this.scroller.getBoundingClientRect().top) + getScrollTop(this.scroller)      }    },    // 获取滚动元素    getScroller () {      let el      if (this.scrollBox) {        if (this.scrollBox === 'window' || this.scrollBox === window) return window        el = document.querySelector(this.scrollBox)        if (!el) throw new Error(` scrollBox prop: '${this.scrollBox}' is not a valid selector`)        if (!isScroller(el)) console.warn(`Warning! scrollBox prop: '${this.scrollBox}' is not a scroll element`)        return el      }      // 如果表格是固定高度,则获取表格内的滚动节点,否则获取父层滚动节点      if (this.$attrs.scroll && this.$attrs.scroll.y) {        this.isInnerScroll = true        return this.$el.querySelector('.ant-table-body')      } else {        return getParentScroller(this.$el)      }    },    // 解决滚动事件    handleScroll () {      if (!this.virtualized) return      // 更新以后尺寸(高度)      this.updateSizes()      // 计算renderData      this.calcRenderData()      // 计算地位      this.calcPosition()    },    // 更新尺寸(高度)    updateSizes () {      if (!this.dynamic) return      let rows = []      if (this.isTree) {        // 解决树形表格,筛选出一级树形构造        rows = this.$el.querySelectorAll('.ant-table-body .ant-table-row-level-0')      } else {        rows = this.$el.querySelectorAll('.ant-table-body .ant-table-tbody .ant-table-row')      }      Array.from(rows).forEach((row, index) => {        const item = this.renderData[index]        if (!item) return        // 计算表格行的高度        let offsetHeight = row.offsetHeight        // 表格行如果有扩大行,须要加上扩大内容的高度        const nextEl = row.nextSibling        if (nextEl && nextEl.classList && nextEl.classList.contains('ant-table-expanded-row')) {          offsetHeight += row.nextSibling.offsetHeight        }        // 表格行如果有子孙节点,须要加上子孙节点的高度        if (this.isTree) {          let next = row.nextSibling          while (next && next.tagName === 'TR' && !next.classList.contains('ant-table-row-level-0')) {            offsetHeight += next.offsetHeight            next = next.nextSibling          }        }        const key = item[this.keyProp]        if (this.sizes[key] !== offsetHeight) {          this.$set(this.sizes, key, offsetHeight)          row._offsetHeight = offsetHeight        }      })    },    // 计算只在视图上渲染的数据    calcRenderData () {      const { scroller, buffer, dataSource: data } = this      // 计算可视范畴顶部、底部      const top = getScrollTop(scroller) - buffer - this.toTop      const scrollerHeight = this.isInnerScroll ? this.$attrs.scroll.y : getOffsetHeight(scroller)      const bottom = getScrollTop(scroller) + scrollerHeight + buffer - this.toTop      let start      let end      if (!this.dynamic) {        start = top <= 0 ? 0 : Math.floor(top / this.itemSize)        end = bottom <= 0 ? 0 : Math.ceil(bottom / this.itemSize)      } else {        // 二分法计算可视范畴内的开始的第一个内容        let l = 0        let r = data.length - 1        let mid = 0        while (l <= r) {          mid = Math.floor((l + r) / 2)          const midVal = this.getItemOffsetTop(mid)          if (midVal < top) {            const midNextVal = this.getItemOffsetTop(mid + 1)            if (midNextVal > top) break            l = mid + 1          } else {            r = mid - 1          }        }        // 计算渲染内容的开始、完结索引        start = mid        end = data.length - 1        for (let i = start + 1; i < data.length; i++) {          const offsetTop = this.getItemOffsetTop(i)          if (offsetTop >= bottom) {            end = i            break          }        }      }      // 开始索引始终保持偶数,如果为奇数,则加1使其放弃偶数【确保表格行的偶数数统一,不会导致斑马纹乱序显示】      if (start % 2) {        start = start - 1      }      this.top = top      this.bottom = bottom      this.start = start      this.end = end      this.renderData = data.slice(start, end + 1)      this.$emit('change', this.renderData, this.start, this.end)    },    // 计算地位    calcPosition () {      const last = this.dataSource.length - 1      // 计算内容总高度      const wrapHeight = this.getItemOffsetTop(last) + this.getItemSize(last)      // 计算以后滚动地位须要撑起的高度      const offsetTop = this.getItemOffsetTop(this.start)      // 设置dom地位      TableBodyClassNames.forEach(className => {        const el = this.$el.querySelector(className)        if (!el) return        // 创立wrapEl、innerEl        if (!el.wrapEl) {          const wrapEl = document.createElement('div')          const innerEl = document.createElement('div')          // 此处设置display为'inline-block',是让div宽度等于表格的宽度,修复x轴滚动时左边固定列没有暗影的bug          wrapEl.style.display = 'inline-block'          innerEl.style.display = 'inline-block'          wrapEl.appendChild(innerEl)          innerEl.appendChild(el.children[0])          el.insertBefore(wrapEl, el.firstChild)          el.wrapEl = wrapEl          el.innerEl = innerEl        }        if (el.wrapEl) {          // 设置高度          el.wrapEl.style.height = wrapHeight + 'px'          // 设置transform撑起高度          el.innerEl.style.transform = `translateY(${offsetTop}px)`          // 设置paddingTop撑起高度          // el.innerEl.style.paddingTop = `${offsetTop}px`        }      })    },    // 获取某条数据offsetTop    getItemOffsetTop (index) {      if (!this.dynamic) {        return this.itemSize * index      }      const item = this.dataSource[index]      if (item) {        return this.offsetMap[item[this.keyProp]] || 0      }      return 0    },    // 获取某条数据的尺寸    getItemSize (index) {      if (index <= -1) return 0      const item = this.dataSource[index]      if (item) {        const key = item[this.keyProp]        return this.sizes[key] || this.itemSize      }      return this.itemSize    },    // 【内部调用】更新    update () {      this.setToTop()      this.handleScroll()    },    // 【内部调用】滚动到第几行    // (不太准确:滚动到第n行时,如果四周的表格行计算出实在高度后会更新高度,导致内容坍塌或撑起)    scrollTo (index, stop = false) {      const item = this.dataSource[index]      if (item && this.scroller) {        this.updateSizes()        this.calcRenderData()        this.$nextTick(() => {          const offsetTop = this.getItemOffsetTop(index)          scrollToY(this.scroller, offsetTop)          // 调用两次scrollTo,第一次滚动时,如果表格行首次渲染高度发生变化时,会导致滚动地位有偏差,此时须要第二次执行滚动,确保滚动地位无误          if (!stop) {            setTimeout(() => {              this.scrollTo(index, true)            }, 50)          }        })      }    },    // 渲染全副数据    renderAllData () {      this.renderData = this.dataSource      this.$emit('change', this.dataSource, 0, this.dataSource.length - 1)      this.$nextTick(() => {        // 革除撑起的高度和地位        TableBodyClassNames.forEach(className => {          const el = this.$el.querySelector(className)          if (!el) return          if (el.wrapEl) {            // 设置高度            el.wrapEl.style.height = 'auto'            // 设置transform撑起高度            el.innerEl.style.transform = `translateY(${0}px)`          }        })      })    },    // 执行update办法更新虚构滚动,且每次nextTick只能执行一次【在数据大于100条开启虚构滚动时,因为监听了data、virtualized会间断触发两次update办法:第一次update时,(updateSize)计算尺寸里的渲染数据(renderData)与表格行的dom是一一对应,之后会扭转渲染数据(renderData)的值;而第二次执行update时,renderData扭转了,而表格行dom未扭转,导致renderData与dom不一一对应,从而地位计算错误,最终渲染的数据对应不上。因而应用每次nextTick只能执行一次来防止bug产生】    doUpdate () {      if (this.hasDoUpdate) return // nextTick内曾经执行过一次就不执行      if (!this.scroller) return // scroller不存在阐明未初始化实现,不执行      // 启动虚构滚动的霎时,须要临时暗藏el-table__append-wrapper里的内容,不然会导致滚动地位始终到append的内容处      this.isHideAppend = true      this.update()      this.hasDoUpdate = true      this.$nextTick(() => {        this.hasDoUpdate = false        this.isHideAppend = false      })    },    // 兼容多选:抉择表格所有行    onCheckAllRows (val) {      val = this.isCheckedImn ? true : val      this.dataSource.forEach(row => {        if (row.$v_checked === val) return        this.$set(row, '$v_checked', val)        this.$set(row, '$v_checkedOrder', val ? checkOrder++ : undefined)      })      this.isCheckedAll = val      this.isCheckedImn = false      this.emitSelectionChange()      // 勾销全选,则重置checkOrder      if (val === false) checkOrder = 0    },    // 兼容多选:抉择表格某行    onCheckRow (row, val) {      if (row.$v_checked === val) return      this.$set(row, '$v_checked', val)      this.$set(row, '$v_checkedOrder', val ? checkOrder++ : undefined)      const checkedLen = this.dataSource.filter(row => row.$v_checked === true).length      if (checkedLen === 0) {        this.isCheckedAll = false        this.isCheckedImn = false      } else if (checkedLen === this.dataSource.length) {        this.isCheckedAll = true        this.isCheckedImn = false      } else {        this.isCheckedAll = false        this.isCheckedImn = true      }      this.emitSelectionChange()    },    // 多选:兼容表格selection-change事件    emitSelectionChange () {      const selection = this.dataSource.filter(row => row.$v_checked).sort((a, b) => a.$v_checkedOrder - b.$v_checkedOrder)      this.$emit('selection-change', selection)    },    // 多选:兼容表格toggleRowSelection办法    toggleRowSelection (row, selected) {      const val = typeof selected === 'boolean' ? selected : !row.$v_checked      this.onCheckRow(row, val)    },    // 多选:兼容表格clearSelection办法    clearSelection () {      this.isCheckedImn = false      this.onCheckAllRows(false)    }  },  watch: {    dataSource () {      if (!this.virtualized) {        this.renderAllData()      } else {        this.doUpdate()      }    },    virtualized: {      immediate: true,      handler (val) {        if (!val) {          this.renderAllData()        } else {          this.doUpdate()        }      }    }  },  created () {    this.$nextTick(() => {      this.initData()    })  },  mounted () {    const appendEl = this.$refs.append    this.$el.querySelector('.ant-table-body').appendChild(appendEl)  },  beforeDestroy () {    if (this.scroller) {      this.scroller.removeEventListener('scroll', this.onScroll)      window.removeEventListener('resize', this.onScroll)    }  }}</script><style lang='less'></style>

用法

<template>  <div>    <a-virtual-table      :columns="columns"      :data-source="list"      :itemSize="54"      keyProp="id"      row-key="id"      :pagination="false"      :scroll="{ x: 1300, y: 800 }">      <a slot="name" slot-scope="{text}">{{ text }}===</a>    </a-virtual-table>  </div></template><script>import { mockData } from '@/utils'import AVirtualTable from '../a-virtual-table'export default {  components: {    AVirtualTable  },  data () {    return {      columns: [        {          title: 'Name',          dataIndex: 'name',          key: 'name',          scopedSlots: { customRender: 'name' },          fixed: 'left',          width: 200        },        {          title: 'id',          dataIndex: 'id',          key: 'id',          width: 100        },        {          title: 'text',          dataIndex: 'text',          key: 'text',          width: 400        },        {          title: 'Address',          dataIndex: 'address',          key: 'address 1',          ellipsis: true,          width: 400        },        {          title: 'Long Column Long Column Long Column',          dataIndex: 'address',          key: 'address 2',          ellipsis: true,          width: 300        },        {          title: 'Long Column Long Column',          dataIndex: 'address',          key: 'address 3',          ellipsis: true,          width: 300        },        {          title: 'Long Column',          dataIndex: 'address',          key: 'address 4',          ellipsis: true,          width: 300,          fixed: 'right',        }      ],      list: mockData(0, 2000)    }  }}</script>

a-virtual-table 组件

Props

参数阐明类型可选值默认值
dataSource总数据Array必填
keyPropkey值,data数据中的惟一id【⚠️若keyProp未设置或keyProp值不惟一,可能导致表格空数据或者滚动时渲染的数据断层、不连贯】stringid
itemSize每一行的预估高度number60
scrollBox指定滚动容器;在指定滚动容器时,如果表格设置了height高度,则滚动容器为表格内的滚动容器;如果表格为设置height高度,则主动获取父层以外的滚动容器,直至window容器为止string-
buffer顶部和底部缓冲区域,值越大显示表格的行数越多Number200
throttleTime滚动事件的节流工夫number10
dynamic动静获取表格行高度,默认开启。设置为false时,则以itemSize为表格行的实在高度,能大大减少虚构滚动计算量,缩小滚动白屏;如果itemSize与表格行的实在高度不统一,可能导致滚动时表格数据错乱booleantrue
virtualized是否开启虚构滚动booleantrue
*反对 <a-table> 组件的props属性,更多请看 <a-table> api--

Methods

办法名阐明参数
scrollTo滚动到第几行【不太准确:因为滚动到第n行时,如果四周的表格行计算出实在高度后会更新高度,导致以后行坍塌或撑起】index
update更新-
clearSelection用于多选 <virtual-column type="selection">,清空用户的抉择-
toggleRowSelection用于多选 <virtual-column type="selection">, 切换某一行的选中状态,如果应用了第二个参数,则是设置这一行选中与否(selected 为 true 则选中)row, selected

Events

事件名称阐明参数
change计算实现实在显示的表格行数(renderData, start, end):renderData 实在渲染的数据,start和end指的是渲染的数据在总数据的开始到完结的区间范畴
selection-change虚构表格多选选项产生更改时触发事件selectedRows