乐趣区

关于vue.js:AntDesignVue1-table实现虚拟滚动

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 必填
keyProp key 值,data 数据中的惟一 id【⚠️若 keyProp 未设置或 keyProp 值不惟一,可能导致表格空数据或者滚动时渲染的数据断层、不连贯】 string id
itemSize 每一行的预估高度 number 60
scrollBox 指定滚动容器;在指定滚动容器时,如果表格设置了 height 高度,则滚动容器为表格内的滚动容器;如果表格为设置 height 高度,则主动获取父层以外的滚动容器,直至 window 容器为止 string
buffer 顶部和底部缓冲区域,值越大显示表格的行数越多 Number 200
throttleTime 滚动事件的节流工夫 number 10
dynamic 动静获取表格行高度,默认开启。设置为 false 时,则以 itemSize 为表格行的实在高度,能大大减少虚构滚动计算量,缩小滚动白屏;如果 itemSize 与表格行的实在高度不统一,可能导致滚动时表格数据错乱 boolean true
virtualized 是否开启虚构滚动 boolean true
* 反对 <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
退出移动版