前言
虚拟滚动是前端领域一个很有意思的优化策略,在了解虚拟滚动具体原理时,采取的策略主要有两种,一是在社区上找虚拟滚动相关文章,二是阅读github上的虚拟滚动源码。通过第一个方法可以比较容易地找到虚拟滚动基础原理的介绍,以及基础的代码实现,不过深度有所欠缺。而通过第二个方法则可以比较深入的了解主流虚拟滚动库的实现细节,但是阅读源码需要的时间较长,另外对阅读技巧的要求也更高。所以写作这篇文章,让读者能够更全面地认识虚拟滚动涉及的知识与原理。
目标读者
- 完全不了解虚拟滚动的同学
- 阅读了简单虚拟滚动代码,但是害怕读不懂虚拟滚动库源码,或者没有时间阅读源码的同学
概念说明
滚动容器:为一种元素盒。无论滚动条是否存在,滚动容器中的内容均可滚动(定义来自MDN)
滚动元素:在本文中指在滚动容器的滚动条位置发生变化时,其在页面上的位置跟随发生变化的元素。
/* 滚动容器 */
<div style={ overflow: scroll } height={100}>
/* 滚动元素 */
<div height={500}>...<div>
</div>
什么是虚拟滚动
虚拟滚动是在表格或者列表组件展示的数据量比较大时经常会用到的一种性能优化方式。在常规的滚动中,滚动元素本身已经被浏览器渲染为了dom元素,只是因为滚动容器尺寸的限制没有展示出来,用户通过移动滚动条就可以浏览到这些内容。而在虚拟滚动中,则是先计算出滚动条所在的位置,然后再决定哪些元素需要渲染,这样就减少了浏览器所需渲染的dom元素数量,从而使用户的操作更为流畅。
下面通过图示的方式对比了常规滚动与虚拟滚动在DOM渲染上的区别,常规滚动会全量创建滚动元素的DOM,在滚动时无需再对DOM元素进行修改。而虚拟滚动始终只渲染需要展示的DOM,在滚动时动态的修改DOM元素。在元素不多的情况下,常规滚动的体验其实会更加流畅,因为无需在滚动时重新销毁和创建DOM元素。而在元素量较大的情况下,由于虚拟滚动只创建可见的元素,首次加载时需要创建的DOM量远小于常规滚动,会有不错的性能提升。而在滚动时,虽然常规滚动不用重新创建DOM,但是页面上庞大的DOM元素数量本身也大大拖慢了浏览器的重绘速度,此时虚拟滚动反而能提供更好的体验。
实现原理
本文将实现虚拟滚动的步骤分为三个部分,其中核心逻辑集中在第二部分。第一部分和第三部分的逻辑比较简单,不过会涉及到一些浏览器、事件、CSS相关知识(从实践的角度来看其实把这些地方处理好是更困难的)。
- 监测滚动行为,当滚动事件发生的时候,触发对应回调函数
- 从滚动事件获取滚动的位置,并计算需要渲染的元素
- 创建这些元素的dom并挂载
滚动相关
这个部分假设读者对于盒模型的clientHeight,scrollHeight等尺寸有所了解,如果不理解可以参考MDN。在这个部分会介绍如何监测滚动事件,也就是在用户滚动鼠标滚轮或者拖动滚动条的时候如何触发重新计算所需渲染元素的函数。然后会介绍如何监测当前滚动的方向以及具体的滚动位置。
滚动事件触发
滚动事件的触发主要可以通过两种方式,第一种是由于用户主动移动滚动条触发,第二种是通过js调用滚动相关的api触发。在实现虚拟滚动时,有时需要通过js调用滚动实现一些比较便捷的功能,比如说立即跳转到第100条数据所在的位置。
最简单的通过js控制滚动条位置的方式是直接改变滚动容器上的scrollTop或者scrollLeft属性。不过这样的滚动不够平滑,建议调用滚动容器上的scrollTo方法实现效果更好的滚动。
滚动事件监测
在滚动事件发生的时候会涉及到滚动容器与滚动主体,滚动事件要注册在滚动容器上。具体的注册方式与选择的框架有关。
// pure js
scrollContainer.addEventListener('scroll', callback)
// react
<div onScroll={callback} > ... </div>
// vue
<div @scroll="callback" > ... </div>
滚动数据获取
在获取具体的滚动条位置之前,先需要谈一谈滚动方向,滚动方向有两种,一是垂直方向的滚动,二是水平方向的滚动。在处理水平方向的滚动时,如果有国际化相关的考虑,需要注意当文字方向为rtl(right to left)时滚动位置的计算逻辑是特殊的,感兴趣的同学可以参考react-window这个代码库的实现。
容器的滚动方向需要使用者根据场景事先确定,一般以垂直方向或者水平方向的单向滚动最为常见。垂直方向的滚动可以通过滚动容器的scrollTop属性获取当前的滚动位置,而水平方向的滚动则可以通过scrollLeft属性来获取当前的滚动位置。值得一提的是,如果滚动容器是window对象,那么应该用scrollX获取水平滚动位置,用scrollY获取垂直滚动位置。
另外需要注意的是safari有elastic scrolling的特性,也就是获取滚动位置的时候可能会发现这个位置已经超出了常规的可滚动范围即[0, scrollHeight - clientHeight]。
确认渲染元素
为了方便,以下的论述假设滚动方向为垂直方向。滚动容器的高度为h,滚动位置为y,滚动元素的数量为n。如果理解了垂直方向的计算方法,将其迁移到水平方向的滚动,或者是垂直方向与水平方向两个方向的滚动并不困难。滚动方向为水平方向时的计算逻辑与垂直方向基本一致,不同点只是垂直方向的滚动会考虑元素高度height,而水平方向的滚动会考虑元素宽度width。而当水平方向与垂直方向均可以滚动时,分开计算水平方向与垂直方向需要展示的元素范围即可。
接下来我们分别考虑以下三种情况如何确认哪些元素需要渲染,更具体地说就是要找到第一个需要渲染的元素和最后一个需要渲染的元素。
- 元素的高度已知且相同
- 元素的高度已知但不同
- 预先不知道元素的高度
这三种方式在实现上的难度依次递增,适用范围也逐渐扩大。不过同时性能表现也因为已知条件的减少而依次递减。
元素高度已知且相同
假设共有n个高度为h0的元素。那么很容易计算出第一个元素的index是
firstIndex = Math.floor(y/h0)
最后一个元素的index通过公式表达相对困难且不好理解,因为准确的表达会涉及到整除问题。比如说滚动容器的高度是20,每个滚动项的高度都是10,那么如果第一个元素完全展示在容器中,那么滚动容器需要展示2个滚动项,而如果第一个元素只有5px需要展示,那么滚动容器就需要展示三个滚动项。
为了便于理解,这里给出迭代式计算lastIndex的方法
let lastIndex = firstIndex
let currentHeight = y
while(currentHeight < y + h) {
currentHeight += h0;
lastIndex++;
}
元素高度已知但不同
假设滚动容器的高度为h,当前滚动位置为y,有n个元素,高度分别为[h0, h1, ... , hn]。累计高度分别为[s0, s1, ... , sn]
假设第一个元素的index为firstIndex(在公式中简写为f),那么应该满足以下条件
求f值的过程是个经典的二分查找问题,不了解的同学可以去leetcode.cn/problems/N6… 看看怎么做。在找到了f之后,可以用与之前类似的方法寻找最后一个需要渲染元素的下标lastIndex。
元素高度未知
仍然假设滚动容器的高度为h,当前滚动位置为y,有n个元素,真实高度分别为[h0, h1, ... , hn]。累计高度分别为[s0, s1, ... , sn] 。但和上一种情况的区别在于这时具体的高度值是不知道的,此时需要采取特殊的策略,也就是先假设元素的尺寸为[g0, g1, ... gn]进行渲染。
根据假设渲染出来的内容是有误差的,接下来就要根据真实尺寸对于渲染出来的内容进行调整了。真实尺寸的获取可以通过resizeObserver相关的API实现。而调整的算法其实就是用当前已知的真实高度值替换猜测出来的高度值重新计算需要渲染的内容直到当前视口不再有元素的尺寸需要更新。尺寸的估计越准确收敛的速度越快。
创建dom并渲染
因为创建dom不同的框架API有所区别,并且核心逻辑在于如何将dom展示在合适的位置,所以这里只关注创建出来的dom如何展示在正确的位置,也就是其css样式如何赋予。常见的方法是将滚动容器的position属性设置为relative,而把滚动元素的position属性设置为absolute。然后再通过css属性top,left或者transform变换将其设置到所有元素全量渲染时它所处的位置即可。
杂谈
这篇文章讲了虚拟滚动实现的一些核心原理,但没有涉及虚拟滚动怎么尽可能做到高性能的话题。这个话题对于具象实现很重要,但并不影响具体实现思路。本文在此举两个例子,希望能够让大家对这个话题有初步认识。首先是在一次用户滚动的行为中,会触发非常多次滚动事件,如果每次事件都重新计算,计算量很大,并且其结果对用户来说也没有意义,所以对于滚动事件的处理需要做防抖处理。其次,如果严格遵守上面的实现思路,会发现每次滚动都要触发重新渲染,而如果有意识地每次额外前后都多渲染几个元素,这样在小范围的滚动时就可以复用之前的dom元素直接展示了。
另外在考虑虚拟滚动之前,先考虑这个交互是否合理,通常用户需要通过滚动的方式来浏览庞大的数据并不是高效的交互。表格为例,如果行太多,考虑提供给用户搜索与筛选的功能也许比虚拟滚动更能解决用户的问题。如果列太多,提供自定义显示哪些列的功能也许更重要。
最后可以思考一下虚拟滚动这一性能优化手段在其他性能优化领域为我们带来的启发。虚拟滚动的核心思想在我看来是只做最为必要的事情,这种必要性来自于对于用户真实需求的观察,如果用户在展示的空间内只能看到100条数据,那么一次性加载10000条数据就是一种资源的浪费。这里列举两个类似的优化思路,第一是在绘制折线图时,如果数据点过多,那么经常会通过采样的方式降低绘制点的数量,通过这种方式得到的图形形状差距肉眼难以分辨,但是绘制需要的资源却大幅降低。第二是在加载具有多个层级的菜单或者选项时,一开始可以只加载第一层的选项,等到用户鼠标移动到选项上或者点击展开的时候再拉取下一层级的数据。
最后限于笔者水平,如有错误欢迎指出。感谢看到这里的读者。
参考资料
- react-window:github.com/bvaughn/rea…
- TanstackVirtual:github.com/TanStack/vi…
- MDN:developer.mozilla.org/zh-CN/