输出内容才能更好的理解学习的知识,相关:
基于【虚拟列表】高性能渲染海量数据
基于【Event Loop】的长列表渲染优化
IntersectionObserver:实现滚动动画、懒加载、虚拟列表...
前言🎀
在前文中我们了解到:
- 在某种特殊场景下,我们需要将 大量数据 使用不分页的方式渲染到列表上,这种列表叫做长列表。
- 因为事件循环的机制,一次性大量的渲染耗时较长,并且渲染期间会阻塞页面交互事件,所以我们使用时间分片机制将渲染分为多次。
- 分析真实业务场景,将全部数据渲染到列表中是无用且浪费资源的行为,只需要根据用户的视窗进行部分渲染即可,所以使用到虚拟列表技术。
前文中我们根据 "无论滚动到什么位置,浏览器只需要渲染可见区域内的节点" 的思路实现了虚拟列表解决了长列表问题,但在一些细节和特殊情况的处理上还是有所欠缺,例如:
- 高度不定的列表项会导致内容出现错位、偏移等情况。
- 列表项含有异步资源,会在渲染后再次改变高度。
- 一次性大量数据的请求导致请求响应与数据处理时间过长。
在本文中我们就来一起研究这些场景,并对原版的虚拟列表做出优化 🚀
如果觉得有收获还望大家点赞、收藏 🌹
动态高度
分析
在前文的虚拟列表实现中,列表项高度itemSize
都是固定的。
// template -> list-item
:style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
// export defualt -> data
itemSize: 50,
因此很多直接与 列表项高度itemSize
关联的属性,都很容易计算:
- 列表总高度
listHeight
= listData.length * itemSize - 当前窗口偏移量
currentOffset
= scrollTop - (scrollTop % itemSize) - 列表数据的开始/结束索引
start/end
= ~~(scrollTop / itemSize)
. . . . . .
但在实际情况中列表元素多为高度不固定的列表项,它可能是多行文本、图片之类的可变内容,如系统日志、微博等等。
不固定的高度会导致上述的属性无法正常计算。
对于高度不固定的列表项,我们遇到的问题如下:
- 如何获取真实高度?
- 相关的属性该如何计算?
- 列表渲染的方式有何改变?
方案
如何获取真实高度?
-
如果能获得列表项高度数组,真实高度问题就很好解决。但在实际渲染之前是很难拿到每一项的真实高度的,所以我们采用预估一个高度渲染出真实DOM,再根据DOM的实际情况去设置真实高度。
-
创建一个缓存列表,其中列表项字段为 索引、高度与定位,并预估列表项高度用于初始化缓存列表。在渲染后根据DOM实际情况更新缓存列表。
相关的属性该如何计算?
-
显然以前的计算方式都无法使用了,因为那都是针对固定值设计的。
-
于是我们根据缓存列表重写 计算属性、滚动回调函数,例如列表总高度的计算可以使用缓存列表最后一项的定位字段的值。
列表渲染的方式有何改变?
-
因为用于渲染页面元素的数据是根据 开始/结束索引 在 数据列表 中筛选出来的,所以只要保证索引的正确计算,那么渲染方式是无需变化的。
-
对于开始索引,我们将原先的计算公式改为:在 缓存列表 中搜索第一个底部定位大于 列表垂直偏移量 的项并返回它的索引。
-
对于结束索引,它是根据开始索引生成的,无需修改。
实现
预估&初始化列表
先设置一个虚拟 预估高度preItemSize
,用于列表初始化。
同时维护一个记录真实列表项数据的 缓存列表positions
。
data() {
return {
. . . . . .
// 预估高度
preItemSize: 50,
// 缓存列表
positions = [
// 列表项对象
{
index: 0, // 对应listData的索引
top: 0, // 列表项顶部位置
bottom: 50, // 列表项底部位置
height: 50, // 列表项高度
}
]
}
}
在创建组件时先用preItemSize
对positions
进行初始化,在后续更新时再进行替换。
created() {
this.initPositions(this.listData, this.positions)
},
methods: {
initPositions(listData, itemSize) {
this.positions = listData.map((item, index) => {
return {
index,
top: index * itemSize,
bottom: (index + 1) * itemSize,
height: itemSize,
}
})
}
}
注:listData即数据列表,里面是每一项数据对应的内容。
列表总高度listHeight
的计算方式改变为缓存列表positions
最后一项的bottom
:
computed: {
listHeight() {
// return this.listData.length * this.itemSize;
+ return this.positions[this.positions.length - 1].bottom;
},
}
更新真实数据
在每次渲染后,获取真实DOM的高度去替换positions里的预估高度。
∵ updated生命周期在数据变化视图更新过后触发所以能获取到真实DOM
∴ 我们利用Vue的updated
钩子来实现这一功能
期间遍历真实列表的每一个节点,对比 节点 和 列表项 生成高度差dValue
判断是否需要更新:
updated() {
this.$nextTick(() => {
// 根据真实元素大小,修改对应的缓存列表
this.updatePositions()
})
},
methods: {
updatePositions() {
let nodes = this.$refs.items;
nodes.forEach((node) => {
// 获取 真实DOM高度
const { height } = node.getBoundingClientRect();
// 根据 元素索引 获取 缓存列表对应的列表项
const index = +node.id
let oldHeight = this.positions[index].height;
// dValue:真实高度与预估高度的差值 决定该列表项是否要更新
let dValue = oldHeight - height;
// 如果有高度差 !!dValue === true
if(dValue) {
// 更新对应列表项的 bottom 和 height
this.positions[index].bottom = this.positions[index].bottom - dValue;
this.positions[index].height = height;
// 依次更新positions中后续元素的 top bottom
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;
}
}
})
}
}
此外在更新完positions
后,当前窗口偏移量currentOffset
也要根据真实情况重新赋值:
updated() {
this.$nextTick(() => {
// 根据真实元素大小,更新对应的缓存列表
this.updatePositions()
// 更新完缓存列表后,重新赋值偏移量
this.currentOffset = this.getCurrentOffset()
})
},
methods: {
updatePositions() { //. . . }
getCurrentOffset() {
if(this.start >= 1) {
this.currentOffset = this.positions[this.start - 1].bottom
} else {
this.currentOffset = 0;
}
}
}
重写滚动回调
滚动触发的回调函数里计算了 开始/结束索引start/end
和 当前窗口偏移量currentOffset
,现在高度不固定后都需要重新计算,而结束索引依赖于开始索引所以不需要修改。
重新计算 开始索引start
定高时我们不必建立数组(建立了也只是重复的数据),直接根据scrollTop
与itemSize
计算索引即可
this.start = ~~(scrollTop / this.itemSize);
但不定高时,只能带着scrollTop
在列表中逐个寻找(后续使用搜索算法优化)。两个计算的最终目的都是找到当前位置对应的数据索引。
列表数据开始索引start
的计算方法修改为:遍历 缓存列表positions
匹配第一个大于当前滚动距离scrollTop
的项,并返回该项的索引。
mounted() {
. . . . . .
// 绑定滚动事件
let target = this.$refs.virtualList
let scrollFn = (event) => this.scrollEvent(event.target)
target.addEventListener("scroll", scrollFn);
},
methods: {
scrollEvent(target) {
const scrollTop = target.scrollTop;
// this.start = ~~(scrollTop / this.itemSize);
+ this.start = this.getStartIndex(scrollTop)
this.end = this.start + this.visibleCount;
this.currentOffset = scrollTop - (scrollTop % this.itemSize);
},
getStartIndex(scrollTop = 0) {
let item = this.positions.find(item => item && item.bottom > scrollTop);
return item.index;
}
},
重新计算 窗口偏移量currentOffset
滚动后立即根据positions
的预估值(此时数据还未更新)计算窗口偏移量currentOffset
:
scrollEvent() {
. . . . . .
// this.currentOffset = scrollTop - (scrollTop % this.itemSize);
this.currentOffset = this.getCurrentOffset()
},
优化
positions
是遍历listData
生成的,listData本是有序的,所以positions也是一个顺序数组。
Array.find方法 时间复杂度 ,查找 索引start
效率较低 ❌
二分查找十分适合顺序存储结构 时间复杂度,效率较高 ✔️
<script>
. . . . . .
var binarySearch = function(list, target) {
const len = list.length
let left = 0, right = len - 1
let tempIndex = null
while (left <= right) {
let midIndex = (left + right) >> 1
let midVal = list[midIndex].bottom
if (midVal === target) {
return midIndex
} else if (midVal < target) {
left = midIndex + 1
} else {
// list不一定存在与target相等的项,不断收缩右区间,寻找最匹配的项
if(tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex
}
right--
}
}
// 如果没有搜索到完全匹配的项 就返回最匹配的项
return tempIndex
};
export default {
. . . . . .
methods: {
. . . . . .
getStartIndex(scrollTop = 0) {
// let item = this.positions.find(i => i && i.bottom > scrollTop);
// return item.index;
+ return binarySearch(this.positions, scrollTop)
}
},
}
</script>
运行查看一下效果,不定高问题已经解决了
滚动缓冲
分析
上文中,为了正确计算不定高列表项,同时在 updated生命周期 和 滚动回调 中增加了额外操作,这都增加了浏览器负担。
因此快速滚动列表时,我们很明显的观察到白屏闪烁的情况,即滚动后,先加载出白屏内容(没有渲染)然后迅速替换为表格内容,制造出一种闪烁的现象。
注:白屏闪烁是浏览器性能低导致的,事件循环中的渲染操作没有跟上窗口的滚动,额外操作只是加剧了这种情况。
方案
为了使页面平滑滚动,我们在原先的列表结构上再加入缓冲区,渲染区域由可视区+缓冲区共同组成,这给滚动回调和页面渲染更多处理时间。
用户在可视区滚动,脱离可视区后立即进入缓冲区,同时渲染下一部分可视区的数据。在脱离缓冲区后新的数据大概率也渲染完成了。
而缓冲区域包含多少个元素呢?
我们创建一个变量表示比例数值,这个比例数值是相对于 最大可见列表项数 的,根据这个 相对比例 和 开始/结束索引 计算上下缓冲区的大小。
对渲染流程有什么影响?
列表显示数据 原先是根据索引计算,现在额外加入上下缓冲区大小重新计算,会额外渲染缓冲元素。
实现
创建一个属性代表比例值:
data: {
bufferPercent: 0.5, // 即每个缓冲区只缓冲 0.5 * 最大可见列表项数 个元素
},
创建三个计算属性,分别代表 缓冲区标准多少个元素 + 上下缓冲区实际包含多少个元素:
computed: {
bufferCount() {
return this.visibleCount * this.bufferPercent >> 0; // 向下取整
},
// 使用索引和缓冲数量的最小值 避免缓冲不存在或者过多的数据
aboveCount() {
return Math.min(this.start, this.bufferCount);
},
belowCount() {
return Math.min(this.listData.length - this.end, this.bufferCount);
},
}
重写 列表显示数据visibleData
的计算方式:
computed: {
visibleData() {
// return this.listData.slice(this.start, this.end);
+ return this.listData.slice(this.start - this.aboveCount, this.end + this.belowCount);
},
}
因为多出了缓冲区域所以窗口偏移量currentOffset
也要根据缓冲区的内容重新计算:
getCurrentOffset() {
if(this.start >= 1) {
// return this.positions[this.start - 1].bottom;
let size = this.positions[this.start].top - (
this.positions[this.start - this.aboveCount] ?
this.positions[this.start - this.aboveCount].top : 0);
// 计算偏移量时包括上缓冲区的列表项
return this.positions[this.start - 1].bottom - size;
} else {
return 0;
}
}
运行看一下效果,闪烁问题已经完美解决了。
异步加载
其实在列表项中包含图片的场景,图片多为高度固定的缩略图,只需要在计算时根据图给每个列表项加一个固定高度,多于一行的图片直接省略。这样异步加载对于虚拟列表就没有影响了。
如果实在要处理图片不定高的场景,只有在列表中的图片完全加载后再重新更新positions
了,利用Image.onload
或DOM.resizeObserver
在异步加载后回调滚动函数。我试了下应该都是可行的。
<div v-for="item in visibleData" :key="item.id" :id="item.id" ref="items" class="list-item">
{{ item.value }}
<img :src="item.img" @load="updatePositions" />
</div>
or
mounted() {
let content = this.$refs.content
let resizeObserver = new ResizeObserver(() => this.updatePositions())
resizeObserver.observe(content)
},
懒加载数据
一次性请求大量数据可能会使后端处理时间增加,过大的响应体也会导致整体请求响应耗时增加,用户等待时间较长体感较差。
因此我们结合懒加载的方式,在每次滚动触底时加载部分新数据并更新positions
,避免单次请求等待时间过长。
// 滚动回调
scrollEvent(target) {
const { scrollTop, scrollHeight, clientHeight } = target;
this.start = this.getStartIndex(scrollTop);
this.end = this.start + this.visibleCount;
this.currentOffset = this.getCurrentOffset()
// 触底
if ((scrollTop + clientHeight) === scrollHeight) {
// 模拟数据请求
let len = this.listData.length + 1
for (let i = len; i <= len + 100; i++) {
this.listData.push({id: i, value: i + '字符内容'.repeat(Math.random() * 20) })
}
this.initPositions(this.listData, this.preItemSize)
}
},
有些同学可能会想,懒加载时初始数据量较少,会导致滚动条很短,间接给用户一种数据量很少的错觉。
对于这种情况我们需要跟后端做好协调,接口返回的数据格式大致规定为这样
data: {
page: 1,
size: 1000,
count: 10000,
list: [1...1000],
updateTime: '...',
. . . . . .
}
然后使用data.count
初始化positions
,在后续懒加载到对应索引的数据时,替换positions
里的内容。
总结
在最后我们简单总结一下,为了优化虚拟列表我们做了哪些操作。
- 不定高:由于很难在渲染之前拿到元素真实高度,我们采取预估高度初始化后重新渲染的方案来正确渲染不定高内容。并重写了滚动回调函数和部分与
itemSize
相关的计算属性。 - 缓冲区:为了解决性能低时数据渲染不及时造成的白屏闪烁,我们创建上下缓冲区额外渲染数据,为可视区的渲染提供更多缓冲时间。为此要重写
start/end
和currentOffset
的计算方式。 - 异步加载:如果一定要处理列表异步加载不定高元素的场景,我们通过
img.onload
和ResizeObserver
在加载完成后更新列表。 - 懒加载:一次性大量数据的请求可能会导致请求响应时间变长,我们使用触底加载新数据并更新
positions
的方式来分化单次请求的数据量。
题外话
因为怕一次性内容太多读者看着头疼 (就是我自己🙈),所以将虚拟列表拆成两篇文章。
虽然在阅读连贯性上存在一定缺陷,但我觉得 一篇基础 + 一篇进阶 这种节奏还是挺好的~
回想起我写作之路的第一篇文章就是与虚拟列表相关,回头一看已经一年了,不禁感慨。
写作期间收获颇多,还有各位掘友的支持也让我有更多动力走下去 (强烈暗示😁)
结语🎉
后续更新了IntersectionObserver
简单实现思路,欢迎查阅:IntersectionObserver:实现滚动动画、懒加载、虚拟列表...
虚拟列表整体实现下来是非常考验代码实践能力和问题解决能力的,希望你能有所收获。
不要光看不实践哦,后续会持续更新前端相关的知识 😉
脚踏实地不水文,真的不关注一下吗~
写作不易,如果觉得有收获还望大家点赞、收藏 🌹
才疏学浅,如有问题或建议欢迎大家指教。