一个真正的实现了DOM节点高效回收和复用的高性能虚拟长列表组件
海量数据场景下的性能瓶颈
几乎可以肯定的是,如果在前端场景下直接渲染海量数据,其体验基本上是很糟糕的,其主要原因如下
- 海量数据导致大量的DOM节点,从而导致DOM在初始化或者reflow、repaint的时候发生大量的计算,导致卡帧,掉帧
- 大量的DOM节点导致占用了大量的内存
现有的解决方案及其原理
- react-virtualized
- react-window
这两个组件本质上一种方案,它最核心的点在于只渲染了当前视口及其上下一定范围内的节点,从而做到了DOM节点数量的范围是固定的,而不是和数据量成正比
其基本的实现原理
其所需要的必要条件
- 固定尺寸的滚动容器
- 固定的数据长度
- 每个数据对应的组件高度可以被计算
这样的话就可以计算出以下的数据
- 整个滚动内容的高度可以被计算出来 - totalHeight
- 滚动至某个位置的时候根据scrollTop计算出当前容器视口下所能展现的items
具体实现
- 最外层容器固定宽高
- 内层的div高度为totalHeight,撑起整个滚动内容
- 根据scrollTop计算出来需要显示的items以绝对定位的方式定位在内层的div上
- item的key是和对应数据一一对应的,这样不需要的item将被删除,继续渲染的item节点被复用
![71a45cc3dea9f842fc48d27039ee9eda.png](https://i-blog.csdnimg.cn/blog_migrate/3d10d2d86ef1efd53f3a2392b83bfe59.jpeg)
这样,就实现了真正渲染的dom节点和容器的高度成正比而不是和数据量成正比。
从而解决了上述所说的性能瓶颈。
不足之处
这种方式虽然能基本解决上述的性能瓶颈,但是实际使用下来还是感觉性能一般,在实际体验中还是能感觉到有_短暂的白屏 _这种现象。
在高速滚动的过程中,为了维持当前DOM节点的规模,会频繁的发生新的DOM节点被插入旧的DOM被删除,不仅导致大量的cpu开销,和频繁的GC以及内存抖动。从而,导致实际的体验不如人意。
重新思考使用场景
在实际的使用过程中,我们列表中的item类型是有限的,如果我们在滚动过程中回收旧的节点,并且在插入新节点的时候复用相同类型的旧节点,那么将会节约大量的开销,进一步提升性能。
那么,最大的问题就是:在React规则下,我们要怎么实现节点的回收和复用呢?
答案就是巧妙的使用 key,具体稍后会将。
重新设计列表模型以按类型区分节点
- 规范列表数据
type CellDatas = {
height: number; // 数据对应节点的高度
data: any; // 所需要传入组件的数据
// 节点类型,也是真正需要 render 的组件
Component: React.ComponentType<CellWrapperProps>;
}[];
- 怎么回收旧的节点呢?
很简单。本来旧的节点消失在视口一定距离之后应该销毁,此时我们不对这个节点进行销毁,
让其留在原来的位置,当插入新的节点中有相同类型的组件(节点)时,直接将其(旧节点)拿来复用。
- 怎么复用旧的节点呢。
------ 本文最核心的逻辑 ------实际上,在我们的节点采取绝对定位之后,节点所显示出来的顺序其实和其在DOM中节点的顺序(又或者说其在React列表中的顺序)是完全无关的,节点所显示出来的顺序完全取决于样式也就是style.top属性。所以,新的节点复用旧的节点只要保持key与之前的节点一致,并且传入新的 style 和 data 即可。这样,旧的节点就在显示效果上 变成
了新的节点,而且没有发现dom的销毁与重建,仅仅付出了修改style和部分dom的开销。
新思路下实现的组件逆天的性能表现
https://weird94.github.io/list/normal/
2000 条数据 2种组件类型在chrome 6 * slowdown情况下性能
60FPS稳如老狗,内存也稳定在3-7MB内,超快速滚动也未出现短暂白屏现象。
![a569bbe19d408154b90850eeddcb219c.png](https://i-blog.csdnimg.cn/blog_migrate/9fd44c3b869140544686e972086d64b1.jpeg)
而且,从 devtools 的 elements 面板可以看出, 列表item项的DOM节点只有style发生了变化,
节点本身没有发生销毁重建,说明旧的节点得到了很好的复用。
react-recyclelist 组件的具体实现
数据结构
- 根据数据 CellDatas 计算出来的节点布局信息
type Layouts = {
height: number;
top: number;
type: React.ComponentType<CellProps<any>>;
}[];
- 存放当前正在render的节点和已经回收了的节点池
type RenderInfo = {
i: number; // 数据指针,用于查找需要渲染的组件和数据
dom: number; // 节点在DOM中的顺序,用来保证
};
// 列表滚动位置 scrollTop 计算出应该渲染节点的起始位置start-end
// start-end区域需要被渲染的节点信息
let current: RenderInfo[];
/*
* 节点池
* 以Map的形式组织
* key 为 CellData.type 也就是React.ComponetType
* value 为有序 RenderInfo 数组,存放着所回收dom的数据指针(i) 和在DOM中的位置(dom)
*/
class NAMap {};
// current 上方区域回收的节点池
let topRecycleMap: NAMap;
// current 下方区域的节点池
let bottomRecycleMap: NAMap;
- 真正需要渲染的DOM节点
let renderCurrent: number[];
// 真正需要被渲染的节点
// 等于 topRecycleMap + current + bottomRecycleMap
// 然后按 dom 从小到大排序
renderCurrent = [
...this.topRemoveMap.getList(),
...current,
...this.bottomRemoveMap.getList()
]
.sort((a, b) => a.dom - b.dom)
.map(i => i.i);
工作逻辑
简单的来说
- 划分两个回收池,分别存储当前渲染区域上下区域所回收的节点
- 当向下滚动的时候 查找可复用节点顺序为 先在 bottomRecycleMap 执行队头查找(getFirst),如果没找到再topRecycleMap查找执行队头查找(getFirst)。(先复用下方最近的节点,再复用上方最远节点)
- 向上滚动时,先 topRecycleMap.getLast,如果没找到再 bottomRecycleMap.getLast。
- 如果没找到复用节点,就插入一个新节点
- 最后,按key顺序排序,防止因为key顺序变动导致的dom节点出现了删除又插入的不必要开销。
示例
![15cf06eaf513f74845523fe026009af6.png](https://i-blog.csdnimg.cn/blog_migrate/6e8228551253372afa356e79995b18c4.jpeg)
从 _状态1 _滚动到 _状态2 _时
- A1 B2已经滚动到上方可回收的位置时,将它们加入回收池,此时回收池为
topRecycleMap
A: 1 -> null
B: 2 -> null
topRecycleMap(空)
- 同时,current 中最后一个item已经出现
top+height < scrollTop + height * 渲染因子
- 应该向 current 中循环增加新的节点以满足充满整个渲染区域 start - end
- 第五个位置类型为B,发现 topRecycleMap 中存在一个可复用的节点,拿来复用,当前节点key则为
1
- 第六个第七个节点时,已经没有可以复用的节点,则直接插入新的节点
- 此时,当前内容范围已经满足需求,停止增加节点
- 最后,将回收池(topRecycleMap、bottomRecycleMap)已经渲染区域(current)的节点按key进行排序渲染。
相关资源
组件源码: react-typed-recyclelist
PS: 厚脸皮求个star~
LiveDemo:
https://weird94.github.io/list/normal/weird94.github.io https://weird94.github.io/list/statefull/weird94.github.io https://weird94.github.io/list/resort/weird94.github.io