前言
这是我参与新手入门的第2篇文章
为了坚持心中所想,想来想去,还是准备写写这个,揭开虚拟滚动节点的面纱。
需求场景
综合之前开发经验,我们会发现一般列表的要求也很简单,首先固定表头,其次加载不可太慢。
- 固定表头。关于这一点,我之前都是通过ul和li这种有序列表组合,再加上css定位和滚动计算实现表头的固定的。前两天,我就在想,那么几个公开的框架是怎么实现的呢?打开几个流行的组件架构,我发现原来是我的格局小了,原生的table是可以做到表头的固定的。这里大家稍微研究一下就可以实现,不在多说,节点结构大概如下:
//表头
<table>
<colgroup>
<col name="s-table-columnid" width="150" align="center">
....
</colgroup>
<thead>
<tr>
<th class="s-table-columnid">序号</th>
...
</tr>
</thead>
</table>
//表体
<table>
<colgroup>
<col name="s-table-columnid" width="150" align="center">
....
</colgroup>
<tbody>
<tr>
<td class="s-table-columnid">XXX</td>
...
</tr>
</tbody>
</table>
复制代码
这里具体的代码,我后面会给出
- 加载渲染不可太慢。这一点抛除掉接口响应导致的问题,前端一般的处理就是写好列表渲染结构,然后加上分页,一般浏览器响应速度都是杠杠的,但是如果一旦一页数据量过大,且列过多,,往往就会不那么流畅了,这个时候我们又该怎么办呢?一般,用vue做开发的就会想到虚拟滚动节点了。其思想就是通过减少真实渲染的dom节点,实现视觉上的快速响应。也许我这么说,还不好理解。更简单点来说就是,我们只渲染可以看到的那块区域的列表数据节点,看不到的默认隐藏起来,这样就大大的提高了页面的视觉响应了。
具体实现步骤
- 准备好列表
- 准备好大的数据量
- 精准计算当前显示的列表数据
1.准备好列表。根据上面的双table结构,我们可以很快的写出一个常规列表。这里是我的代码
//grid.vue 主结构
<template>
<div class="s-grid">
<div class="s-grid-header" >
<gridHeader :columnData="columnList" :verticalBar="hasScrollBar" ref="tableTitle"></gridHeader>
</div>
<div class="s-grid-body" ref="tableBody" @scroll.passive="onBodyScroll" >
<gridBody :columnData="columnList" :list="renderData" :scrollStyle="getBodyWrapperStyle"></gridBody>
</div>
</div>
</template>
//gridHeader.vue
<template>
<div class="s-gridHeader">
<table>
<colgroup>
<col v-for="(th,index) in columnData" :name="'s-table-column'+ th.name" :key="index" :width="th.width" :align="th.textAlign">
<col :width="scrollWidth" v-show="verticalBar">
</colgroup>
<thead>
<tr>
<th v-for="(th,index) in columnData" :class="'s-table-column'+ th.name " :key="index">
<div>
{{$i18nc.message(th.i18n,th.text)}}
</div>
</th>
<th class="gutter" v-show="verticalBar"></th>
</tr>
</thead>
</table>
</div>
</template>
//gridBody.vue
<template>
<div class="s-gridBody" :style="scrollStyle">
<table>
<colgroup>
<col v-for="(th,index) in columnData" :name="'s-table-column'+ th.name" :key="index" :width="th.width" :align="th.textAlign">
</colgroup>
<tbody>
<tr v-for="(tr,index) in list" :key="index">
<td v-for="(td,indexx) in columnData" :class="'s-table-column'+ td.name " :key="indexx" >
<div>{{tr[td.name]}}</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
复制代码
这里唯一需要注意的一点就是,我在表头中预留出了滚动条的区域。这是因为,一旦数据超出body可存放时,会出现滚动条,而双table会因为滚动条的出现,导致上下错位情况出现。
2.准备好大的数据量。这个准备点假数据,循环遍历生成就好了。
//grid.vue
data(){
return{
columnList:[
{
type:"column",
xtype:"textfield",
name:"id",
textAlign:"center",
text:"序号",
i18n:"column.orderNumber",
width:150,
}
....
]
}
}
mounted(){
const vm = this;
let dataList = [];
for(let i=0;i<200;i++){
let obj = {
id:i,
name:vm.nameList[i%4],
sex:i%2 == "1" ? "女" : "男",
province:"省份" + i,
province1:"省份1" + i,
province2:"省份2" + i,
province3:"省份3" + i,
province4:"省份4" + i,
province5:"省份5" + i,
date:"2021-05-27",
address:"地址" + i,
partment:"部门" + i
}
dataList.push(obj);
}
vm.tableData = dataList;
console.log(vm.tableData)
},
复制代码
3.精准计算当前显示的列表数据。这也是整个功能的核心,也是最重要的一点。其实就是每次精准的算出body所需要的列表数据集合。这里实现的思路我们可以分为这几步:
- 取到每行的高度,这里我们暂时固定行高为 40。
- 要渲染的行数,这里选择为 30
- 监听滚动事件,然后每次滚动后,重现计算显示区域的列表那30条数据
这里先说横轴滚动的处理,这里的处理其实就是让表头跟着表体一起滚动,使其看起来像一个整体。主要方法如下:
onBodyScroll(){
window.requestAnimationFrame(() => {
const { scrollLeft, scrollTop } = this.$refs.tableBody;
const { tableTitle } = this.$refs;
tableTitle.$el.style.transform = `translateX(-${scrollLeft}px) translateZ(0)`;
if (this.scrollLeft !== scrollLeft) {
const { tableTitle } = this.$refs;
const type = scrollLeft ? "add" : "remove";
tableTitle.$el.style.transform = `translateX(-${scrollLeft}px) translateZ(0)`;
this.scrollLeft = scrollLeft;
} else {
this.refreshRenderData(scrollTop);
}
});
},
复制代码
纵轴的处理稍微麻烦点,主要就是计算当前渲染的列表的数据集合。代码如下:
//grid.vue
混入了tableBody.js
mixins:[tableBody],
//tableBody.js
import Virtual from "./virtual";
const ESTIMATE_SIZE = 40; // 每行高度
const VirtualList = {
data() {
return {
range: null,
keeps: 30, // 渲染的行数
itemHeight: ESTIMATE_SIZE,
renderData: []
};
},
watch: {
tableData() {
if(!this.virtual) return
this.refreshVirtual();
}
},
computed: {
getBodyWrapperStyle() {
//表体padding
const { padFront, padBehind } = this.range || {};
return { padding: `${padFront}px 0px ${padBehind}px` };
}
},
mounted() {
this.$nextTick(() => {
this.installVirtual();
});
},
beforeDestroy() {
this.virtual.destroy();
},
methods: {
refreshVirtual() {
this.virtual.updateParam("uniqueIds", this.getUniqueIdFromDataSources());
this.virtual.handleDataSourcesChange();
},
installVirtual() {
this.virtual = new Virtual(
{
keeps: this.keeps,
estimateSize: this.itemHeight,
buffer: Math.round(this.keeps / 3), // recommend for a third of keeps
uniqueIds: this.getUniqueIdFromDataSources()
},
this.onRangeChanged
);
this.range = this.virtual.getRange();
},
getUniqueIdFromDataSources() {
return this.tableData.map(dataSource => dataSource.id);
},
onRangeChanged(range) {
this.range = range;
this.renderData = this.getRenderData();
},
onVirtualScroll() {
if (this.scrollFlag) return;
window.requestAnimationFrame(() => {
const { scrollLeft, scrollTop } = this.$refs.sunTable;
if (this.scrollLeft !== scrollLeft) {
const { tableTitle } = this.$refs;
const type = scrollLeft ? "add" : "remove";
tableTitle.$el.style.transform = `translateX(-${scrollLeft}px) translateZ(0)`;
this.scrollLeft = scrollLeft;
} else {
this.refreshRenderData(scrollTop);
}
});
},
refreshRenderData(offset) {
this.virtual.handleScroll(offset);
},
getRenderData() {
//视图上展示的列表数据
const renderData = [];
const { start, end } = this.range;
const { tableData } = this;
for (let index = start; index <= end; index++) {
const item = tableData[index];
item.__vindex = index;
renderData.push(item);
}
return renderData;
}
}
};
export default VirtualList;
//virtual.js
const DIRECTION_TYPE = {
FRONT: 'FRONT', // scroll up or left
BEHIND: 'BEHIND' // scroll down or right
}
const CALC_TYPE = {
INIT: 'INIT',
FIXED: 'FIXED',
DYNAMIC: 'DYNAMIC'
}
const LEADING_BUFFER = 2
export default class Virtual {
constructor (param, callUpdate) {
this.init(param, callUpdate)
}
init (param, callUpdate) {
// param data
this.param = param
this.callUpdate = callUpdate
// size data
this.sizes = new Map()
this.firstRangeTotalSize = 0
this.firstRangeAverageSize = 0
this.lastCalcIndex = 0
this.fixedSizeValue = 0
this.calcType = CALC_TYPE.INIT
// scroll data
this.offset = 0
this.direction = ''
// range data
this.range = Object.create(null)
if (param) {
this.checkRange(0, param.keeps - 1)
}
// benchmark test data
// this.__bsearchCalls = 0
// this.__getIndexOffsetCalls = 0
}
destroy () {
this.init(null, null)
}
// return current render range
getRange () {
const range = Object.create(null)
range.start = this.range.start
range.end = this.range.end
range.padFront = this.range.padFront
range.padBehind = this.range.padBehind
return range
}
isBehind () {
return this.direction === DIRECTION_TYPE.BEHIND
}
isFront () {
return this.direction === DIRECTION_TYPE.FRONT
}
// return start index offset
getOffset (start) {
return start < 1 ? 0 : this.getIndexOffset(start)
}
updateParam (key, value) {
if (this.param && (key in this.param)) {
// if uniqueIds reducing, find out deleted id and remove from size map
if (key === 'uniqueIds' && (value.length < this.param[key].length)) {
this.sizes.forEach((v, key) => {
if (!value.includes(key)) {
this.sizes.delete(key)
}
})
}
this.param[key] = value
}
}
// save each size map by id
saveSize (id, size) {
this.sizes.set(id, size)
// we assume size type is fixed at the beginning and remember first size value
// if there is no size value different from this at next comming saving
// we think it's a fixed size list, otherwise is dynamic size list
if (this.calcType === CALC_TYPE.INIT) {
this.fixedSizeValue = size
this.calcType = CALC_TYPE.FIXED
} else if (this.calcType === CALC_TYPE.FIXED && this.fixedSizeValue !== size) {
this.calcType = CALC_TYPE.DYNAMIC
// it's no use at all
delete this.fixedSizeValue
}
// calculate the average size only in the first range
if (this.calcType !== CALC_TYPE.FIXED && typeof this.firstRangeTotalSize !== 'undefined') {
if (this.sizes.size < Math.min(this.param.keeps, this.param.uniqueIds.length)) {
this.firstRangeTotalSize = this.firstRangeTotalSize + size
this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size)
} else {
// it's done using
delete this.firstRangeTotalSize
}
}
}
// in some special situation (e.g. length change) we need to update in a row
// try goiong to render next range by a leading buffer according to current direction
handleDataSourcesChange () {
let start = this.range.start
if (this.isFront()) {
start = start - LEADING_BUFFER
} else if (this.isBehind()) {
start = start + LEADING_BUFFER
}
start = Math.max(start, 0)
this.updateRange(this.range.start, this.getEndByStart(start))
}
// when slot size change, we also need force update
handleSlotSizeChange () {
this.handleDataSourcesChange()
}
// calculating range on scroll
handleScroll (offset) {
this.direction = offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND
this.offset = offset
if (this.direction === DIRECTION_TYPE.FRONT) {
this.handleFront()
} else if (this.direction === DIRECTION_TYPE.BEHIND) {
this.handleBehind()
}
}
// ----------- public method end -----------
handleFront () {
const overs = this.getScrollOvers()
// should not change range if start doesn't exceed overs
if (overs > this.range.start) {
return
}
// move up start by a buffer length, and make sure its safety
const start = Math.max(overs - this.param.buffer, 0)
this.checkRange(start, this.getEndByStart(start))
}
handleBehind () {
const overs = this.getScrollOvers()
// range should not change if scroll overs within buffer
if (overs < this.range.start + this.param.buffer) {
return
}
this.checkRange(overs, this.getEndByStart(overs))
}
// return the pass overs according to current scroll offset
getScrollOvers () {
const offset = this.offset
if (offset <= 0) {
return 0
}
// if is fixed type, that can be easily
if (this.isFixedType()) {
return Math.floor(offset / this.fixedSizeValue)
}
let low = 0
let middle = 0
let middleOffset = 0
let high = this.param.uniqueIds.length
while (low <= high) {
// this.__bsearchCalls++
middle = low + Math.floor((high - low) / 2)
middleOffset = this.getIndexOffset(middle)
if (middleOffset === offset) {
return middle
} else if (middleOffset < offset) {
low = middle + 1
} else if (middleOffset > offset) {
high = middle - 1
}
}
return low > 0 ? --low : 0
}
// return a scroll offset from given index, can efficiency be improved more here?
// although the call frequency is very high, its only a superposition of numbers
getIndexOffset (givenIndex) {
if (!givenIndex) {
return 0
}
let offset = 0
let indexSize = 0
for (let index = 0; index < givenIndex; index++) {
// this.__getIndexOffsetCalls++
indexSize = this.sizes.get(this.param.uniqueIds[index])
offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize())
}
// remember last calculate index
this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1)
this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex())
return offset
}
// is fixed size type
isFixedType () {
return this.calcType === CALC_TYPE.FIXED
}
// return the real last index
getLastIndex () {
return this.param.uniqueIds.length - 1
}
// in some conditions range is broke, we need correct it
// and then decide whether need update to next range
checkRange (start, end) {
const keeps = this.param.keeps
const total = this.param.uniqueIds.length
// datas less than keeps, render all
if (total <= keeps) {
start = 0
end = this.getLastIndex()
} else if (end - start < keeps - 1) {
// if range length is less than keeps, corrent it base on end
start = end - keeps + 1
}
if (this.range.start !== start) {
this.updateRange(start, end)
}
}
// setting to a new range and rerender
updateRange (start, end) {
this.range.start = start
this.range.end = end
this.range.padFront = this.getPadFront()
this.range.padBehind = this.getPadBehind()
this.callUpdate(this.getRange())
}
// return end base on start
getEndByStart (start) {
const theoryEnd = start + this.param.keeps - 1
const truelyEnd = Math.min(theoryEnd, this.getLastIndex())
return truelyEnd
}
// return total front offset
getPadFront () {
if (this.isFixedType()) {
return this.fixedSizeValue * this.range.start
} else {
return this.getIndexOffset(this.range.start)
}
}
// return total behind offset
getPadBehind () {
const end = this.range.end
const lastIndex = this.getLastIndex()
if (this.isFixedType()) {
return (lastIndex - end) * this.fixedSizeValue
}
// if it's all calculated, return the exactly offset
if (this.lastCalcIndex === lastIndex) {
return this.getIndexOffset(lastIndex) - this.getIndexOffset(end)
} else {
// if not, use a estimated value
return (lastIndex - end) * this.getEstimateSize()
}
}
// get the item estimate size
getEstimateSize () {
return this.isFixedType() ? this.fixedSizeValue : (this.firstRangeAverageSize || this.param.estimateSize)
}
}
复制代码
最终展现结果如图
本次描述可能不够详尽,大部分都是代码,需要大家自行运行测试下,其实核心就那么点东西,后期考虑看如何更好的描述出来,如果有说的不到位的地方,还望大家见谅哈。
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END