当遇到一次性需要渲染大量子元素的长列表时,浏览器的 Rendering 过程会消耗大量时间,可以试一下这个例子——浏览器默认的长列表渲染效果,这对于用户体验来说是比较差的。虚拟滚动就是在保证列表可滚动的前提下,每次只渲染少量的需要展示的元素,当滚动到一定位置后,再渲染对应的元素,来达到优化渲染性能的目的。
元素定高的虚拟滚动实现起来并不难,可以参考 a1元素定高。基本的思路就是先用一个“占位子元素”给容器撑出滚动条,比如我们需要在某个容器内渲染10万个40px高度的子元素,那么就先创建一个10万*40px高度的元素,这个元素的作用在于撑起一个等效于实际渲染那10万个子元素应该撑起的滚动条。然后监听容器的scroll 事件,在事件触发时获取当前的 scollTop 值,通过 scrollTop 计算出当前应该显示第几个元素了,再根据容器的高度计算出还需要再渲染多少个元素才能填满容器。如果 scroll 事件触发时,需要渲染的子列表发生了变化,那么就移除之前创建的子列表DOM,重新创建新的子列表对应的DOM。如果子列表不是从第一个元素开始,则由于前面的子元素没有实际渲染,会导致后面的子元素无法位于我们期望的位置,看起来就像是把前面的元素抽走了,后面的元素塌陷上去了,所以需要给实际渲染的元素添加一个偏移量,可以通过绝对定位+top值的方式,也可以通过给元素加translateY的方式,也可以给容器加padding-top把元素挤下去...不管哪种,反正达到目的就行,这个不是重点。
可以预渲染一定量的缓冲元素,比如子列表实际需要渲染第10个到第20个子元素,我们可以在前后额外渲染5个缓冲元素,那么实际渲染的就是从第5个元素到第25个元素。如果 scroll 事件触发时,新计算出的开始元素索引和上次渲染时用的开始元素索引(比如前面的第10个)的差值在±5以内,就意味着无需重新创建DOM,在一定程度上可以降低渲染的次数,也可以更及时的展示“隐藏”起来的元素。具体实现参考 a2元素定高+缓冲元素。
由于浏览器存在元素最大尺寸的限制,比如谷歌浏览器允许的最大元素高度大概是1600多万px,所以当数据量达到百万级别时,元素的总高度很容易超出这个值,这就会导致我们用于撑出滚动条的“占位子元素”,无法撑起来一个实际元素总高度对应的滚动条,也就意味着滚动到最底部时,实际的
scrollTop 值是达不到理论最大的 scrollTop
值的,按照这个不足的值计算,就会导致后面的元素显示不了,表现出来的BUG就是滚不到底。针对这个问题,有两张解决思路:
一种思路是用有限的 scrollTop 来模拟更大的 scollTop,比如我们理论上的 scrollTop 最大值是实际 scollTop 最大值的 3倍,那么每次 scroll 事件触发时,我们都用当前实际
scollTop 值的
3倍来计算应该对应的开始元素索引,并进行渲染,但由于用户鼠标滚动没有滚那么远的距离,我们却渲染了更后面的元素,会导致“跳跃”,所以我们还需要加额外的
translateY,让元素看起来也有 3 倍的滚动速度。这个方式的具体实现可以参考
a3元素定高+缓冲元素+支持千万级数据量
中的代码。在放大倍率不高时还是值得一用的,但是如果放大的倍率太高,用户稍微滚一下,就会滚很远,会有一种不跟手,滚动不细腻的感觉。
另一种思路是放弃撑出等效滚动条的思路,改用虚拟滚动条和虚拟的 scrollTop,可以参考
b1元素定高+缓冲元素+支持任意级别数据量 中的实现,这种方式需要把容器的 overflow 设置为
hidden,然后利用 mousewheel事件(PC端)或 touchmove 事件(移动端)计算出一个虚拟的 scollTop
值,然后利用给元素加
translateY的方式来模拟滚动起来的效果,由于还要考虑上面没实际渲染的元素带来的“塌陷”,所以这个实际 translateY
的距离是(scollTop -
应该塌陷的总高度),因为滚上去的越多,渲染的起始元素也越大,前面需要塌陷的部分也越多,所以这个差值不会很大,实际范围就是在
0 到(子列表总高度-容器高度) 之间,所以不会被浏览器支持的最大尺寸限制。由于滚动条和 scollTop
都是虚拟的,所以理论上可以实现任意数量级的虚拟滚动,也是我觉得比较好的一种方式。
无论是真实滚动条还是虚拟滚动条,实现虚拟滚动的前提是我们可以计算出当前 scrollTop 值对应的开始元素,如果元素不定高,这个计算就变得复杂了,但也不是不可行。想象一下,所有的元素被整齐的从上到下码放在一个标尺上,他们身上都标记着自己的开始位置和结束位置的值,比如索引是0的元素的开始坐标是0,高度是45,结束坐标就是45,紧接着索引是1的元素的开始坐标就是45,如果它的高度是60,那么结束坐标就是105...依此类推,直到最后一个元素。假设我们有一个这样的“位置信息列表”,在滚动时就可以用二分查找很快的找到 scrollTop 值对应的开始元素索引,也就知道该渲染哪些元素了。所以现在的问题就变成如何维护这么一个位置信息的列表了,由于我们每次渲染元素后,才能得到它的实际高度,元素的高度也可能发生变化,比如加载了一个图片,所以我们需要有一个更新“位置信息列表”的机制,来保证在元素高度发生变化后或在二分查找前这份列表是最新的。假如某个元素的高度发生了变化,我们就需要把列表中该元素之后的所有元素的位置信息都更新一遍,当然由于每次创建渲染的元素时都是批量创建的,所以也可以一次性收集他们的实际高度,再更新列表。当有多个元素的高度发生变化时,我们只需要从索引最小的那个元素开始更新就好了,所以批量更新也并不会带着翻倍的计算量,看起来似乎是可行的,a4元素不定高+缓冲元素+支持千万级数据量 就是按照这个思路实现的,实测下来,列表如果数据量在百万级以下,还是可以流畅使用的,千万级就会有点卡,当然如果电脑性能特别好,可能也可以支持更大数据量的流畅使用,不过理论上来说这个方法是没毛病的。通常数据量也不会超过百万,所以用来做不定高的虚拟滚动列表完全可行。补充说明的一点是,虽然是不定高的虚拟滚动,但是我们还是需要声明一个元素的最小高度,元素的实际高度只能比他大,因为搜索到 scrollTop 对应的开始元素索引后,我们还需要知道结束元素的索引,这个预期的最小高度就会用来计算至少还得再渲染多少个元素,才能填满容器,假如元素的实际高度比预期高度还小,那么可能会导致无法填满容器。当然这个也不是非有不可,比如先往后渲染10个,在拿到高度后,判断一下是不是覆盖住容器高度了,如果不够再渲染10个,也不是不行,用 Intersection Observer 判断也行,但从实际场景出发,一般元素怎么也得有个高度吧,规定一个最小高度也不是啥很困难的事情,所以还是设个预期最小高度来的简单直接,或者直接指定找到开始元素后,继续渲染多少个元素...不管怎样,能达到目的就行,这个不是重点,重点在于维护一份和实际DOM状态同步的“位置信息列表”以及如何快速通过 scrollTop 值找到对应的开始元素索引。
当前面的元素位置发生变化,后面的元素位置都会被影响,也就意味着之后的元素位置信息都需要更新,当数据量比较大时,这个更新“位置信息列表”的操作可能会比较耗时,再加上这个列表必须及时和实际DOM同步,这就会导致在数据量大时出现卡顿。所以得想办法降低这个更新量,因为“位置信息列表”是用来找 scrollTop 对应的索引的,所以我们只需要更新到 scrollTop 的位置就行了,反正后面的信息也用不上,更新不更新都不影响查找的准确性,只要某个 scrollTop 值前面的位置信息全部都是更新过的就可以了。所以完全可以按需更新,当滚动时,拿到一个新的 scrollTop 值,就先把“位置信息列表”更新到 scrollTop 的位置,然后再二分查找就行了。这么一来每次更新的数据量就是从变化开始的位置(或上次更新到的位置)一直更新到 scrollTop 的位置就行了。加上每次滚动的距离又不多,所以也更新不了几个信息,这么一来任何数量级的不定高列表就都可以流畅滚动了。但如果用户拖动滚动条,一下子跳转几百万条数据,那确实没办法,还是会卡一下,但比起前面那种无脑更新到最后一个还是好一些的。优化后的参考 a5元素不定高+缓冲元素+支持千万级数据量+性能优化 或 b2元素不定高+缓冲元素+支持任意级别数据量