关于虚拟滚动已经写过一篇文章,讲解了虚拟滚动实现原理和具体实现过程:虚拟滚动实现。关于不定高虚拟列表实现原理以及具体怎么实现也写过一篇文章:不定高虚拟列表的一种实现。
本文是将虚拟滚动与虚拟列表结合,以便解决不定高虚拟列表遗留的问题:滑动过快导致白屏现象
。

基于虚拟滚动的定高统一高度虚拟列表
先实现基于虚拟滚动的定高统一高度虚拟列表
,这个相对比较简单,之后再实现基于虚拟滚动的不定高虚拟列表
,由浅入深。
演示效果
最终的效果如下

演示示例是有300条数据,每条数据高度50px。其内容都是简单的字符串。实现了内容区可以通过鼠标或键盘触屏板触发滚动同时右侧虚拟滚动条滚动,同时拖动右侧虚拟滚动条内容区滚动
。
虚拟列表组件实现
这部分说一下如何实现的上述效果。
监听wheel开启虚拟滚动
使用wheel
监听键盘触屏板滑动和鼠标滚轮滚动,具体实现
js
<script>
export default {
name: "virtualSrollSameHeightVirtualList",
...
methods: {
// 为盒子绑定事件 监听滚轮距离或鼠标滚动距离
bindContainerEvent() {
const { $container } = this.$element;
const containerOffsetHeight = $container.offsetHeight;
this.wheelOffset = 0;
const bindContainerOffset = (event) => {
event.preventDefault();
this.wheelOffset += -event.wheelDeltaY;
this.wheelOffset = Math.max(this.wheelOffset, 0);
this.wheelOffset = Math.min(
this.wheelOffset,
this.listHeight - containerOffsetHeight
);
// 更新内容区偏移量
this.updateRenderIndex();
};
$container.addEventListener("wheel", bindContainerOffset);
this.unbindContainerEvent = () => {
$container.removeEventListener("wheel", bindContainerOffset);
};
},
...
}
};
</script>
关于wheel事件可参考:wheel,通过事件对象的wheelDeltaY
获取移动距离,this.wheelOffset
为累加值,可以理解成滚动条滚动的距离。
为了符合真实的滚动效果,需要限制一下this.wheelOffset
,最小为0,最大为列表项高度-内容盒子高度
,[0, 列表项高度-内容盒子高度]。
更新内容区偏移量
更新内容区偏移量使用this.updateRenderIndex
方法,具体实现
js
<script>
export default {
name: "virtualSrollSameHeightVirtualList",
computed: {
// 内容区偏移量
contentTransform() {
return `translateY(-${this.contentOffset}px)`;
},
// 可视区渲染数据
visibleData() {
return this.listData.slice(this.start, this.end);
},
},
...
methods: {
updateRenderIndex(by = "content") {
// 根据this.wheelOffset找到头部和尾部渲染索引
const headIndex = this.findOffsetIndex(this.wheelOffset);
const footerIndex = this.findOffsetIndex(
this.wheelOffset + this.screenHeight
);
// 缓存数据this.aboveCount和this.belowCount
this.start = Math.max(headIndex - this.aboveCount, 0);
this.end = Math.min(footerIndex + this.belowCount, this.listData.length);
if (by === "content") {
this.handleOffset = this.transferOffset();
}
// 对于真实的滚动条内容区的高度contentHeight = containerHeight + maxScrollTop
// 对于真实的滚动条滚动多少内容区就向上移动
// contentMove = curScrollTop
// this.wheelOffset相当于当下滚动的距离curScrollTop
// 此处因为渲染内容高度是动态的,所以偏移量也是动态的,需要减去不渲染的那部分内容
// 内容区的偏移量应该为this.wheelOffset - this.sumHeight(0, this.start)
this.$nextTick(()=>{
this.contentOffset = this.wheelOffset - this.sumHeight(0, this.start);
})
},
...
}
};
</script>
使用this.wheelOffset
算出渲染数据的头部数据和尾部数据索引,this.aboveCount
和this.belowCount
为多出来渲染的缓存数据。
因为是虚拟列表所以这里只是渲染the.start
和this.end
之间数据,然后更新内容区偏移量,更新的关键点
js
this.$nextTick(()=>{
this.contentOffset = this.wheelOffset - this.sumHeight(0, this.start);
})
因为the.start
和this.end
可能发生了变化,所以使用this.$nextTick
执行。
偏移量为什么是this.wheelOffset - this.sumHeight(0, this.start)
,我尝试解释。上边已经限定了this.wheelOffset
的最小和最大区间[0, 列表项高度-内容盒子高度],也就是真实滚动条的滚动距离区间。
正常来说应该this.wheelOffset
滚动多少,内容区就移动多少,但是此时的内容区是动态的,会根据the.start
和this.end
渲染指定内容。所以this.wheelOffset
需要减去不渲染的那部分高度,也就是this.sumHeight(0, this.start)
,所以偏移量是this.wheelOffset - this.sumHeight(0, this.start)
。this.sumHeight
是求数据总高度的方法
js
...
sumHeight(start = 0, end = 100) {
let height = 0;
for (let i = start; i < end; i++) {
height += this.listData[i].height;
}
return height;
}
...
让虚拟滚动条滚动
内容区滚动了,需要也让虚拟滚动条滚动。这个实现的关键是搞清楚内容区滚动距离和滚动条滚动距离
之间的关系,实际二者有一个比例关系。
也即内容区最大可滚动距离和虚拟滚动条最大可滚动距离之间的比例
,这个是固定的。因为this.wheelOffset
就是内容区的瞬时滚动距离,所以虚拟滚动条的瞬时滚动距离就知道了。
也就是找到比例之后用_this.wheelOffset * assistRatio
js
methods: {
// 手柄和内容之间的偏移量转换
transferOffset(to = "handle") {
const { $container, $slider } = this.$element;
const contentSpace = this.listHeight - $container.offsetHeight;
const handleSpace = $slider.offsetHeight - this.handleHeight;
const assistRatio = handleSpace / contentSpace; // 小于1
const _this = this;
const computedOffset = {
handle() {
return _this.wheelOffset * assistRatio;
},
content() {
return _this.handleOffset / assistRatio;
},
};
return computedOffset[to]();
},
// 初始化手柄高度以及限制最小高度
initHandleHeight() {
const { $container, $slider } = this.$element;
this.handleHeight =
($slider.offsetHeight * $container.offsetHeight) / this.listHeight;
// 最小值为HandleMixHeight
if (this.handleHeight < HandleMixHeight) {
this.handleHeight = HandleMixHeight;
}
},
},
上面transferOffset
方法除了可以将this.wheelOffset
转为虚拟滚动条的移动距离,还可以将手柄移动距离转为this.wheelOffset
。
另外initHandleHeight
方法用来限制滚动条手柄的最小高度,方便使用手柄滚动。
监听onmousemove、onmousedown、onmouseup模拟滚动条
手柄滑动的具体实现
js
...
bindHandleEvent() {
const { $slider, $handle } = this.$element;
$handle.onmousedown = (e) => {
const startY = e.clientY;
const startTop = this.handleOffset;
window.onmousemove = (e) => {
const deltaX = e.clientY - startY;
this.handleOffset =
startTop + deltaX < 0
? 0
: Math.min(
startTop + deltaX,
$slider.offsetHeight - this.handleHeight
);
this.wheelOffset = this.transferOffset("content");
this.updateRenderIndex("handle");
};
window.onmouseup = function () {
window.onmousemove = null;
window.onmouseup = null;
};
};
},
saveHtmlElementById() {
const { container, slider, handle } = this.$refs;
this.$element = {
$container: container,
$slider: slider,
$handle: handle,
};
this.initHandleHeight();
this.bindContainerEvent();
this.bindHandleEvent();
},
...
实现原理是给手柄$handle
添加监听事件onmousedown
,通过事件对象记录按下鼠标的clientY
。给window
添加onmousedown
、onmouseup
,在onmousedown
函数里计算出垂直方向的移动距离deltaX
。
再之后限制手柄移动的距离[0, $slider.offsetHeight - this.handleHeight]
,同时根据上面介绍的方法this.transferOffset
将手柄偏移量转为总偏移量this.wheelOffset
。
再之后更新内容区索引和内容区偏移量,执行this.updateRenderIndex("handle")
。
基于虚拟滚动的不定高虚拟列表
上面讲解了基于虚拟滚动的定高统一高度虚拟列表
主要实现思路,下面在这个基础上实现基于虚拟滚动的不定高虚拟列表
。
不定高的难点是一开始的高度不知道,为了拿到高度可以,可以用的方案
- 在屏幕外渲染,但消耗性能
- 以
预估高度
先行渲染,然后获取真实高度并缓存
这里采用第二个方案也是别人实现过的方案。
缓存位置点
一开始给每条数据一个预估高度itemSize
,预估高度要贴近真实的数据渲染高度,可以使用数据渲染后的最小高度。之后缓存每条数据的位置信息,包括高度height
、top
和bottom
,方便后面使用
js
...
<script>
export default {
name: "VirtualScrollVirtualList",
props: {
// 所有列表数据
listData: {
type: Array,
default: () => [],
},
itemSize: {
type: Number,
default: 100,
},
...
},
data() {
return {
...
// 缓存位置数组
positions: [],
};
},
computed: {
_listData() {
return this.listData.reduce((init, cur, index) => {
init.push({
// _转换后的索引_第一项在原列表中的索引_本行包含几列
_key: index,
value: cur,
});
return init;
}, []);
},
},
methods: {
// 初始化缓存
initPositions() {
this.positions = this._listData.map((d, index) => ({
index,
height: this.itemSize,
top: index * this.itemSize,
bottom: (index + 1) * this.itemSize,
}));
},
},
created() {
this.initPositions();
}
};
</script>
给要渲染列表数据做标记
给渲染数据列表listData
添加计算属性_listData
,_listData
有一个唯一标识_key
。_key
用来变更缓存数据里面对应数据的高度值,具体是_key
作为渲染div的id
值
js
<template>
<div
ref="container"
class="infinite-list-container"
>
<div :style="{ transform: contentTransform }" class="infinite-list">
<div
ref="items"
class="infinite-list-item-container"
:id="row._key"
:key="row._key"
v-for="row in visibleData"
>
<div class="infinite-item">
<slot :item="row.value" :index="row._key"></slot>
</div>
</div>
</div>
<div class="infinite-slider" ref="slider">
<div
class="infinite-handle"
:style="{ transform: handleTransform, height: handleStyleHeight }"
ref="handle"
></div>
</div>
</div>
</template>
<script>
const HandleMixHeight = 20;
export default {
name: "VirtualScrollVirtualList",
props: {
// 所有列表数据
listData: {
type: Array,
default: () => [],
},
itemSize: {
type: Number,
default: 100,
},
},
data() {
return {
...
// 缓存位置数组
positions: [],
handleHeight: HandleMixHeight,
contentOffset: 0,
};
},
computed: {
// 内容计算属性给列表数据加标记_key
_listData() {
return this.listData.reduce((init, cur, index) => {
init.push({
// _转换后的索引_第一项在原列表中的索引_本行包含几列
_key: index,
value: cur,
});
return init;
}, []);
},
...
visibleData() {
return this._listData.slice(this.start, this.end);
},
},
methods: {
// 初始化缓存
initPositions() {
this.positions = this._listData.map((d, index) => ({
index,
height: this.itemSize,
top: index * this.itemSize,
bottom: (index + 1) * this.itemSize,
}));
},
// 更新缓存位置数据
updateItemsSize() {
return new Promise((resolve) => {
const nodes = this.$refs.items;
nodes.forEach((node) => {
// 获取元素自身的属性
const rect = node.getBoundingClientRect();
const height = rect.height;
const index = +node.id;
const oldHeight = this.positions[index].height;
const dValue = oldHeight - height;
// 存在差值
if (dValue) {
this.positions[index].bottom =
this.positions[index].bottom - dValue;
this.positions[index].height = height;
this.positions[index].over = true; // TODO
for (let k = index + 1; k < this.positions.length; k++) {
this.positions[k].top = this.positions[k - 1].bottom;
this.positions[k].bottom = this.positions[k].bottom - dValue;
}
}
});
resolve();
});
},
},
...
};
</script>
更新缓存位置点数据
也就是为了获得渲染数据真实的高度height
、top
和bottom
,这块使用Promise包一层,在回调函数中执行内容偏移量逻辑
js
...
updateItemsSize() {
return new Promise((resolve) => {
const nodes = this.$refs.items;
nodes.forEach((node) => {
// 获取元素自身的属性
const rect = node.getBoundingClientRect();
const height = rect.height;
const index = +node.id;
const oldHeight = this.positions[index].height;
const dValue = oldHeight - height;
// 存在差值
if (dValue) {
this.positions[index].bottom =
this.positions[index].bottom - dValue;
this.positions[index].height = height;
this.positions[index].over = true; // TODO
for (let k = index + 1; k < this.positions.length; k++) {
this.positions[k].top = this.positions[k - 1].bottom;
this.positions[k].bottom = this.positions[k].bottom - dValue;
}
}
});
resolve();
});
},
...
具体实现不定高虚拟列表
监听wheel开启虚拟滚动
这里和上面定高虚拟列表组件实现
里的监听wheel开启虚拟滚动逻辑一致。
更新内容区偏移量
这里有不一致的地方,也就是在更新缓存位置数据后再更新内容的偏移量,具体是在this.updateItemsSize()
的then
里面
js
updateRenderIndex(by = "content") {
const headIndex = this.findOffsetIndex(this.wheelOffset);
const footerIndex = this.findOffsetIndex(
this.wheelOffset + this.screenHeight
);
this.start = Math.max(headIndex - this.aboveCount, 0);
this.end = Math.min(footerIndex + this.belowCount, this._listData.length);
this.updateItemsSize().then(() => {
if (by === "content") {
this.handleOffset = this.transferOffset();
}
this.contentOffset = this.wheelOffset - this.sumHeight(0, this.start);
});
},
让虚拟滚动条滚动
和上面定高虚拟列表组件实现
里的让虚拟滚动条滚动逻辑一致。
监听onmousemove、onmousedown、onmouseup模拟滚动条
和上面定高虚拟列表组件实现
里的监听onmousemove
、onmousedown
、onmouseup
模拟滚动条逻辑一致。
总结
看下基于虚拟滚动的不定高虚拟列表最终效果

怎么拖动都没有白屏现象了。
本文使用虚拟滚动结合虚拟列表彻底解决了不定高虚拟列表遗留的问题:滑动过快导致白屏现象。
基本思路:先收集总偏移量,再进行数据渲染,更新完毕再进行内容区的移动。
关键点:对渲染数据进行缓存,渲染后拿到数据的真实渲染高度height、top、bottom数据,再更新内容区的偏移量,内容区的偏移量=总偏移量-没有渲染的部分。
项目代码:github.com/zhensg123/r...
(本文完)