包阅导读总结
1. 关键词:虚拟滚动、前端、技术原理、应用场景、滚动原理
2. 总结:
– 本文探讨了虚拟滚动技术,指出其常见于前端工作,能解决页面卡顿问题。
– 介绍了虚拟滚动的应用场景,如 table 表格、list 等。
– 分析了两种虚拟滚动原理及实现方式,包括动态加载内容和只渲染可视区域列表项。
3. 主要内容:
– 虚拟滚动的应用与原理
– 应用场景
– 解决页面卡顿,如 select 标签渲染大量数据时。
– 常见于 web 页面的通用场景,如 table 表格、list 等。
– 原理
– 动态加载内容
– 以 element-plus Infinite Scroll 为例,利用滚动事件判断是否到底,动态加载新数据。
– 只渲染可视区域的列表项,非可见区域不渲染
– 通过分治思想,只展示视口内容,利用滚动事件更新视口内容。
– 虚拟滚动源码分析
– 看源码应领会核心原理,关注滚动时如何替换内容。
– 开源库注重封装,以适应广泛使用和避免偏见性学习。
思维导图:
文章地址:https://mp.weixin.qq.com/s/tHKwD7zTMn2kFiqoVFrITA
文章来源:mp.weixin.qq.com
作者:老骥farmer
发布时间:2024/8/21 8:25
语言:中文
总字数:7808字
预计阅读时间:32分钟
评分:85分
标签:Vue,虚拟滚动,前端性能优化,动态加载,可视区域渲染
以下为原文内容
本内容来源于用户推荐转载,旨在分享知识与观点,如有侵权请联系删除 联系邮箱 media@ilingban.com
点击关注公众号,“技术干货”及时达!
声明:本文为稀土掘金技术社区首发签约文章
前言
虚拟滚动
是前端领域的一个很常见的技术方案,它出现在我们工作中的方方面面,面试要考
,业务要用
,性能要优化
,数据要展示
都都离不开「虚拟滚动」 的背影
于是,最近闲来无事,浅浅的研究了一下, 不成想技术原理还挺深奥
细究之后,总结为这篇文章,跟这位jym
汇报一下
虚拟滚动有哪些应用场景
说起虚拟滚动的应用场景,我们还是要追溯到问题的本质解答问题。
「我们为什么要使用虚拟滚动?」
答案很简单,不用虚拟滚动页面他卡啊
有的jym
就开始问了,为啥会卡呢?
那我就要「从丘处机路过牛家村开始了」
我们知道,页面之所以卡顿是,是因为同时渲染的元素太多了

大家 可以发现,一个select标签,只是简单的渲染上千条数文案数据

