「前端进阶」高性能渲染十万条数据(虚拟列表)
myzbx 2024-12-06 17:40 12 浏览
前言
在工作中,有时会遇到需要一些不能使用分页方式来加载列表数据的业务情况,对于此,我们称这种列表叫做长列表。比如,在一些外汇交易系统中,前端会实时的展示用户的持仓情况(收益、亏损、手数等),此时对于用户的持仓列表一般是不能分页的。
在高性能渲染十万条数据(时间分片)一文中,提到了可以使用时间分片的方式来对长列表进行渲染,但这种方式更适用于列表项的DOM结构十分简单的情况。本文会介绍使用虚拟列表的方式,来同时加载大量数据。
为什么需要使用虚拟列表
假设我们的长列表需要展示10000条记录,我们同时将10000条记录渲染到页面中,先来看看需要花费多长时间:
<button id="button">button</button><br>
<ul id="container"></ul>
复制代码
document.getElementById('button').addEventListener('click',function(){
// 记录任务开始时间
let now = Date.now();
// 插入一万条数据
const total = 10000;
// 获取容器
let ul = document.getElementById('container');
// 将数据插入容器中
for (let i = 0; i < total; i++) {
let li = document.createElement('li');
li.innerText = ~~(Math.random() * total)
ul.appendChild(li);
}
console.log('JS运行时间:',Date.now() - now);
setTimeout(()=>{
console.log('总运行时间:',Date.now() - now);
},0)
// print JS运行时间: 38
// print 总运行时间: 957
})
复制代码
当我们点击按钮,会同时向页面中加入一万条记录,通过控制台的输出,我们可以粗略的统计到,JS的运行时间为38ms,但渲染完成后的总时间为957ms。
简单说明一下,为何两次console.log的结果时间差异巨大,并且是如何简单来统计JS运行时间和总渲染时间:
- 在 JS 的Event Loop中,当JS引擎所管理的执行栈中的事件以及所有微任务事件全部执行完后,才会触发渲染线程对页面进行渲染
- 第一个console.log的触发时间是在页面进行渲染之前,此时得到的间隔时间为JS运行所需要的时间
- 第二个console.log是放到 setTimeout 中的,它的触发时间是在渲染完成,在下一次Event Loop中执行的
关于Event Loop的详细内容请参见这篇文章-->
然后,我们通过Chrome的Performance工具来详细的分析这段代码的性能瓶颈在哪里:
从Performance可以看出,代码从执行到渲染结束,共消耗了960.8ms,其中的主要时间消耗如下:
- Event(click) : 40.84ms
- Recalculate Style : 105.08ms
- Layout : 731.56ms
- Update Layer Tree : 58.87ms
- Paint : 15.32ms
从这里我们可以看出,我们的代码的执行过程中,消耗时间最多的两个阶段是Recalculate Style和Layout。
- Recalculate Style:样式计算,浏览器根据css选择器计算哪些元素应该应用哪些规则,确定每个元素具体的样式。
- Layout:布局,知道元素应用哪些规则之后,浏览器开始计算它要占据的空间大小及其在屏幕的位置。
在实际的工作中,列表项必然不会像例子中仅仅只由一个li标签组成,必然是由复杂DOM节点组成的。
那么可以想象的是,当列表项数过多并且列表项结构复杂的时候,同时渲染时,会在Recalculate Style和Layout阶段消耗大量的时间。
而虚拟列表就是解决这一问题的一种实现。
什么是虚拟列表
虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。
假设有1万条记录需要同时渲染,我们屏幕的可见区域的高度为500px,而列表项的高度为50px,则此时我们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,我们只需加载10条即可。
说完首次加载,再分析一下当滚动发生时,我们可以通过计算当前滚动值得知此时在屏幕可见区域应该显示的列表项。
假设滚动发生,滚动条距顶部的位置为150px,则我们可得知在可见区域内的列表项为第4项至`第13项。
实现
虚拟列表的实现,实际上就是在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。
- 计算当前可视区域起始数据索引(startIndex)
- 计算当前可视区域结束数据索引(endIndex)
- 计算当前可视区域的数据,并渲染到页面中
- 计算startIndex对应的数据在整个列表中的偏移位置startOffset并设置到列表上
由于只是对可视区域内的列表项进行渲染,所以为了保持列表容器的高度并可正常的触发滚动,将Html结构设计成如下结构:
<div class="infinite-list-container">
<div class="infinite-list-phantom"></div>
<div class="infinite-list">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</div>
</div>
复制代码
- infinite-list-container 为可视区域的容器
- infinite-list-phantom 为容器内的占位,高度为总列表高度,用于形成滚动条
- infinite-list 为列表项的渲染区域
接着,监听infinite-list-container的scroll事件,获取滚动位置scrollTop
- 假定可视区域高度固定,称之为screenHeight
- 假定列表每项高度固定,称之为itemSize
- 假定列表数据称之为listData
- 假定当前滚动位置称之为scrollTop
则可推算出:
- 列表总高度listHeight = listData.length * itemSize
- 可显示的列表项数visibleCount = Math.ceil(screenHeight / itemSize)
- 数据的起始索引startIndex = Math.floor(scrollTop / itemSize)
- 数据的结束索引endIndex = startIndex + visibleCount
- 列表显示数据为visibleData = listData.slice(startIndex,endIndex)
当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式控制将渲染区域偏移至可视区域中。
- 偏移量startOffset = scrollTop - (scrollTop % itemSize);
最终的简易代码如下:
<template>
<div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
<div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
<div class="infinite-list" :style="{ transform: getTransform }">
<div ref="items"
class="infinite-list-item"
v-for="item in visibleData"
:key="item.id"
:style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }"
>{{ item.value }}</div>
</div>
</div>
</template>
复制代码
export default {
name:'VirtualList',
props: {
//所有列表数据
listData:{
type:Array,
default:()=>[]
},
//每项高度
itemSize: {
type: Number,
default:200
}
},
computed:{
//列表总高度
listHeight(){
return this.listData.length * this.itemSize;
},
//可显示的列表项数
visibleCount(){
return Math.ceil(this.screenHeight / this.itemSize)
},
//偏移量对应的style
getTransform(){
return `translate3d(0,${this.startOffset}px,0)`;
},
//获取真实显示列表数据
visibleData(){
return this.listData.slice(this.start, Math.min(this.end,this.listData.length));
}
},
mounted() {
this.screenHeight = this.$el.clientHeight;
this.start = 0;
this.end = this.start + this.visibleCount;
},
data() {
return {
//可视区域高度
screenHeight:0,
//偏移量
startOffset:0,
//起始索引
start:0,
//结束索引
end:null,
};
},
methods: {
scrollEvent() {
//当前滚动位置
let scrollTop = this.$refs.list.scrollTop;
//此时的开始索引
this.start = Math.floor(scrollTop / this.itemSize);
//此时的结束索引
this.end = this.start + this.visibleCount;
//此时的偏移量
this.startOffset = scrollTop - (scrollTop % this.itemSize);
}
}
};
复制代码
点击查看在线DEMO及完整代码
最终效果如下:
列表项动态高度
在之前的实现中,列表项的高度是固定的,因为高度固定,所以可以很轻易的获取列表项的整体高度以及滚动时的显示数据与对应的偏移量。而实际应用的时候,当列表中包含文本之类的可变内容,会导致列表项的高度并不相同。
比如这种情况:
在虚拟列表中应用动态高度的解决方案一般有如下三种:
1.对组件属性itemSize进行扩展,支持传递类型为数字、数组、函数
- 可以是一个固定值,如 100,此时列表项是固高的
- 可以是一个包含所有列表项高度的数据,如 [50, 20, 100, 80, ...]
- 可以是一个根据列表项索引返回其高度的函数:(index: number): number
这种方式虽然有比较好的灵活度,但仅适用于可以预先知道或可以通过计算得知列表项高度的情况,依然无法解决列表项高度由内容撑开的情况。
2.将列表项渲染到屏幕外,对其高度进行测量并缓存,然后再将其渲染至可视区域内。
由于预先渲染至屏幕外,再渲染至屏幕内,这导致渲染成本增加一倍,这对于数百万用户在低端移动设备上使用的产品来说是不切实际的。
3.以预估高度先行渲染,然后获取真实高度并缓存。
这是我选择的实现方式,可以避免前两种方案的不足。
接下来,来看如何简易的实现:
定义组件属性estimatedItemSize,用于接收预估高度
props: {
//预估高度
estimatedItemSize:{
type:Number
}
}
复制代码
定义positions,用于列表项渲染后存储每一项的高度以及位置信息,
this.positions = [
// {
// top:0,
// bottom:100,
// height:100
// }
];
复制代码
并在初始时根据estimatedItemSize对positions进行初始化。
initPositions(){
this.positions = this.listData.map((item,index)=>{
return {
index,
height:this.estimatedItemSize,
top:index * this.estimatedItemSize,
bottom:(index + 1) * this.estimatedItemSize
}
})
}
复制代码
由于列表项高度不定,并且我们维护了positions,用于记录每一项的位置,而列表高度实际就等于列表中最后一项的底部距离列表顶部的位置。
//列表总高度
listHeight(){
return this.positions[this.positions.length - 1].bottom;
}
复制代码
由于需要在渲染完成后,获取列表每项的位置信息并缓存,所以使用钩子函数updated来实现:
updated(){
let nodes = this.$refs.items;
nodes.forEach((node)=>{
let rect = node.getBoundingClientRect();
let height = rect.height;
let index = +node.id.slice(1)
let oldHeight = this.positions[index].height;
let dValue = oldHeight - height;
//存在差值
if(dValue){
this.positions[index].bottom = this.positions[index].bottom - dValue;
this.positions[index].height = height;
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;
}
}
})
}
复制代码
滚动后获取列表开始索引的方法修改为通过缓存获取:
//获取列表起始索引
getStartIndex(scrollTop = 0){
let item = this.positions.find(i => i && i.bottom > scrollTop);
return item.index;
}
复制代码
由于我们的缓存数据,本身就是有顺序的,所以获取开始索引的方法可以考虑通过二分查找的方式来降低检索次数:
//获取列表起始索引
getStartIndex(scrollTop = 0){
//二分法查找
return this.binarySearch(this.positions,scrollTop)
},
//二分法查找
binarySearch(list,value){
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while(start <= end){
let midIndex = parseInt((start + end)/2);
let midValue = list[midIndex].bottom;
if(midValue === value){
return midIndex + 1;
}else if(midValue < value){
start = midIndex + 1;
}else if(midValue > value){
if(tempIndex === null || tempIndex > midIndex){
tempIndex = midIndex;
}
end = end - 1;
}
}
return tempIndex;
},
复制代码
滚动后将偏移量的获取方式变更:
scrollEvent() {
//...省略
if(this.start >= 1){
this.startOffset = this.positions[this.start - 1].bottom
}else{
this.startOffset = 0;
}
}
复制代码
通过faker.js 来创建一些随机数据
let data = [];
for (let id = 0; id < 10000; id++) {
data.push({
id,
value: faker.lorem.sentences() // 长文本
})
}
复制代码
点击查看在线DEMO及完整代码
最终效果如下:
从演示效果上看,我们实现了基于文字内容动态撑高列表项情况下的虚拟列表,但是我们可能会发现,当滚动过快时,会出现短暂的白屏现象。
为了使页面平滑滚动,我们还需要在可见区域的上方和下方渲染额外的项目,在滚动时给予一些缓冲,所以将屏幕分为三个区域:
- 可视区域上方:above
- 可视区域:screen
- 可视区域下方:below
定义组件属性bufferScale,用于接收缓冲区数据与可视区数据的比例
props: {
//缓冲区比例
bufferScale:{
type:Number,
default:1
}
}
复制代码
可视区上方渲染条数aboveCount获取方式如下:
aboveCount(){
return Math.min(this.start,this.bufferScale * this.visibleCount)
}
复制代码
可视区下方渲染条数belowCount获取方式如下:
belowCount(){
return Math.min(this.listData.length - this.end,this.bufferScale * this.visibleCount);
}
复制代码
真实渲染数据visibleData获取方式如下:
visibleData(){
let start = this.start - this.aboveCount;
let end = this.end + this.belowCount;
return this._listData.slice(start, end);
}
复制代码
点击查看在线DEMO及完整代码
最终效果如下:
基于这个方案,个人开发了一个基于Vue2.x的虚拟列表组件:vue-virtual-listview,可点击查看完整代码。
面向未来
在前文中我们使用监听scroll事件的方式来触发可视区域中数据的更新,当滚动发生后,scroll事件会频繁触发,很多时候会造成重复计算的问题,从性能上来说无疑存在浪费的情况。
可以使用IntersectionObserver替换监听scroll事件,IntersectionObserver可以监听目标元素是否出现在可视区域内,在监听的回调事件中执行可视区域数据的更新,并且IntersectionObserver的监听回调是异步触发,不随着目标元素的滚动而触发,性能消耗极低。
遗留问题
我们虽然实现了根据列表项动态高度下的虚拟列表,但如果列表项中包含图片,并且列表高度由图片撑开,由于图片会发送网络请求,此时无法保证我们在获取列表项真实高度时图片是否已经加载完成,从而造成计算不准确的情况。
这种情况下,如果我们能监听列表项的大小变化就能获取其真正的高度了。我们可以使用ResizeObserver来监听列表项内容区域的高度改变,从而实时获取每一列表项的高度。
不过遗憾的是,在撰写本文的时候,仅有少数浏览器支持ResizeObserver。
参考
- 浅说虚拟列表的实现原理
- react-virtualized组件的虚拟列表实现
- React和无限列表
- 再谈前端虚拟列表的实现
相关推荐
- Django零基础速成指南:快速打造带用户系统的博客平台
-
#python##服务器##API##编程##学习#不是所有教程都值得你花时间!这篇实战指南将用5分钟带你解锁Django核心技能,手把手教你从零搭建一个具备用户注册登录、文章管理功能的完整...
- iOS 17.0 Bootstrap 1.2.9 半越狱来啦!更新两点
-
这款Bootstrap半越狱工具终于更新,离上一次更新已相隔很久,现在推出1.2.9版本,主要为内置两点功能进行更新,也是提升半越狱的稳定性。如果你正在使用这款半越狱工具的,建议你更新。注意!...
- iOS 16.x Bootstrap 1.2.3 发布,支持运行清理工具
-
本文主要讲Bootstrap半越狱工具更新相关内容。如果你是iOS16.0至16.6.1和17.0系统的,想体验半越狱的果粉,请继续往下看。--知识点科普--Bootstrap...
- SpringBoot整合工作流引擎Acticiti系统,适用于ERP、OA系统
-
今日推荐:SpringBoot整合工作流引擎Acticiti的源码推荐理由:1、SpringBoot整合工作流引擎Acticiti系统2、实现了三级权限结构3、持久层使用了mybatis框架4、流程包...
- SpringCloud自定义Bootstrap配置指南
-
在SpringCloud中自定义Bootstrap配置需要以下步骤,以确保在应用启动的早期阶段加载自定义配置:1.添加依赖(针对新版本SpringCloud)从SpringCloud2020...
- Python使用Dash开发网页应用(三)(python网页开发教程)
-
PlotlyDash开发Web应用示例一个好的网页设计通常都需要编写css甚至js来定制前端内容,例如非常流行的bootstrap框架。我们既然想使用Dash来搭建web应用,很大的一个原因是不熟悉...
- Oxygen XML Editor 27.1 中的新功能
-
OxygenXMLEditor27.1版是面向内容作者、开发者、合作者和出版商的行业领先工具包的增量版本。在27.1版本中,AIPositronAssistant得到了增强,包括用于...
- 【LLM-多模态】Mini-Gemini:挖掘多模态视觉语言模型的潜力
-
一、结论写在前面论文提出了Mini-Gemini,一个精简而强大的多模态VLM框架。Mini-Gemini的本质在于通过战略性框架设计、丰富的数据质量和扩展的功能范围,发掘VLM的潜在能力。其核心是补...
- 谐云课堂 | 一文详解分布式改造理论与实战
-
01微服务与分布式什么是分布式?首先,我们对上图提到的部分关键词进行讲解。单体,是指一个进程完成全部的后端处理;水平拆分,是同一个后端多环境部署,他们都处理相同的内容,使用反向代理来均衡负载,这种也叫...
- 基于Abaqus的手动挡换挡机构可靠性仿真
-
手动挡,也称手动变速器,英文全称为Manualtransmission,简称MT,即用手拨动换挡操纵总成才能改变变速器内的齿轮啮合位置,改变传动比,从而达到变速的目的。家用轿车主要采用软轴连接的换挡...
- 【pytorch】目标检测:彻底搞懂YOLOv5详解
-
YOLOv5是GlennJocher等人研发,它是Ultralytics公司的开源项目。YOLOv5根据参数量分为了n、s、m、l、x五种类型,其参数量依次上升,当然了其效果也是越来越好。从2020...
- 超实用!50个非常实用的PS快捷键命令大全分享
-
今天,给大家介绍50个非常实用的快捷键命令大全,大家伙都是设计师,关于软件使用那是越快越好啊。一、常用的热键组合1、图层混合模式快捷键:正常(Shift+Option+N),正片叠底(Shif...
- Pohtoshop中深藏不露的小技巧(科目一考试技巧记忆口诀看完必过)
-
邢帅教育ps教程为大家总结了一些Pohtoshop中深藏不露的小技巧,可以帮助到大家在设计时减少不必要的麻烦,提高工作效率哦~~~1.设置网格线保持像素完美不在1:1分辨率下也能保持像素完美,可以...
- Ganglia监控安装总结(监控安装工作总结)
-
一、ganglia简介:Ganglia是一个跨平台可扩展的,高性能计算系统下的分布式监控系统,如集群和网格。它是基于分层设计,它使用广泛的技术,如XML数据代表,便携数据传输,RRDtool用于数据...
- 谁说Adobe XD做不出好看的设计?那是你没搞懂这些功能
-
AdobeXD的美化栏具有将设计视图美化的功能,它能使界面设计和原型设计更漂亮、更吸引眼球。美化栏的7个功能包括竖线布局设计、横线布局设计、重复网格、图形大小和位置设置、响应式调整大小、文字美化以及...
- 一周热门
- 最近发表
-
- Django零基础速成指南:快速打造带用户系统的博客平台
- iOS 17.0 Bootstrap 1.2.9 半越狱来啦!更新两点
- iOS 16.x Bootstrap 1.2.3 发布,支持运行清理工具
- SpringBoot整合工作流引擎Acticiti系统,适用于ERP、OA系统
- SpringCloud自定义Bootstrap配置指南
- Python使用Dash开发网页应用(三)(python网页开发教程)
- Oxygen XML Editor 27.1 中的新功能
- 【LLM-多模态】Mini-Gemini:挖掘多模态视觉语言模型的潜力
- 谐云课堂 | 一文详解分布式改造理论与实战
- 基于Abaqus的手动挡换挡机构可靠性仿真
- 标签列表
-
- HTML 基础教程 (29)
- HTML 简介 (30)
- HTML 响应式设计 (31)
- HTML URL 编码 (32)
- HTML Web 服务器 (31)
- HTML 表单属性 (32)
- HTML 音频 (31)
- HTML5 支持 (33)
- HTML API (36)
- HTML 总结 (32)
- HTML 全局属性 (32)
- HTML 事件 (31)
- HTML 画布 (32)
- HTTP 方法 (30)
- 键盘快捷键 (30)
- CSS 语法 (35)
- CSS 选择器 (30)
- CSS 轮廓 (30)
- CSS 轮廓宽度 (31)
- CSS 谷歌字体 (33)
- CSS 链接 (31)
- CSS 中级教程 (30)
- CSS 定位 (31)
- CSS 图片库 (32)
- CSS 图像精灵 (31)