长列表一般也叫虚拟列表,是一种大数据量下只渲染可见节点避免页面卡顿的优化方案
长列表也有时间分片的做法,比较少用,感兴趣的可以看 高性能渲染十万条数据(时间分片)
前端比较有名的有两个项目:
以及 Ant Design 4 的 virtual-list
本文将对这些开源库进行剖析,分析实现原理,并进行各个指标的评估,最终实现一个高可用的长列表组件
主要评估以下几点:
然后说下看源码的策略,主要看这几点:
如果还不清楚长列表是什么,可以先看下这篇文章「前端进阶」高性能渲染十万条数据(虚拟列表)
一张图快速入门
下面我们来看看其他开源库都怎么做的
项目地址
功能: 支持自适应高度,横向滚动,图片自适应高度
dom 结构如下
<div class="vue-recycle-scroller">
<div class="vue-recycle-scroller__item-wrapper" :style="{ 'minHeight': totalSize + 'px' }">
<div
v-for="view of pool"
:key="view.nr.id"
:style="{ transform: `translateY(${view.position}px)` }"
class="vue-recycle-scroller__item-view"
>
<slot
:item="view.item"
:index="view.nr.index"
:active="view.nr.used"
/>
div>
div>
div>
一个相对定位的列表,由 min-height
撑开 wrapper 以产生滚动条
每个列表项进行平移(translateY),这个偏移值为该项在列表中的高度累加值
还有一些 -9999px
的不可见元素,这些其实是缓存池列表项,这个在 节点回收复用 一节会讲到
列表项数据结构:
listItem = {
// 数据项内容
item:Object,
// 非响应式数据
nr:{
id,// 唯一标识
index,// 数据项中的索引
used: Boolean,// 是否已用来显示在可视区域
key,// 数据项中的key
type,// 在对应类型的缓冲池中存取
}
// translateY 值
position: Number,
}
不会产生回流(非自适应高度的情况)
浏览器渲染速度快:进行滚动时产生变化的列表项会尽可能的使用缓存池元素并修改 translateY ,其他没有变动的列表项在dom结构中的位置不变
有三种场景
最简单的情况,需要设置 itemSize
起始项索引可以通过 ~~(scrollTop / itemSize)
得到
列表总高度 = itemSize * itemCount
itemSize 设为 null,通过数据项中的 height 字段定义每个列表项的高度
由于所有列表项高度是确定的,一开始会计算每一项的高度和偏移值
O(n) 时间复杂度
同时确定了列表总高度,固定为 最后一项的偏移值+高度
当进行滚动时,由于每一项的偏移位置是确定的,则查找起始项索引可以采用二分法
O(logn) 时间复杂度
需要配置数据项最小高度,并根据这个值初始化每个数据项的高度和偏移值 – sizes
O(n) 时间复杂度
列表总高度为 最后一项的偏移值+高度
sizes 计算
for (let i = 0, l = items.length; i < l; i++) {
current = items[i][field] || minItemSize
accumulator += current
sizes[i] = { accumulator, size: current }
}
进行滚动,通过二分 sizes 得到起始项索引,结束项索引为起始项索引加上 可视高度/最小列高
O(logn) 时间复杂度
之后进行节点渲染,渲染完毕时获取实际高度,并重新计算 sizes 以及列表总高度
O(n) 时间复杂度
总体来说,性能较差,有优化的空间
连续滚动(continuous): 前后两次查找的数据范围有重叠,比如第一次为 1~10 第二次为 5~14 或者相反
已使用的列表项(views): 记录已使用项的 Map ,key 为列表项的 key
pool: 页面中所有渲染的列表项,包括未使用的
类型-缓存池 Map (unusedViews): 记录类型和缓存池的 Map
缓存池(unusedPool): 某种类型的缓存池,其中的列表项在页面中偏移位置为 -9999px
举个连续滚动的例子:
对应的,非连续滚动定义为 快速滚动,初始化一个空的 map – unusedIndex, 作用是记录同类型的 unusedPool 需要从哪个索引开始取值。
如果 unusedPool 存在元素,拿来复用;如果同类型 pool 被用光了,addView
感觉此处
v++
的处理有点问题,没有深究
和安卓的列表滚动类似,按类型进行回收,新找到的列表项会复用缓存中同类型的,可以减少 layout 时间
类似的还有 weex 的 recycle-list
由于进度条高度会变化,因此存在鼠标与滚动条不同步的 bug
功能丰富,自适应高度方面的处理性能不行(尝试考虑pr),项目结构较差不易维护
不建议使用
提供四种组件:
不支持自适应高度
grid 不分析了, FixedSizeList 就是最简单的固定高度的情景,做法都一样,我们直接分析 VariableSizeList
先吐个槽, react-window 为了复用,代码封装了一层又一层,render 还是用的 createElement … 看源码的时候实在难受,而且还是用 flow 写的,编辑器各种报错
不知道什么原因,依赖安装的时候一直失败,本地没有启动起来,这次是直接看的源码
<div style="{{position: 'relative', height: `${height}px`, overflow: 'auto'}}">
<div style="{{height: `${totalSize}px`, width: '100%'}}">
<div style="position: absolute; left: 0px; top: 38px; height: 30px; width: 100%;">
Row 1
div>
<div style="position: absolute; left: 0px; top: 68px; height: 65px; width: 100%;">
Row 2
div>
....
<div style="position: absolute; left: 0px; top: 133px; height: 70px; width: 100%;">
Row 3
div>
div>
div>
依然是用一个 totalSize 高度的容器去产生进度条,每个列表项通过绝对定位进行偏移
这里其实用 transform: translateY
效果一样的,当然渲染上的性能差异我就不知道了
和这篇文章的实现一致 – 再谈前端虚拟列表的实现
貌似对指数搜索有误解?
设 lastMeasureItem 为已测量的最远元素,
lastMeasureItem.offset 为该元素的偏移值
lastMeasureItem.index 为该元素的索引
列表总高度 = lastMeasureItem.offset + 未测量元素 * 默认高度
初次滚动,从第0项开始测量并缓存已滚动过的元素的偏移和索引,更新 lastMeasureItem
O(n) 时间复杂度
当滚动偏移值小于 lastMeasureItem.offset
时,表示起始项在 0~lastMeasureItem.index 范围之间,由于该范围所有列表项偏移值都计算过了,此时采用二分即可快速得到起始项索引
O(logn)
当滚动偏移值小于 lastMeasureItem.offset
时,则以 lastMeasureItem.index
开始测量并缓存已滚动过的元素的偏移和索引,更新 lastMeasureItem
O(n) 时间复杂度
Watch 观察 lastMeasureItem.index ,若改变,则列表总高度跟着变
在线 Demo
本方案有两个缺点:
由于进度条高度会变化,因此存在鼠标与滚动条不同步的 bug
不支持自适应高度,性能不行,不是 react 长列表的首选方案
如果有用到 Grid 的话可以用
项目地址
目前分析综合评分最高的一个
支持自适应高度,支持动画效果,支持滚动位置复原
<div
class="container"
style="width: 200px; height: 300px;"
@scroll.passive="handleScroll"
>
<div
class="total-list"
:style="{
height: `${itemHeight * data.length}px`,
}"
>
<div
class="visible-list"
:style="{
transform: `translateY(${topHeight}px)`,
}"
>
<div
v-for="item in visibleList"
:key="item.id"
class="visible-list-item"
:style="{
height: `${itemHeight}px`,
}"
>{{ item.value }}div>
div>
div>
div>
和上面的方案不一样,这里是创建了一个 total-list 的容器,直接对这个容器进行 translateY 偏移
总高度始终固定,等于 列表项个数(itemCount) * 列表项最小高度(itemHeight)
处理逻辑如下:
注意:此处的渲染表示对 dom 进行操作,还未到浏览器实际渲染阶段
核心思想是任意高度的列表项都占据相同的滚动条范围
定位项与滚动条位置对应,可以理解为滚动条水平方向指向的那个列表项。
当滚动条为0时,指向第0项,此时定位项位第0项
当滚动条处于最大值时,指向最后一项,此时定位项位为最后一项
const itemCount = this.data.length
const scrollTopMax = scrollHeight - clientHeight
/** 进度条滚动百分比 */
const scrollPtg = scrollTop / scrollTopMax
/** 确定定位项 */
const itemIndex = Math.floor(scrollPtg * itemCount);
/** 可见列表项个数 = 可见容器高度 / 每个列表项高度 ,记得向上取整 */
const visibleCount = Math.ceil(this.$el.clientHeight / this.itemHeight)
/** 确定起始项和结束项 */
const startIndex = Math.max(0, itemIndex - Math.ceil(scrollPtg * visibleCount))
const endIndex = Math.min(itemCount - 1, itemIndex + Math.ceil((1 - scrollPtg) * visibleCount))
渲染 startIndex ~ endIndex 的列表项
在列表项渲染完毕后,触发 update 回调
获取并统计 startIndex ~ itemIndex 列表项的实际总高度 s2iHeight
计算起始项偏移高度 startItemTop ,如下:
const startItemTop = 定位项绝对高度(itemAbsoluteTop) - 起始项至定位项的高度(s2iHeight)
const itemAbsoluteTop = scrollTop + 定位项相对视口高度(itemRelativeTop)
const itemRelativeTop = 滚动过的视口高度(scrollPtg * clientHeight) - 定位项偏移高度(itemOffsetPtg * itemHeight)
如图所示:
由于总高度固定,不存在鼠标和滚动条不同步的问题
性能优异,通过几个数学公式即可确定起止位置(还有优化的空间)
若需要自适应高度,则需要进行2次render,否则第一次render即可计算偏移位置
目前唯一一种不产生鼠标和滚动条不同步问题的方案
拓展性强,毕竟后面是 Ant Design 4 的核心组件之一
react 长列表首选方案
vue 可以尝试造个轮子
本节为其他一些长列表的处理方案,主要表现在起始项查找和更新列表高度方面的不同
这里提到的几种方案,都会出现 鼠标和滚动条不同步 的问题
性能较差的一种方案
定义数组 itemHeightRecord 记录列表项实际高度,可以是自适应计算出来的高度,也可以是定义高度方法计算得到的高度
一开始,列表总高度 = 列表项个数 * 默认高度
查找起始项,由于没有记录偏移值,只能采用顺序叠加的方式判断, itemHeightRecord 中有值的取值,没值的取默认高度
时间复杂度 O(n)
渲染列表项,并将取得的高度与默认值之间的差更新到 列表总高度 上,并对 itemHeightRecord 进行赋值
缺点:查找起始项的效率太低
思路来源于 「前端进阶」高性能渲染十万条数据(虚拟列表) ,并对更新偏移值进行优化
需要配置数据项最小高度,并根据这个值初始化每个数据项的高度和偏移值 – sizes
列表总高度 = sizes[length-1].offset + sizes[length-1].height
查找起始项,根据 sizes 进行二分
时间复杂度 O(logn)
渲染列表项(假设有m项),并将取得的高度替换 sizes 中的 height,并将 height 与默认值之间的差更新到其后每一项的 offset
时间复杂度 O(n)
与 vue-virtual-scroller 自适应高度的处理方式类似,只不过我们仅需要从起始项开始处理
在线 Demo
引用自 「前端进阶」高性能渲染十万条数据(虚拟列表) ,更新偏移值那边的时间复杂度为 O(n*m)
缺点:更新 sizes 较为耗时
自己想出来的一种解决方案,能够达到查询和更新都是 O(logn)
时间复杂度
在未看 virtual-list 的方案前,我一度以为这是最好的方案
效率上两者差不多,不过本方案会有 鼠标和滚动条不同步 的问题
我们先建立数据模型,列表项的高度列表为长度为 len 的正数数组 nums ,有两种操作:
很明显这是一个树状数组模板题,两个操作都是 O(logn) 的时间复杂度,模板可以参考我写的 BinaryIndexedTree
关于树状数组原理可以参考文章 – 树状数组(Binary Indexed Trees)
提供了几个方法
function findGe(target) {} //找到最小的一个n,其前n项和大于等于 target
function update (i, val) {} // 第 i 项增加差值val, 1<=i<=len
function prefixSum (n = this.tree.length - 1) {} //计算前 n 项的和 , 1<=n<=len
查找起始项可以采用 findGe, 查找起始项偏移位置以及列表总高度可以用 prefixSum, 更新偏移值采用 update
测试效果:10W 条数据滚动时处理的计算时间在 1ms 左右
感兴趣的可以看我的开源库 virtual-list-demo
可视区域的列表高度,一般不变,没必要每次都通过 $el.clientHeight
获取(会造成回流),在 resize 时再改变
如果是自适应高度,那本操作可有可无
本来打算单独写一节的,最后决定采用 virtual-list 的设计方式
列表项采用 Render Props
的形式,用 cloneElement 生成实际列表项。
无论是自适应高度还是固定高度,都是通过参数配置,对外仅提供一个组件
个人方案仅测试了 chrome ,还没测试过其他浏览器,看开源库的时候有看到其做了一些兼容处理
比如火狐滚动白屏问题,Safari scrollTop 可能为负的问题,移动端卡顿的问题
篇幅有限,这些不在本文的研究范围,建议就是生产环境尽量用开源库
若列表项高度是依赖于图片高度的,由于图片加载较慢,在初次渲染结束时(update生命周期中)并获取不到真实列表高度,需要等待图片加载完毕后计算
具体做法就是采用 ResizeObserver API ,不过这个兼容性有点问题,
vue-virtual-scroller 中其实有用到了,感兴趣的可以参考一下
通过方向键切换列表项,需要拦截键盘默认事件,并赋值 scrollTop
这个更多的是基于长列表的 Select,Tree 中会用到,到时候用到再说
原文地址,感兴趣的给个 star ~