首页>>前端>>JavaScript->虚拟滚动

虚拟滚动

时间:2023-11-30 本站 点击:1

一、前言

做前端开发,难以避免的要和无限滚动加载这类交互打交道,简单的滚动加载大家都知道,监听滚动行为和位置,然后发请求加载下一屏数据。在当前vue/react大行其道的时代,数据驱动视图更新,修改数据来新增dom节点到列表元素中就可以实现,而且这种行为在大部分产品或场景中都没多大问题,但当代随着社交媒体的流行,大量的视频、图片、文字等数据被用户消费,传统的拼dom元素的滚动方案在性能上就存在下次,海量的数据早就大量的元素节点产生,从而会导致页面滚动的卡顿,那么有什么好的方案让用户浏览海量数据?

二、谷歌LightHouse开发推荐

chrome影响页面性能的因素:

总共有超过1,500个节点。

具有大于32个节点的深度。

有一个超过60个子节点的父节点。

三、效果对比

内容社交消耗是目前网上用户消费最多,而且加载数据最多的一种类型,下方是在数据大概2000条左右常规加载和虚拟滚动实现的效果对比:

常规方案在数据量小,dom元素少的情况下,也是非常流畅,但是在数据量达到一定程度,dom元素量过大时,渲染时间就会急剧增多,滚动将变得滞后,灵敏度下降。

四、滚动方式介绍

原理:用固定个数的元素来模拟无线滚动加载,通过位置的布局来达到滚动后的效果

由于无限滚动的列表元素高度是和产品设计的场景有关,有定高的,也有不定高的。

定高:滚动列表中子元素高度相等。

不定高:滚动列表中子元素高度都随机且不相等。

4.1不定高方案

由于开发中遇到的不一定是等高的元素,例如刚开始所看到的内容,有好几类内容交互卡片,纯文字的,文字加视频的,文字加一张图的,文字加多张图的,纯图片的,纯文字的等等。元素卡片高度都是不相等的。高度不定,而且返回的内容是不固定的,所以每次返回的内容的可能组成非常多的方式,这样上面的方案就不适用了。

4.1.1实现原理

通过观察者方式,来观察元素是否进入视口。我们会对固定元素的第一个和最后一个分别打上标签,例如把第一个元素的id设置为top,把最后一个元素的id值设置为bottom。

此时调用异步的api:IntersectionObserver,他能获取到进入到视口的元素,判断当前进入视口的元素是最后个元素,则说明内容是往上滚的,如果进入视口的是第一个元素,则说明内容是往下滚的。

我们依次保存下当前第一个元素距离顶部的高度和距离底部的高度,赋值给滚动内容元素的paddingTop和paddingBottom,这样内容区域的高度就不会坍塌,依旧保持这传统滚动元素充满列表时的内容高度:

4.1.2滚动效果

4.1.3实现方式

我们首先定义交叉观察者:IntersectionObserver。

IntersectionObserverAPI是异步的,不随着目标元素的滚动同步触发,性能消耗极低。

获取到指定元素,然后让其进行绑定监听