他就耗时接近两秒,于是,我们在这两秒内,我们就无法进行任何操作,
具体为什么无法操作,这属于浏览器工作原理的范畴。我们就不再废唾沫星子了
如果有兴趣,可以去这重学前端(五)——谈谈前端性能优化(https://juejin.cn/post/6983706469015388168)
「简而言之,就是单线程
的特性,导致js执行和渲染是互斥的,无法同时进行,只能将js 放入eventloop 队列中,延后执行」
于是有了这些片汤话的铺垫,我们就能很简单的得出结论 :「大连数据的列表都可以使用虚拟滚动」
接下来,他的应用场景就能「呼之欲出」了,比如在web页面中,table表格
、list
、 select
、 tree
等通用场景,都是虚拟滚动的需要应用的地方。
虚拟滚动原理
我们在之前的 推导中找到了需要应用的地方, 接下来就该怎样应用了,也就是 「虚拟滚动原理」
其实虚拟滚动
听起来很玄乎,仿佛是一个高大上的技术方案,其实他的原理很简单,
在目前的行业实践中,主要有两个方向
-
2、只渲染可视区域的列表项,非可见区域的**不渲染
别急我们一个一个讲
1、根据滑动位置,动态加载内容
这是一个非常讨巧的方案, 因为他的原理很有意思,你不是说,同时加载卡吗?
「那我一点点加载分批来不就解决了吗?」
element-plus
Infinite Scroll 就是这种方案,是一个指令插件,使用方式很简单
<ulv-infinite-scroll="load"class="infinite-list"style="overflow:auto">
<liv-for="iincount":key="i"class="infinite-list-item">{{i}}</li>
</ul>
其实,本质上来说,他是什么类型的插件无所谓,「形式不重要,内容才重要」!!
我们来简单的研究一下他的内容(也就是源码)
//@ts-nocheck
import{nextTick}from'vue'
import{isFunction}from'@vue/shared'
import{throttle}from'lodash-unified'
import{
getOffsetTopDistance,
getScrollContainer,
throwError,
}from'@element-plus/utils'
importtype{ComponentPublicInstance,ObjectDirective}from'vue'
//一些常量
exportconstSCOPE='ElInfiniteScroll'
exportconstCHECK_INTERVAL=50
exportconstDEFAULT_DELAY=200
exportconstDEFAULT_DISTANCE=0
//默认属性
constattributes={
delay:{
type:Number,
default:DEFAULT_DELAY,
},
distance:{
type:Number,
default:DEFAULT_DISTANCE,
},
disabled:{
type:Boolean,
default:false,
},
immediate:{
type:Boolean,
default:true,
},
}
//类型定义
typeAttrs=typeofattributes
typeScrollOptions={[KinkeyofAttrs]:Attrs[K]['default']}
typeInfiniteScrollCallback=()=>void
typeInfiniteScrollEl=HTMLElement&{
[SCOPE]:{
container:HTMLElement|Window
containerEl:HTMLElement
instance:ComponentPublicInstance
delay:number//exportfortest
lastScrollTop:number
cb:InfiniteScrollCallback
onScroll:()=>void
observer?:MutationObserver
}
}
//获取一下外部传入的属性
constgetScrollOptions=(
el:HTMLElement,
instance:ComponentPublicInstance
):ScrollOptions=>{
returnObject.entries(attributes).reduce((acm,[name,option])=>{
const{type,default:defaultValue}=option
constattrVal=el.getAttribute(`infinite-scroll-${name}`)
letvalue=instance[attrVal]??attrVal??defaultValue
value=value==='false'?false:value
value=type(value)
acm[name]=Number.isNaN(value)?defaultValue:value
returnacm
},{}asScrollOptions)
}
//销毁dom监听
constdestroyObserver=(el:InfiniteScrollEl)=>{
const{observer}=el[SCOPE]
if(observer){
observer.disconnect()
deleteel[SCOPE].observer
}
}
//滚动条事件
consthandleScroll=(el:InfiniteScrollEl,cb:InfiniteScrollCallback)=>{
//取出实例中的内容
const{container,containerEl,instance,observer,lastScrollTop}=
el[SCOPE]
//同样的获取属性
const{disabled,distance}=getScrollOptions(el,instance)
//拿到他的容器高度,滚动的总共高度,举例顶部的举例
const{clientHeight,scrollHeight,scrollTop}=containerEl
//获取这一次滚动了多少
constdelta=scrollTop-lastScrollTop
//保存当前这次滚动距离,方便下一次计算
el[SCOPE].lastScrollTop=scrollTop
//判断一些特殊情况,如果往上滑,或者禁用的时候,就不处理
if(observer||disabled||delta<0)return
letshouldTrigger=false
//如果绑定的指令就是容器
if(container===el){
//计算是否需要执行函数
shouldTrigger=scrollHeight-(clientHeight+scrollTop)<=distance
}else{
//如果绑定的指令不是容器,那么就用另一种计算方式
const{clientTop,scrollHeight:height}=el
constoffsetTop=getOffsetTopDistance(el,containerEl)
shouldTrigger=
scrollTop+clientHeight>=offsetTop+clientTop+height-distance
}
//如果判断出来的距离需要加载新数据
if(shouldTrigger){
//那么就执行下拉获取新数据
cb.call(instance)
}
}
functioncheckFull(el:InfiniteScrollEl,cb:InfiniteScrollCallback){
//从SCOPE中取出实例
const{containerEl,instance}=el[SCOPE]
//判断禁用情况
const{disabled}=getScrollOptions(el,instance)
//如果有禁用视口高度等于0等情况,那就直接回退
if(disabled||containerEl.clientHeight===0)return
//然后就判断,如果滑动宽度比视口还小
if(containerEl.scrollHeight<=containerEl.clientHeight){
//那就说明可能要执行一次函数了拉取下一页
cb.call(instance)
}else{
//否要就要清除监听
//移除监听的原因是因为,他出现滚动条了,就可以执行滚动事件了
//不在需要靠监听dom变动来解决问题
destroyObserver(el)
}
}
//核心代码在这
//指令型插件,很多生命周期
constInfiniteScroll:ObjectDirective<
InfiniteScrollEl,
InfiniteScrollCallback
>={
//dom初始化执行
asyncmounted(el,binding){
//取出回调函数
const{instance,value:cb}=binding
//兜底判断
if(!isFunction(cb)){
throwError(SCOPE,"'v-infinite-scroll'bindingvaluemustbeafunction")
}
//防止没有dom出问题,用nextTick处理一下
awaitnextTick()
//拿到其中的一些默认配置
const{delay,immediate}=getScrollOptions(el,instance)
//获取滚动条层的dom容器
constcontainer=getScrollContainer(el,true)
//判断容器是不是window因为如果是window的话,就必须啊找到他下头的第一个节点
//因为window是不能滚动的
constcontainerEl=
container===window
?document.documentElement
:(containerasHTMLElement)
constonScroll=throttle(handleScroll.bind(null,el,cb),delay)
if(!container)return
//绑定环境,因为在页面中可能会有很多个虚拟滚动实例
//所以我们要将每个实例保存起来方便后续取用
//这里的技巧就是绑定在el上,后续给大家说好处
el[SCOPE]={
instance,
container,
containerEl,
delay,
cb,
onScroll,
lastScrollTop:containerEl.scrollTop,
}
//immediate表示是否立即执行加载
if(immediate){
//如果立即执行,那么就监听dom变化
constobserver=newMutationObserver(
throttle(checkFull.bind(null,el,cb),CHECK_INTERVAL)
)
//保存实例
el[SCOPE].observer=observer
//启动监听,针对当前el下方的所有dom变动
observer.observe(el,{childList:true,subtree:true})
//执行检查函数,主要就是为了判断是不是到底了,包括每次监听dom变化也是这个原因
//这个方法就是为了防止我没有盛满容器,有不能出发scroll事件,从而,用的兜底策略
//利用监听dom变化来多次监听从而多次执行获取新内容函数
checkFull(el,cb)
}
//绑定滑动时间,实时计算距离,是否需要下拉新内容
container.addEventListener('scroll',onScroll)
},
//dom卸载
unmounted(el){
if(!el[SCOPE])return
const{container,onScroll}=el[SCOPE]
container?.removeEventListener('scroll',onScroll)
destroyObserver(el)
},
//dom更新
asyncupdated(el){
//如果没有实例就不管了
if(!el[SCOPE]){
awaitnextTick()
}else{
//如果有的话,要重新检查一下
const{containerEl,cb,observer}=el[SCOPE]
if(containerEl.clientHeight&&observer){
checkFull(el,cb)
}
}
},
}
exportdefaultInfiniteScroll
其实上述代码中洋洋洒洒写了这么多,其实主要就干了一个事情
「利用滚动条的scroll
事件,判断滚动是否到底,如果到底则动态加载新数据,如此而已」
之前我说过,他的优秀之处,不是形式,而是内容,因为他内部做了大量的兼容,以及小妙招对于我们日常的开发大有裨益
-
2、 利用MutationObserver保证兜底策略
指令实例保存方式
这是一个非常「新颖的方案」,在这之前,我们知道指令内部是无法保存实例的
,如果当指令初始化之后,指令外部想要使用指令初始化之后的实例,我们大多数人的常规操作,将实例
挂载到全局,而这么一来就会有个问题,如果我有多个实例呢?
所以这个插件的保存实例
方式就很「巧妙」,将内容挂载到 dom
上,既解决了实例保存的问题,有解决了多指令获取实例的问题。
我们想要使用实例的时候只需要
//html
<ulref="infiniteRef"v-infinite-scroll="load"class="infinite-list"style="overflow:auto">
<liv-for="iincount":key="i"class="infinite-list-item">{{i}}</li>
</ul>
//js
constinfiniteRef=ref(null);
//从dom中取实例
contentRef.value.ElInfiniteScroll.observer()
利用MutationObserver保证兜底策略
MutationObserver
我就不再赘述了,他是一个监听 dom
的 api
但从来没有人会想到利用MutationObserver
去主动更新 dom
这其实就是一个创新升级,希望在我们搬砖
的项目中,可以借鉴。
第二种方案「指的是渲染可视区域的列表项,非可见区域的」不渲染
2、只渲染可视区域的列表项,非可见区域的不渲染
上图我们可以发现,不过表格怎么变化,dom
就那么几个
朴实无华,主旨很简单, 分治思想
我们只管当前只一片就行
很多人可能不太理解,那么我就用一个图,来给大家生动的展示一下

尽力了,原谅我骥某人才疏学浅,只能画成这样了,各位jym
凑活看吧
画的虽然有点ugly
,但表达的东西,相信大家都能看懂
「我们只是将一部分视口看到的内容展示出来, 其他的假装展示了,反正你也看不见」
可接下来问题来了,我展示是展示了,可我要滑动页面,怎么更新视口内容呢?
如何更新视口内容
如何更新视口,其实本质上就是我们如何能让那几十个我们能看得见的 dom
永远在视口处活动就可以
那应该怎么做呢? 我们首先可以确定两点
ok,「一拍即合」,我们来实现一下
<scriptsetuplang="ts">
import{ref}from'vue'
constpaddingTop=ref(0)
constscroll=(e)=>{
if(e.target.scrollTop%200<20){
paddingTop.value=e.target.scrollTop
}
}
</script>
<template>
<divclass="warp"@scroll="scroll">
<divclass="scroll-box":style="`padding-top:${paddingTop}px`">
<divclass="test-item">这是测试item</div>
<divclass="test-item">这是测试item</div>
<divclass="test-item">这是测试item</div>
<divclass="test-item">这是测试item</div>
<divclass="test-item">这是测试item</div>
<divclass="test-item">这是测试item</div>
<divclass="test-item">这是测试item</div>
</div>
</div>
</template>
<stylelang="scss"scoped>
.warp{
height:300px;
width:500px;
border:1pxsolid#000;
overflow:auto;
margin-top:100px;
margin-left:100px;
.scroll-box{
height:6000px;
}
}
</style>
以上代码中,就是我们利用滚动事件,事实将内容放到视口中,这里我用的是padding
解决的问题,当然你也可以用 css3的transition
搞定,无所谓。
还是那句话,「形式不重要,内容才重要」
「一图胜千言」
有了这个基础,我们就可以进行接下来的一步,动态改变 item
内容,这里我自己写可能有点班门弄斧,我们从网上选取了一个库来参考一下,当然,基于国际惯例,我也臭不要脸的给他 fork
下来并且有详细的注释
有兴趣可以移步virtual-list
接下来我们就来浅浅的分析一下
首先他的使用方式很简单
//data-sources传入数据
//data-component传入list组件(其实我觉得用插槽更好)
<VirtualList
class="list-dynamicscroll-touch"
:data-key="'id'"
:data-sources="items"
:data-component="Item"
:estimate-size="80"
:item-class="'list-item-dynamic'"
/>
虚拟滚动源码分析
聊起源码分析,很多人总是想将源码净收眼底,这其实是一个看源码的误区
毕竟老话说得好,「不能既要又要」,因为在源码中的大多数内容都是为了兼容和兜底用的,对于我们的业务帮助可能并不大
我们看源码其实本质上说,就是领会精神(也就是核心原理)
于是,回到当前问题上来也是一样, 「我们只需要关注他源码中怎样根据滚动替换内容即可」
至于那些兜底和兼容逻辑,随他去吧,因为他只是在当下的场景下的一个「不得不的一个做法」
放到的的业务中可能并不适用
就好比很多人在看历史的时候,总以为可以以史为鉴
其实,很多人不知道的是所谓的「以史为鉴,鉴的不过是自己的偏见」
所有的历史事件的发生,都有他不得不发生的理由,「盲目瞎学,学的也只是你自己的偏见」
额,好像有点跑题了。。。。
我们回到正题,「研究他怎么动态更新内容」
开源库和我们普通代码的区别就是,他要被别人指指点点
,所以封装一般都成了所有开源库的标配
于是,本库就直接封装了虚拟滚动的的内容
代码如下:
/**
*virtuallistcorecalculatingcenter
*
*@format
*/
constDIRECTION_TYPE={
FRONT:'FRONT',//scrolluporleft
BEHIND:'BEHIND',//scrolldownorright
}
constCALC_TYPE={
INIT:'INIT',
FIXED:'FIXED',//固定item宽度
DYNAMIC:'DYNAMIC',//动态item宽度
}
constLEADING_BUFFER=2
//虚拟滚动实例本质上就是提供了一些封装方法和初始状态
exportdefaultclassVirtual{
constructor(param,callUpdate){
//初始化启动
this.init(param,callUpdate)
}
init(param,callUpdate){
//传入参数
this.param=param
//回调函数
this.callUpdate=callUpdate
//总展示item,注意是一步步的展示的
this.sizes=newMap()
//展示的总高度,为了计算平均值的
this.firstRangeTotalSize=0
//根据上述变量计算平均高度
this.firstRangeAverageSize=0
//上一次的滑动到过的index
this.lastCalcIndex=0
//固定的高度的item的高度
this.fixedSizeValue=0
//item类型,是动态高度,还是非动态高度
this.calcType=CALC_TYPE.INIT
//滑动距离,为了算padding的大小
this.offset=0
//滑动方向
this.direction=''
//创建范围空对象,保存展示的开始展示位置,结束展示位置,
this.range=Object.create(null)
//先初始化一次
if(param){
this.checkRange(0,param.keeps-1)
}
//benchmarktestdata
//this.__bsearchCalls=0
//this.__getIndexOffsetCalls=0
}
destroy(){
this.init(null,null)
}
//返回当前渲染范围
//其实就是深拷贝
getRange(){
constrange=Object.create(null)
range.start=this.range.start
range.end=this.range.end
range.padFront=this.range.padFront
range.padBehind=this.range.padBehind
returnrange
}
isBehind(){
returnthis.direction===DIRECTION_TYPE.BEHIND
}
isFront(){
returnthis.direction===DIRECTION_TYPE.FRONT
}
//返回起始索引偏移
getOffset(start){
return(
(start<1?0:this.getIndexOffset(start))+this.param.slotHeaderSize
)
}
updateParam(key,value){
if(this.param&&keyinthis.param){
//ifuniqueIdschange,findoutdeletedidandremovefromsizemap
if(key==='uniqueIds'){
this.sizes.forEach((v,key)=>{
if(!value.includes(key)){
this.sizes.delete(key)
}
})
}
this.param[key]=value
}
}
//按id保存每个item
saveSize(id,size){
this.sizes.set(id,size)
//我们假设大小类型在开始时是固定的,并记住第一个大小值
//如果下次提交保存时没有与此不同的大小值
//我们认为这是一个固定大小的列表,否则是动态大小列表
//他这个套路很巧妙他给每一列的高度判断一下
//如果相同那么就默认为是相同的高度,如果不同那么默认为不同的高度
if(this.calcType===CALC_TYPE.INIT){
this.fixedSizeValue=size
this.calcType=CALC_TYPE.FIXED
}elseif(
this.calcType===CALC_TYPE.FIXED&&
this.fixedSizeValue!==size
){
this.calcType=CALC_TYPE.DYNAMIC
//it'snouseatall
deletethis.fixedSizeValue
}
//仅计算第一个范围内的平均大小
//如果是动态高度的情况下
if(
this.calcType!==CALC_TYPE.FIXED&&
typeofthis.firstRangeTotalSize!=='undefined'
){
//如果已经获取高度的数据比展示的总数据小的时候才计算
if(
this.sizes.size<
Math.min(this.param.keeps,this.param.uniqueIds.length)
){
this.firstRangeTotalSize=[...this.sizes.values()].reduce(
(acc,val)=>acc+val,
0,
)
//计算出来一个平均高度
this.firstRangeAverageSize=Math.round(
this.firstRangeTotalSize/this.sizes.size,
)
}else{
//拿到平均高度了,就干掉总高度
deletethis.firstRangeTotalSize
}
}
}
//insomespecialsituation(e.g.lengthchange)weneedtoupdateinarow
//trygoiongtorendernextrangebyaleadingbufferaccordingtocurrentdirection
handleDataSourcesChange(){
letstart=this.range.start
if(this.isFront()){
start=start-LEADING_BUFFER
}elseif(this.isBehind()){
start=start+LEADING_BUFFER
}
start=Math.max(start,0)
this.updateRange(this.range.start,this.getEndByStart(start))
}
//whenslotsizechange,wealsoneedforceupdate
handleSlotSizeChange(){
this.handleDataSourcesChange()
}
//滚动计算范围
handleScroll(offset){
//计算方向也就是是朝上还是朝下滑动
this.direction=
offset<this.offset?DIRECTION_TYPE.FRONT:DIRECTION_TYPE.BEHIND
//保存当前offset距离,为了判断下次是朝上还是朝下
this.offset=offset
if(!this.param){
return
}
if(this.direction===DIRECTION_TYPE.FRONT){
//如果是朝上滑动
this.handleFront()
}elseif(this.direction===DIRECTION_TYPE.BEHIND){
//如果是朝下滑动
this.handleBehind()
}
}
//-----------publicmethodend-----------
handleFront(){
constovers=this.getScrollOvers()
//shouldnotchangerangeifstartdoesn'texceedovers
if(overs>this.range.start){
return
}
//moveupstartbyabufferlength,andmakesureitssafety
conststart=Math.max(overs-this.param.buffer,0)
this.checkRange(start,this.getEndByStart(start))
}
handleBehind(){
//获取偏移量所对饮的list
constovers=this.getScrollOvers()
//如果在缓冲区内滚动,范围不应改变,range是在每次滑动出缓冲区的时候更改
if(overs<this.range.start+this.param.buffer){
return
}
//也就是当overs大于当前的缓冲内容了,也就是到头了
//我们就开始启动检查机制,重新确定range
//其实就是开辟新的缓冲区
this.checkRange(overs,this.getEndByStart(overs))
}
//根据当前滚动偏移量返回传递值
getScrollOvers(){
//如果插槽标头存在,我们需要减去它的大小,为了兼容
constoffset=this.offset-this.param.slotHeaderSize
if(offset<=0){
return0
}
//固定高度的itm很好办,直接用偏移量除以单独的宽度就行,就能得出挪上去了几个元素
if(this.isFixedType()){
returnMath.floor(offset/this.fixedSizeValue)
}
//非固定高度就麻烦了
letlow=0
letmiddle=0
letmiddleOffset=0
lethigh=this.param.uniqueIds.length
//接下来就要有一套算法来解决问题了,求偏移了几个
while(low<=high){
console.log(low,high)
//this.__bsearchCalls++
//他这个算法应该属于二分法,通过二分法去求最接近偏移量的list条数
//获取二分居中内容,其实有可能跟总high一样
middle=low+Math.floor((high-low)/2)
//获取居中条数的总偏移量
middleOffset=this.getIndexOffset(middle)
//如果偏移量,等于当前偏移量
if(middleOffset===offset){
//中间位置数据返回
returnmiddle
//还是利用二分法去找逐渐缩小距离
}elseif(middleOffset<offset){
low=middle+1
}elseif(middleOffset>offset){
high=middle-1
}
}
//最后是在没找到,就也是无限接近了
//因为如果只有大于才会给while干掉
//也就是在干掉的一瞬间他一定是最接近offset的那个值,并且根据动态高度,所形成的list条数
//之所以--是因为while不行了,所以,我们要回到他行的时候
returnlow>0?--low:0
}
//返回给定索引的滚动偏移量,这里可以进一步提高效率吗?
//虽然通话频率很高,但它只是数字的叠加
getIndexOffset(givenIndex){
//如果没有就返回0偏移量
if(!givenIndex){
return0
}
//初始偏移量
letoffset=0
letindexSize=0
//遍历元素内容
for(letindex=0;index<givenIndex;index++){
//this.__getIndexOffsetCalls++
//获取他们的高度
indexSize=this.sizes.get(this.param.uniqueIds[index])
//获取他准确的偏移量,只有前一部分有后续就没有了,所以就要按照前头计算的平均计算量去计算
//后续如果滑动完了,那么就会找到,能事实更正
offset=
offset+
(typeofindexSize==='number'?indexSize:this.getEstimateSize())
}
//记住上次计算指标这里计算是为了后续比较的时候用的
//因为有可能往上滑或者往下滑,所以每次要比较一下取最大值
this.lastCalcIndex=Math.max(this.lastCalcIndex,givenIndex-1)
//或者跟总元素个数比较取最小的也就是lastCalcIndex不能比总元素数量小,这个math.min
//之所以要取小,是为了兼容,lastCalcIndex可能大于最大数量的情况
//console.log(this.lastCalcIndex,this.getLastIndex())
//经过实践发现,好像前者永远不会大于后者,这个取值好像没用
this.lastCalcIndex=Math.min(this.lastCalcIndex,this.getLastIndex())
returnoffset
}
//isfixedsizetype
isFixedType(){
returnthis.calcType===CALC_TYPE.FIXED
}
//returnthereallastindex
getLastIndex(){
returnthis.param.uniqueIds.length-1
}
//在某些情况下,范围被打破,我们需要纠正它
//然后决定是否需要更新到下一个范围
checkRange(start,end){
constkeeps=this.param.keeps
consttotal=this.param.uniqueIds.length
//小于keep的数据,全部呈现
//就是条数太少了,就没有必要搞烂七八糟的计算了
if(total<=keeps){
start=0
end=this.getLastIndex()
}elseif(end-start<keeps-1){
//如果范围长度小于keeps,则根据end进行校正
start=end-keeps+1
}
//如果范围有问题,那么就需要重新更新范围
if(this.range.start!==start){
this.updateRange(start,end)
}
}
//设置到新范围并重新渲染
updateRange(start,end){
this.range.start=start
this.range.end=end
this.range.padFront=this.getPadFront()
this.range.padBehind=this.getPadBehind()
//通知回调函数
console.log(this.getRange())
this.callUpdate(this.getRange())
}
//这个其实就是基于他的开始位置,返回一个一定的位置
getEndByStart(start){
consttheoryEnd=start+this.param.keeps-1
//也有可能最后算出来的超出了当前的总数据量,所以要取小来搞定结束位置
consttruelyEnd=Math.min(theoryEnd,this.getLastIndex())
returntruelyEnd
}
//返回总前偏移
getPadFront(){
//固定高度的
if(this.isFixedType()){
returnthis.fixedSizeValue*this.range.start
}else{
//非固定高度,在方法中用二分法,获取最接近的
returnthis.getIndexOffset(this.range.start)
}
}
//计算总高度
getPadBehind(){
//获取初始end
constend=this.range.end
//获取总条数
constlastIndex=this.getLastIndex()
//如果是fixed大小
if(this.isFixedType()){
return(lastIndex-end)*this.fixedSizeValue
}
//这是非固定高度
if(this.lastCalcIndex===lastIndex){
//如果之前滑动到过底部则返回精确的偏移量
returnthis.getIndexOffset(lastIndex)-this.getIndexOffset(end)
}else{
//如果没有,请使用估计值
return(lastIndex-end)*this.getEstimateSize()
}
}
//获取项目估计大小,兜底策略,防止高度为空的情况,拿他的默认高度
getEstimateSize(){
returnthis.isFixedType()
?this.fixedSizeValue
:this.firstRangeAverageSize||this.param.estimateSize
}
}
以上代码中,就是对于虚拟滚动的封装,主要有关的就封装了那么几个方法
-
1、保存个更新 range
实时更新 padding
接下来就很简单了我们初始化这个实例
//初始化虚拟滚动
constinstallVirtual=()=>{
//获取虚拟滚动所用实例
virtual=newVirtual(
{
slotHeaderSize:0,
slotFooterSize:0,
keeps:props.keeps,
estimateSize:props.estimateSize,
buffer:Math.round(props.keeps/3),//默认保留三分之一,也就是十条之所以保留三分之一,防止他还没划到地方就更改padding出现错误
uniqueIds:getUniqueIdFromDataSources(),
},
//选区改变,重新生成选区
onRangeChanged,
)
//获取选区这一步其实有点多此一举了
//range.value=virtual.getRange()
}
//在组件的初始渲染发生之前被调用。
onBeforeMount(()=>{
//初始化虚拟滚动
installVirtual()
})
监听滚动事件,根据virtual
中的实例
动态改变数据和更改 padding
//list核心组件
exportdefaultdefineComponent({
name:'VirtualList',
//props传值
props:VirtualProps,
setup(props,{emit,slots,expose}){
//主渲染逻辑
constgetRenderSlots=()=>{
constslots=[]
//由于在之前scroll中更改了范围的开始和结束
const{start,end}=range.value
const{
dataSources,
dataKey,
itemClass,
itemTag,
itemStyle,
extraProps,
dataComponent,
itemScopedSlots,
}=props
//开始遍历,当前内容
for(letindex=start;index<=end;index++){
constdataSource=dataSources[index]
if(dataSource){
constuniqueKey=
typeofdataKey==='function'
?dataKey(dataSource)
:dataSource[dataKey]
if(typeofuniqueKey==='string'||typeofuniqueKey==='number'){
slots.push(
//传入的内容,将内容放到item上,注意这个item是传入的
<Item
index={index}
tag={itemTag}
event={EVENT_TYPE.ITEM}
horizontal={isHorizontal}
uniqueKey={uniqueKey}
source={dataSource}
extraProps={extraProps}
component={dataComponent}
scopedSlots={itemScopedSlots}
style={itemStyle}
class={`${itemClass}${
props.itemClassAdd?''+props.itemClassAdd(index):''
}`}
onItemResize={onItemResized}
/>,
)
}else{
console.warn(
`Cannotgetthedata-key'${dataKey}'fromdata-sources.`,
)
}
}else{
console.warn(`Cannotgettheindex'${index}'fromdata-sources.`)
}
}
returnslots
}
//核心逻辑监听滚动事件
constonScroll=(evt)=>{
//获取距离顶部的距离
constoffset=getOffset()
//获取视口宽度
constclientSize=getClientSize()
//获取内容总高度
constscrollSize=getScrollSize()
//iOS滚动回弹行为会造成方向错误,解决兼容bug
if(offset<0||offset+clientSize>scrollSize+1||!scrollSize){
return
}
//处理滚动事件确定数据
virtual.handleScroll(offset)
emitEvent(offset,clientSize,scrollSize,evt)
}
return()=>{
//拿到props
const{
pageMode,
rootTag:RootTag,
wrapTag:WrapTag,
wrapClass,
wrapStyle,
headerTag,
headerClass,
headerStyle,
footerTag,
footerClass,
footerStyle,
}=props
//动态的更改paddingtop和paddingbottom
//注意这个距离顶部的距离,和距离底部的距离,是根据在滑动的时候动态算出来的
const{padFront,padBehind}=range.value!
constpaddingStyle={
padding:isHorizontal
?`0px${padBehind}px0px${padFront}px`
:`${padFront}px0px${padBehind}px`,
}
constwrapperStyle=wrapStyle
?Object.assign({},wrapStyle,paddingStyle)
:paddingStyle
const{header,footer}=slots
//jsx
return(
<RootTagref={root}onScroll={!pageMode&&onScroll}>
{/*headerslot*/}
{header&&(
<Slot
class={headerClass}
style={headerStyle}
tag={headerTag}
event={EVENT_TYPE.SLOT}
uniqueKey={SLOT_TYPE.HEADER}
onSlotResize={onSlotResized}
>
{header()}
</Slot>
)}
{/*mainlist*/}
<WrapTagclass={wrapClass}style={wrapperStyle}>
//核心展示逻辑
{getRenderSlots()}
</WrapTag>
{/*footerslot*/}
{footer&&(
<Slot
class={footerClass}
style={footerStyle}
tag={footerTag}
event={EVENT_TYPE.SLOT}
uniqueKey={SLOT_TYPE.FOOTER}
onSlotResize={onSlotResized}
>
{footer()}
</Slot>
)}
{/*anemptyelementusetoscrolltobottom*/}
<div
ref={shepherd}
style={{
width:isHorizontal?'0px':'100%',
height:isHorizontal?'100%':'0px',
}}
/>
</RootTag>
)
}
},
})
以上核心代码中,主要就做了两件小事
-
1、 监听 scroll 事件,更改 virtual
实例的内容
打完收工
最后
这两种方案虽然都能提升性能,但各有千秋,因为,前者是无法规避 vue
内部的 diff
计算的 js
损耗
而后者,是无法规避每次滑动的渲染损耗
所以两瓶毒药,大家可自己斟酌,如果自己斟酌不了
「那就问领导!!!,如果他也搞不定,那他还当什么领导,让我来」
源码分析完了,如果有看不明白的 jy
可以给我私信
至于怎么变成插件,我在之前的文章中已经写过了实用的VUE系列——手把手教你写个vue 插件(https://juejin.cn/post/7383548892627075087)
请移步学习!
希望跟各位jym
共同进步!