Posted in

实用的 VUE 系列——我们怎么用 vue 实现一个虚拟滚动插件?_AI阅读总结 — 包阅AI

包阅导读总结

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表格listselecttree等通用场景,都是虚拟滚动的需要应用的地方。

虚拟滚动原理

我们在之前的 推导中找到了需要应用的地方, 接下来就该怎样应用了,也就是 「虚拟滚动原理」

其实虚拟滚动 听起来很玄乎,仿佛是一个高大上的技术方案,其实他的原理很简单,

在目前的行业实践中,主要有两个方向

  • 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 我就不再赘述了,他是一个监听 domapi 但从来没有人会想到利用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 共同进步!