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 |
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。