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 |