基于 Ant-Design-Vue 的 Table 组件开发的虚构滚动组件,反对动静高度,解决数据量大时滚动卡顿的问题。
demo & 源码:https://xiaocheng555.github.i…
<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>
参数 |
阐明 |
类型 |
可选值 |
默认值 |
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 |
– |
— |
– |
办法名 |
阐明 |
参数 |
scrollTo |
滚动到第几行【不太准确:因为滚动到第 n 行时,如果四周的表格行计算出实在高度后会更新高度,导致以后行坍塌或撑起】 |
index |
update |
更新 |
– |
clearSelection |
用于多选 <virtual-column type="selection"> ,清空用户的抉择 |
– |
toggleRowSelection |
用于多选 <virtual-column type="selection"> , 切换某一行的选中状态,如果应用了第二个参数,则是设置这一行选中与否(selected 为 true 则选中) |
row, selected |
事件名称 |
阐明 |
参数 |
change |
计算实现实在显示的表格行数 |
(renderData, start, end):renderData 实在渲染的数据,start 和 end 指的是渲染的数据在总数据的开始到完结的区间范畴 |
selection-change |
虚构表格多选选项产生更改时触发事件 |
selectedRows |