在前端性能优化的众多方案中,虚拟列表可以说是一个既经典又常见的话题。虽然很多开发者对它有所耳闻,但当被问到具体实现原理时,往往会觉得无从下手。今天,让我们用最直观的方式来一探虚拟列表的究竟。
虚拟列表的实现有多种方案,本文以 react-virtual-list 组件为基础进行分析。原文链接:https://github.com/dwqs/blog/…
什么是虚拟列表?
想象一下你正在刷抖音:屏幕上虽然只显示一个视频,但你知道下面还有数不清的内容在等着你。虚拟列表就是这样一个聪明的技术 —— 它只把你眼前能看到的内容渲染出来,而不是一股脑儿地把所有内容都显示出来。这就像是魔术师的手法,让你感觉整个列表都在那里,实际上却只有一小部分在起作用。
要理解虚拟列表,我们需要先搞清楚三个关键概念:
1. 滚动容器
这是承载内容的”视口”(Viewport),可以理解为一个固定尺寸的框。它可以是浏览器窗口,也可以是页面中某个固定高度的div
元素。就像是你在看一卷长画轴,滚动容器就是那个让你能通过它观看画作的框框。
2. 可滚动区域
这是内容的真实区域,决定了滚动条的高度。继续用画轴的比喻 —— 如果你有一卷100米长的画轴,那么可滚动区域就是这100米。在虚拟列表中,如果你有1000条数据,每条高度50像素,那么可滚动区域的高度就是50000像素。这个高度是虚拟的,但对用户来说,滚动体验和真实渲染是完全一致的。
3. 可视区域
这是用户通过滚动容器实际能看到的内容区域。还是用画轴的例子 —— 虽然画轴有100米长,但你通过框框一次只能看到20厘米的内容。在虚拟列表中,如果滚动容器高度是400像素,那么可视区域一次可能只能显示8-10条数据。
这三者的关系可以用一个简单的类比来理解:
- 滚动容器就像是火车车窗
- 可滚动区域就像是火车外的整个风景
- 可视区域就是你透过车窗在某一时刻看到的那部分风景
虚拟列表的核心就是只渲染可视区域的内容,而不是整个可滚动区域的内容。当用户滚动时,我们动态计算并更新可视区域中应该显示的内容,从而大大减少了DOM节点的数量,提升了页面性能。
实现虚拟列表的具体步骤如下:
- 根据滚动条的位置,计算可见区域该展示长列表中哪部分的数据,得到这部分可视数据的 startIndex
- 根据可见区域的高度,计算可容纳的列表项个数,结合 startIndex 得到可视数据的 endIndex
- 根据 startIndex 和 endIndex 提取可视数据,并渲染可视数据列表
- 因为滚动容器已向上滚动scrollTop, 所以可视数据列表需要paddingTop,top偏移同样的距离startOffset才能显示在可视区域
- 由于需要用可视数据列表的上下padding来模拟完整列表的真实高度,得到真实的滚动条,所以还需要计算 endIndex 对应的数据相对于可滚动区域最底部的偏移位置 endOffset,并设置可视数据列表paddingBottom
建议参考下图理解一下上面的步骤:
元素 L 代指当前列表中的最后一个元素
从上图可以看出,startOffset
和 endOffset
会撑开容器元素的内容高度,让其可持续的滚动;此外,还能保持滚动条处于一个正确的位置。
为什么需要虚拟列表?
虚拟列表是对长列表的一种优化方案。在前端开发中,会碰到一些不能使用分页方式来加载列表数据的业务场景,我们称这种列表叫做长列表。比如,在一些外汇交易系统中,前端会准实时的展示用户的持仓情况(收益、亏损、手数等),此时对于用户的持仓列表一般是不能分页的。
在本篇文章中,我们把长列表定义成数据长度大于 999,并且不能使用分页的形式来展示的列表。
如果对长列表不作优化,完整地渲染一个长列表,到底需要多长时间呢?接下来会写一个简单的 demo 来测试以下。
本文 demo 的测试环境:Macbook Pro(Core i7 2.2G, 16G), Chrome 69,React 16.4.1
在 demo 中,我们先测一下浏览器渲染 10000 个简单的节点需要多长时间:
1 |
|
当点击 Button 时,会调用 onCreateSimpleDOMs
创建 10000 个简单节点。从 Chrome 的 Performance 标签页看到的数据如下:
从上图可以看到,从 Event Click 到 Paint,总共用了大约 693ms,渲染时的主要时间消耗情况如下:
- Recalculate Style:40.80ms
- Layout:518.55ms
- Update Layer Tree:11.84ms
在 Recalculate Style 和 Layout 阶段,ReactDOM 调用了
setInnerHTML
方法,其内部主要通过innerHTML
方法,将创建好的 html 片段添加到对应节点
然后,我们创建 10000 个稍微复杂点的节点。修改组件如下:
1 |
|
当点击 Button 时,会调用 onCreateComplexDOMs
。从 Chrome 的 Performance 标签页看到的数据如下:
从上图可以看到,从 Event Click 到 Paint,总共用了大约 964.2ms,渲染时的主要时间消耗情况如下:
- Recalculate Style:117.07ms
- Layout:538.00ms
- Update Layer Tree:31.15ms
对于上述测试各进行 5 次,然后取各指标的平均值,统计结果如下:
- | Recalculate Style | Layout | Update Layer Tree | Total |
---|---|---|---|---|
渲染简单节点 | 199.66ms | 523.72ms | 12.572ms | 735.952ms |
渲染复杂节点 | 114.684ms | 806.05ms | 31.328ms | 952.512ms |
- Total = Recalculate Style + Layout + Update Layer Tree
- demo 的测试代码:test code
从上面的测试结果中可以看到,渲染 10000 个节点就需要 700ms+,实际业务中的列表每个节点都需要 20 个左右的节点,布局也会复杂很多,在 Recalculate Style 和 Layout 阶段也会耗费更长的时间。那么,700ms 也仅能渲染 300 ~ 500 个左右的列表项,所以完整的长列表渲染基本上很难达到业务上的要求的。而非完整的长列表渲染一般有两种方式:按需渲染和延迟渲染(即懒渲染)。常见的无限滚动便是延迟渲染的一种实现,而虚拟列表则是按需渲染的一种实现。
延迟渲染不在本文讨论范围。接下来,本文会简单介绍虚拟列表的一种实现方案。
实现
本章节将会创建一个 VirtualizedList
组件,并结合代码,慢慢梳理虚拟列表的实现。
为了简化,我们设定 window
为滚动容器元素,给 html
和 body
元素均添加样式规则 height: 100%
,设定可视区域为浏览器的窗口大小。VirtualizedList
在 DOM 元素的布局上将参考Twitter 的移动端:
1 |
|
在虚拟列表上的实现上,也分为两种情形:列表项是固定高度的和列表项是动态高度的。
列表项是固定高度的
既然列表项是固定高度的,那约定没个列表项的高度为 60,列表数据的长度为 1000。
首先,我们根据可视区域的高度估算可视区域能渲染的元素个数:
1 |
|
然后,计算 startIndex
和 endIndex
,并先初始化初次需要渲染的数据:
1 |
|
如上文所说,endOffset
是计算 endIndex
对应的数据相对于可滚动区域底部的偏移位置。在本 demo 中,可滚动区域的高度就是 1000 60,因而 endIndex
对应的数据相距底部的偏移就是 (1000 - endIndex) 60。
由于是初始化初次需要渲染的数据,因而 startOffset
的初始值是 0。
根据上述代码,可以得知,要计算可见区域需要渲染的数据,只要计算出 startIndex
就行,因为 visibleCount
是一个定值,bufferSize
是一个缓冲值,用来增加一定的缓存区域,让正常滑动速度的时候不会显得那么突兀。而 endIndex
的值就等于 startIndex
加上 visibleCount
;同时,当用户滚动改变可见区域的数据时,还需要计算 startOffset
的值,以保证新的数据会出现在用户浏览器的视口中:
如果不计算 startOffset
的值,那本应该渲染在可视区域内的元素会渲染到可视区域之外。从上图可以看到,startOffset
的值就是元素8的上边框 (可视区域内最上面一个元素) 到元素1的上边框的偏移量。元素8称为 锚点元素,即可视区域内的第一个元素。 因而,我们需要定义一个变量来缓存锚点元素的一些位置信息,同时也要缓存已渲染的元素的位置信息:
1 |
|
方法 cachePosition
会在每个列表项组件渲染完后(componentDidMount
)进行调用,node
是对应的列表项节点元素,index
是节点的索引值:
1 |
|
缓存了锚点元素和已渲染元素的位置信息之后,接下来就可以处理用户的滚动行为了。以用户向下滚动(scrollTop
值增大的方向)为例:
1 |
|
在滚动事件处理函数中,会去更新 startIndex
、endIndex
以及新的锚点元素的位置信息(即更新 startOffset
),然后就可以动态的去更新可视区域的渲染数据了:
完整的代码在可以戳:固定高度的虚拟列表实现
列表项是动态高度的
这种情形下,实现的思路和列表项固高大同小异。而小异之处就在于缓存列表项的位置信息时,怎么拿到列表项的精确高度?首先要更改 cachePosition
的部分逻辑:
1 |
|
由于列表项的高度不固定,那要怎么计算 visibleCount
呢?我们先考虑每个列表项只是渲染一些纯文本。在实际项目中,有的列表项可能只有一行文本,有的列表项可能有多行文本,此时,我们要基于项目的实际情况,给列表项一个预估的高度:estimatedItemHeight
。
比如,有一个长列表要渲染用户的文章摘要,并规定摘要显示不超过三行,那么我们取列表的前 10 个列表项的高度平均值作为预估高度。当然,为了预估高度更精确,我们是可以扩大取样样本的。
既然有了预估高度,那么将原先代码中的 height
替换成 estimatedItemHeight
,就可以计算出 visibleCount
了:
1 |
|
我们通过 faker.js 来创建一些随机数据,并赋值给 data
:
1 |
|
修改一下列表项的 render
逻辑,其它不变:
1 |
|
此时,列表项的高度已经是动态的了,根据渲染的实际情况,我们给的预估高度是 80:
完整的代码在可以戳:动态高度的虚拟列表实现
那如果列表项渲染的不是纯文本呢?比如渲染的是图文,那在 Item 组件的 componentDidMount
去调用 cachePosition
方法时,能拿到对应节点的正确高度吗?在渲染图文的情况下,因为图片会发起网络请求,此时并不能保证在列表项组件挂载(执行 componentDidMount
)的时候图片渲染好了,那此时对应节点的高度就是不准确的,因而在用户滚动改变可见区域渲染的数据时,就可能出现元素相互重叠的情况:
在这种情况下,如果我们能监听 Item 组件节点的大小变化就能获取其正确的高度了。ResizeObserver 或许就可以满足我们的需求,其提供了监听 DOM 元素大小变化的能力,但在撰写本文时,仅 Chrome 67 及以上版本支持,其它主流浏览器均为提供支持。以下是我搜集的一些资料,供你参考(自备梯子):
- ResizeObserver: It’s Like document.onresize for Elements
- ResizeObserver
- caniuse#resizeobserver
总结
在本文中,首先对虚拟列表进行了简单的定义,然后从长列表的角度分析了为什么需要虚拟列表,最后就列表项固高和不固高两个场景下以一个简单的 demo 详细讲述了虚拟列表的实现思路。
在列表项是动态高度的场景下,分析了渲染纯文本和图文混合的场景。前者给出了一个具体的 demo,针对后者对于怎么监听元素大小的变化提供了参考的 ResizeObserver 方案。基于 ResizeObserver 的方案呢,我也实现了一个支持渲染图文混合(当然也支持纯文本)的虚拟列表组件 react-virtual-list,供你参考。
当然,这并不是唯一一种实现虚拟列表的方案。在组件 react-virtual-list 的实现过程中,也阅读了不同虚拟列表组件的源码,如: react-tiny-virtual-list、react-window、react-virtualized 等,后续的系列文章我会从源码的角度逐一分析。