constbox=document.querySelector('xxxx');constintersectionObserver=newIntersectionObserver((entries)=>{entries.forEach((entry)=>{if(entry.isIntersecting){//进入可视区域....}})},{threshold:[0,0.5],root:document.querySelector('.wrap'),rootMargin:"10px10px30px20px",});intersectionObserver.observe(box);

元素绑定位置,在元素的第一个值和最后一个值上挂上ref,方便获取指定的dom元素,这里我们一共使用20个元素来实现无线滚动加载

constNODENUM=20;...const$bottomElement=useRef();const$topElement=useRef();const$downAnchor:any=useRef();const$upAnchor:any=useRef();...constgetReference=(index)=>{switch(index){case0:return$topElement;case5:return$upAnchor;case10:return$downAnchor;case(NODENUM-1):return$bottomElement;default:returnnull;}}

定义起始元素和截止元素,在第一个元素和最后一个元素上绑定id值:top&&bottom。通过调用getReference方法获取ref变量绑定在元素上

<divclassName="container"><divref={$wrap}style={{paddingTop:`${paddingTop}px`,paddingBottom:`${paddingBottom}px`,'position':'relative'}}>{currentArr.slice(start,end).map((item,index)=>{constrefVal=getReference(index);constid=index===0?'top':(index===(NODENUM-1)?'bottom':'');constclassValue=index%4===0?'test':''return<divid={id}ref={refVal}key={item}><divclassName={`item${classValue}`}>{item}</div></div>})}</div></div>

接下来就是核心监听滚动的处理逻辑了,由于IntersectionObserver接收一个回调函数,传入回调函数的参数为该元素的一些相关属性值,我们通过对元素的id进行判断来区分当前是向上滚动还是向下滚动,同时改变起始值start和结束值end去切分数据数组,通过数据去驱动视图重新渲染。

entry中包含的isIntersecting表示是否进入可视区域。

entry.target.id则为进入可视区域的元素的id,我们在第0个元素上绑定id为top,在最后一个元素上绑定id为bottom

constcallback=(entries,observer)=>{entries.forEach((entry,index)=>{constlistLength=currentArr.length;//向下滚动if(entry.isIntersecting&&entry.target.id==="bottom"){constmaxStartIndex=listLength-1-NODENUM;constmaxEndIndex=listLength-1;constnewStart=(end-10)<=maxStartIndex?end-10:maxStartIndex;constnewEnd=(end+10)<=maxEndIndex?end+10:maxEndIndex;//加载更多数据,这里模拟了每次拉新数据40条if(newEnd+10>=maxEndIndex&&!config.current.isRequesting&&true){currentArr.push(...arr.slice(i*40,(i+1)*40))i++;}if(end+10>maxEndIndex)return;updateState(newStart,newEnd,true);}//向上滚动if(entry.isIntersecting&&entry.target.id==="top"){constnewEnd=end===NODENUM?NODENUM:(end-10>NODENUM?end-10:NODENUM);constnewStart=start===0?0:(start-10>0?start-10:0);updateState(newStart,newEnd,false);}});}

封装一个paddingTop和paddingBottom更新的函数,如果有当前的padding值,则取出渲染,如果没有,则保存下当前的paddingTop和paddingBottom。

constupdateState=(newStart,newEnd,isDown)=>{if(config.current.setting)return;config.current.syncStart=newStart;if(start!==newStart||end!==newEnd){config.current.setting=true;setStart(newStart);setEnd(newEnd);constpage=~~(newStart/10)-1;if(isDown){//向下newStart!==0&&!config.current.paddingTopArr[page]&&(config.current.paddingTopArr[page]=$downAnchor.current.offsetTop);//setPaddingTop(check?config.current.paddingTopArr[page]:$downAnchor.current.offsetTop);setPaddingTop(config.current.paddingTopArr[page]);setPaddingBottom(config.current.paddingBottomArr[page]||0);}else{//向上//constnewPaddingBottom=$wrap.current.scrollHeight-$upAnchor.current.offsetTop;constnewPaddingBottom=$wrap.current.scrollHeight-$downAnchor.current.offsetTop;newStart!==0&&(config.current.paddingBottomArr[page]=newPaddingBottom);setPaddingTop(config.current.paddingTopArr[page]||0);//setPaddingBottom(check?config.current.paddingBottomArr[page]:newPaddingBottom);setPaddingBottom(config.current.paddingBottomArr[page]);}setTimeout(()=>{config.current.setting=false;},0);}}

4.1.4白屏问题

在开发过程中发现,数据量非常大的情况下,快速滚动页面,由于api是异步的,导致更新方法触发没跟上,所以需要对滚动事件做一个监听,判断是否当前的滚动高度已经更新过了,没更新则通过滚动结束后强行更新,当然,一定要记得对这个滚动监听事件加一个节流

4.1.5兼容问题

在safari的兼容问题,可以使用polyfill来解决。

4.1.6整体代码

//index.less.item{width:100vw;height:240rpx;border-bottom:2rpxsolidblack;}.test{height:110rpx;}.container{width:100vw;height:100vh;overflow:scroll;-webkit-overflow-scrolling:touch;}
//index.tsximport{createElement,useEffect,useRef,useState}from"rax";import"./index.less";constarr=[];//模拟一共有2万条数据for(leti=0;i<20000;i++){arr.push(i);}leti=1;//默认第一屏取2页数据constcurrentArr=arr.slice(0,40),screenH=window.screen.height;constNODENUM=20;functionthrottle(fn,wait){vartimeout;returnfunction(){varctx=this,args=arguments;clearTimeout(timeout);timeout=setTimeout(function(){fn.apply(ctx,args);},wait);};}functionIndex(props){const[start,setStart]=useState(0);const[end,setEnd]=useState(NODENUM);const[paddingTop,setPaddingTop]=useState(0);const[paddingBottom,setPaddingBottom]=useState(0);const[observer,setObserver]=useState(null);const$bottomElement=useRef();const$topElement=useRef();const$downAnchor:any=useRef();//定位paddingTop的距离const$upAnchor:any=useRef();//定位paddingBottom的距离const$wrap:any=useRef();//协助定位paddingBottom的距离constcontainer=useRef();constconfig=useRef({isRequesting:false,paddingTopArr:[],//paddingTop数据栈paddingBottomArr:[],//paddingBottom数据栈preScrollTop:0,syncStart:0,setting:false,});constgetReference=(index)=>{switch(index){case0:return$topElement;case5:return$upAnchor;case10:return$downAnchor;case(NODENUM-1):return$bottomElement;default:returnnull;}}constresetObservation=()=>{observer&&observer.unobserve($bottomElement.current);observer&&observer.unobserve($topElement.current);}constintiateScrollObserver=()=>{constoptions={root:null,rootMargin:'0px',threshold:0.1};constObserver=newIntersectionObserver(callback,options);if($topElement.current){Observer.observe($topElement.current);}if($bottomElement.current){Observer.observe($bottomElement.current);}setObserver(Observer);}constcallback=(entries,observer)=>{entries.forEach((entry,index)=>{constlistLength=currentArr.length;//向下滚动if(entry.isIntersecting&&entry.target.id==="bottom"){constmaxStartIndex=listLength-1-NODENUM;constmaxEndIndex=listLength-1;constnewStart=(end-10)<=maxStartIndex?end-10:maxStartIndex;constnewEnd=(end+10)<=maxEndIndex?end+10:maxEndIndex;if(newEnd+10>=maxEndIndex&&!config.current.isRequesting&&true){currentArr.push(...arr.slice(i*40,(i+1)*40))i++;}if(end+10>maxEndIndex)return;updateState(newStart,newEnd,true);}//向上滚动if(entry.isIntersecting&&entry.target.id==="top"){constnewEnd=end===NODENUM?NODENUM:(end-10>NODENUM?end-10:NODENUM);constnewStart=start===0?0:(start-10>0?start-10:0);updateState(newStart,newEnd,false);}});}constupdateState=(newStart,newEnd,isDown)=>{if(config.current.setting)return;config.current.syncStart=newStart;if(start!==newStart||end!==newEnd){config.current.setting=true;setStart(newStart);setEnd(newEnd);constpage=~~(newStart/10)-1;if(isDown){//向下newStart!==0&&!config.current.paddingTopArr[page]&&(config.current.paddingTopArr[page]=$downAnchor.current.offsetTop);//setPaddingTop(check?config.current.paddingTopArr[page]:$downAnchor.current.offsetTop);setPaddingTop(config.current.paddingTopArr[page]);setPaddingBottom(config.current.paddingBottomArr[page]||0);}else{//向上//constnewPaddingBottom=$wrap.current.scrollHeight-$upAnchor.current.offsetTop;constnewPaddingBottom=$wrap.current.scrollHeight-$downAnchor.current.offsetTop;newStart!==0&&(config.current.paddingBottomArr[page]=newPaddingBottom);setPaddingTop(config.current.paddingTopArr[page]||0);//setPaddingBottom(check?config.current.paddingBottomArr[page]:newPaddingBottom);setPaddingBottom(config.current.paddingBottomArr[page]);}setTimeout(()=>{config.current.setting=false;},0);}}useEffect(()=>{document.getElementsByClassName('container')[0].addEventListener('scroll',scrollEventListner);},[])useEffect(()=>{resetObservation();intiateScrollObserver();},[end,currentArr])constscrollEventListner=throttle(function(event){constscrollTop=document.getElementsByClassName('container')[0].scrollTop;letindex=config.current.paddingTopArr.findIndex(e=>e>scrollTop);index=index<=0?0:index;constlen=config.current.paddingTopArr.length;len&&(config.current.paddingTopArr[len-1]<scrollTop)&&(index=len);constnewStart=index*10;constnewEnd=index*10+NODENUM;if(newStart===config.current.syncStart){config.current.preScrollTop=scrollTop;return;}updateState(newStart,newEnd,scrollTop>config.current.preScrollTop);//true为往下滚动false为往上滚动config.current.preScrollTop=scrollTop;},100);return(<divclassName="container"><divref={$wrap}style={{paddingTop:`${paddingTop}px`,paddingBottom:`${paddingBottom}px`,'position':'relative'}}>{currentArr.slice(start,end).map((item,index)=>{constrefVal=getReference(index);constid=index===0?'top':(index===(NODENUM-1)?'bottom':'');constclassValue=index%4===0?'test':''return<divid={id}ref={refVal}key={item}><divclassName={`item${classValue}`}>{item}</div></div>})}</div></div>);}exportdefaultIndex;

五、总结

定高和不定高2类虚拟滚动,适合用户消费海量数据的场景,尤其是社交媒体这类数据消费。而像通讯录,好友列表这类数据量单一且不会特别多的情况,还是用传统的滚动体验会更好。技术选型就按照自己业务的实际需要去出发选择,千万不要强行套用。

关于懒加载这块的问题,需要根据自己的实际情况来判断是否让元素强行更新,在react下,默认的元素key应该用元素遍历的map索引值来表达。

列表元素等高的场景,可以参考《虚拟滚动 - 等高元素无限滚动加载解决方案》


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:/JavaScript/2583.html