虚拟滚动的轮子是如何造成的?

12,804 阅读7分钟

相信大家都遇到过渲染一个很长的列表或者页面带来的痛苦,长列表与页面可能对首屏渲染速度造成很大的影响,并且会对页面的滚动造成一些不流畅的体验。

我也在最近遇到了这个问题,发现除了直接使用分页外,虚拟滚动这种解决方案很是流行,于是也重新造了一下vue中虚拟滚动的轮子。虚拟滚动简单的说就是渲染在浏览器中当前可见的范围内的内容,通过用户滑动滚动条的位置动态地来计算显示内容,其余部分用空白填充来给用户造成一个长列表的假象。

这个轮子其实并没有大家想象中的复杂,下面就具体介绍一下这个轮子是怎么造的,如果大家在工作中学习中遇到同样的场景也可以适用这种解决方案。以下便是我实现的虚拟滚动的一个简单demo,在线demo源码

Dom结构

虚拟滚动的核心dom结构其实就是一个简单的列表,在vue中可被描述为如下的代码

<div style="overflow-y: scroll; height: 300px;" @scroll="handleScroll">
    <div v-for="item in items" :key="`${item.id}`">
        <slot :data="item">
        </slot>
    </div>
</div>

这里用了vue的scoped slot来处理用户的自定义dom内容与自定义dom内容的传入数据,如果没有scoped slot,我们也可以通过让用户在传入数据时,在传入的数据对象中定义一个特定的渲染函数来实现这一步骤。

有了这个可自定义dom的列表结构后,在外面套一层可滚动的定高的容器,我们就实现了一个所有列表类组件的基础dom。那么接下来要做的就是填充可视列表以外的滚动高度。这个做法有挺多的,比如在列表上下定义<div>,通过改动<div>高度来控制总高度;比如通过控制列表的padding-toppadding-bottom来控制;再比如直接将列表高度设置成所有元素高度总和,通过定义position启用top来进行定位也是可以的......那么有了这个dom结构后我们就可以来对显示内容进行计算了。

监听滚动事件更新列表内容(handleScroll方法)

1. 计算可视的列表范围

首先我们可以确定的是列表的可视范围是一段连续的数组内容,于是这个计算就被简化为了找到连续数组内容的开始点与结束点。开始点与结束点依赖的两个信息:一是列表每一项的具体y坐标,二就是当前可视范围的开始点与结束点。列表每一项的y坐标可以用一次循环通过累加每项的高度来得到每项的y坐标,如下图

当前可视范围的开始点s即是列表容器的scrollTop属性,而结束点e就是s加上列表容器的高度。现在我们有了计算数组开始点与结束点的所有信息了,数组的开始点计算就是在所有项的y坐标中寻找到一个不大于可视范围开始点s的项,数组的结束点计算就是在所有项的y坐标中寻找一个不小于e的项,如下图

当数组每一项都为非固定高度的时候,我们采用二分法(具体实现可参看源码)来寻找数组的上界与下界;当数组每一项为固定高度的时候,我们可以直接用s除以每项高度向下取整(floor)来得到上界,用e除以每项高度向上取整(ceil)来得到下界。然后用slice方法获得最终需要展示的元素数组。

可能大家注意到了,虽然我们用了二分法O(logN)与直接计算O(1)代替了普通的遍历来寻找上下界,但是slice方法还是会将整体的复杂度提升到O(N),所以这个优化也仅仅对在有限数组的情况起到一定的提速作用。那么在实际应用中我们会不会遇到一个相当庞大的数组,大到能够忽视O(logN)O(1)带来的提升呢?答案是否定的,因为浏览器对页面的内存限制我们很难在实际应用中遇上这样一个数组。

2. 计算上下填充高度

有了数组的上界与下界,上下填充高度的计算其实非常直观,我们以设置列表的padding-toppadding-bottom属性为例,如图

页模式

很多时候我们需要优化的不是一个长列表,而是一个长页面,那么对于上述的计算方法有什么改变呢?

首先我们需要改变可视范围开始点s与结束点e的计算方法,对于页面而言可视范围的开始点即是window.pageYOffset || document.documentElement.scrollTop;结束点是开始点加上可视范围的高度,这里的高度计算我们使用window.innerHeight || document.documentElement.clientHeight,但请注意这两个属性在页面有滚动条的时候返回的值是不同的,innerHeight会包含滚动条的高度,clientHeight不包含滚动条的高度。

计算完了可视范围,我们还需要调整数组y坐标。原先的数组y坐标都是相对于滚动容器而言的,现在我们需要将数组的y坐标调整为相对于页面。调整方法有两种:一是可以在计算y坐标的时,加上滚动容器的offsetTop属性;二是可以在计算可视范围开始点s与结束点e时,减去滚动容器的offsetTop属性。

调整完了坐标,我们还需要将滚动容器的heightoverflow-y属性去掉,让容器自由生长,同时将滚动容器的scroll事件转移到window对象上,这样就实现了对页的虚拟滚动。通过页模式,我们就可以实现对任何通过固定高度块布局的长页面进行此类的优化。

更多可以优化的地方

1. 滚动显示优化

当滚动刷新数据过于频繁的时候,渲染就会就会产生闪烁,这时我们就需要通过requestAnimationFrame来调用更新列表的方法来实现对更新列表速率的控制,从而生成平滑的滚动动画。

2. 列表缓存

vue在这里帮我们处理了一部分列表更新的问题,比如在滚动造成的小范围数组变动中,vue是会复用先前渲染的节点来进行列表更新的。如果你没有使用类似的框架,那么就需要自己去处理一下这部分的复用逻辑。

除此之外,我们可以对在一定范围内的渲染内容直接进行缓存,例如我们可以限定缓存节点数量,在滚动时遇到缓存命中时直接使用缓存中的节点,如果无命中并且缓存节点已满的情况下则可用一定的缓存替换策略,例如用新节点来替换最不频繁使用(LFU)的缓存节点。通过这样的列表缓存来实现对小范围滚动的再次优化。

3. 列表回收

我们当前的做法依然是在滚动时对dom进行不停地销毁与再创建,虽然每次创建与销毁dom的开销并不大,但是它们依旧会占用浏览器的一部分性能。

当列表内的每一个元素都是通过统一的dom模版或渲染函数进行渲染时,我们就可以通过列表回收的方式,将超出可视范围的dom节点回收,再将新的数据注入到回收的dom节点中,最后将更新数据后的回收节点放回列表中去,如下图。通过列表回收的方式可以保证你的dom节点总量在一个极低的范围内,并且省去了创建销毁dom这一部分的开销。

最后感谢你的阅读,如果大家有什么意见,建议或者前端相关的问题都欢迎与我交流,这是我的github,have a nice day! :)

相关链接

考虑了x轴滚动的vue虚拟滚动组件

加入了列表缓存与列表回收的vue虚拟滚动组件