「这是我参与2022首次更文挑战的第8天,活动详情查看:2022首次更文挑战」。
为什么要使用虚拟列表
场景
我们先来看个,这个列表项共有6000条数据,而且该页面有四个这样的列表,页面加载和滚动列表,肉眼可见的卡顿。(tip:下图帧率低,看的不明显)
什么原因呢?
这是因为页面中dom元素过多,导致页面初始化和滚动列表的时候,浏览器渲染的速度慢。
虚拟列表是啥
本来需要渲染6000条数据,但是容器盒子可视范围内只能显示7条。虚拟列表就是在6000条数据中,截取可视区域中最多容纳的条数7条,即页面中只存在7个真实的dom列表元素。然后监听容器的滚动,实时去更新该7条数据。
版本一:列表项高度固定
效果图
列表滚动的时候,如德芙般纵享丝滑,但是观察dom树结构发现,只有10条数据。这就是根据可视区域高度和每项高度计算得知的。
dom树结构
-
一个container盒子,包含一个ListBox盒子,在ListBox盒子里面渲染每一个列表项。
-
listbox高度 = 每一项的高度 * 列表项的总条数。使得撑开container盒子,产生滚动条
-
初始化的时候计算可视区需要显示的条数,以及开始索引,结束索引等。
- 计算条数时,注意要使用Math.ceil(),而不是floor()
-
监听Container盒子的滚动事件,滚动时计算开始索引和结束索引。
-
这就实现了列表的无缝衔接
上代码
import React, { memo, useState, useMemo, useCallback, useRef } from "react";
import styled from "styled-components";
const Container = styled.div`
overflow-y: auto;
overflow-x: hidden;
height: ${({ height }) => height};
`
const ListBox = styled.div`
background-color: pink;
position: relative;
`
const VirList3 = memo(function ({ list = [], containerHeight = 800, ItemBox = <></>, itemHeight = 50, ...props }) {
const ContainerRef = useRef();
const [startIndex, setStartIndex] = useState(0);
// 用于撑开Container的盒子,计算其高度
const wraperHeight = useMemo(function () {
return list.length * itemHeight;
}, [list, itemHeight])
// 可视区域最多显示的条数
const limit = useMemo(function () {
return Math.ceil(containerHeight / itemHeight);
}, [startIndex]);
// 当前可视区域显示的列表的结束索引
const endIndex = useMemo(function () {
return Math.min(startIndex + limit, list.length - 1);
}, [startIndex, limit]);
const handleSrcoll = useCallback(function (e) {
if (e.target !== ContainerRef.current) return;
const scrollTop = e.target.scrollTop;
let currentIndex = Math.floor(scrollTop / itemHeight);
if (currentIndex !== startIndex) {
setStartIndex(currentIndex);
}
}, [ContainerRef, itemHeight, startIndex])
const renderList = useCallback(function () {
const rows = [];
for (let i = startIndex; i <= endIndex; i++) {
// 渲染每个列表项
rows.push(<ItemBox
data={i}
key={i}
style={{
width: "100%",
height: itemHeight - 1 + "px",
borderBottom: "1px solid #aaa",
position: "absolute",
top: i * itemHeight + "px",
left: 0,
right: 0,
}} />)
}
return rows;
}, [startIndex, endIndex, ItemBox])
return (<Container
height={containerHeight + "px"}
ref={ContainerRef}
onScroll={handleSrcoll}>
<ListBox
style={{ height: wraperHeight + "px" }}>
{renderList()}
</ListBox>
</Container>)
})
export default VirList3;
复制代码
使用组件
版本二:列表项高度不固定
问题
列表项高度不固定的话,那如何计算当前可视区域应该显示的条数呢,如何在滚动的时候,修改首位索引,达到无缝衔接呢?
dom结构
增加一层div包裹列表项:该项目中指的是Wraper盒子
整体思路
-
由于列表项高度不固定,导致显示的条数limit等变量无法计算。所以我们预先定义一个默认列表项高度(该高度需要根据自己的项目确定合适的高度)。
-
使用一个缓存数组存储各个列表项的位置,每个对象包含:索引,每一项的顶部距离ListBox容器的距离,每一项底部距离ListBox容器的距离,每一项的高度。使用useState将该缓存数组进行初始化。
-
计算limit:因为每一项的高度不固定,所以需要根据容器滚动实时去计算。使用useMemo当作计算属性,依赖缓存数组进行实时更新。
-
ListBox的高度默认为列表项数乘以默认列表项高度,当缓存数组更新的时候会触发ListBox高度重新计算。具体代码在wraperHeight位置。
-
getTransform值:当滚动的时候需要调整Wraper盒子的高度,以实现页面滚动时,无缝衔接效果。
-
在滚动时,重新计算起始索引(使用二分查找),结束索引,limit。
-
当页面滚动时,缓存数组获取列表项中的自定义属性
data-id
获取到当前项索引,然后通过计算得到当前项真实的位置。踩坑提示:注意这里要用data-id,不要用当前循环的那个索引。
二分查找
上代码
import React, { memo, useState, useMemo, useCallback, useRef, useEffect } from "react";
import styled from "styled-components";
const Container = styled.div`
overflow-y: auto;
height: ${({ height }) => height};
`
const ListBox = styled.div`
background-color: pink;
position: relative;
`
const Wraper = styled.div`
`
const VirList4 = memo(function ({
list = [],
containerHeight = 800,
ItemBox = <></>,
estimatedItemHeight = 90,
...props }) {
const ContainerRef = useRef();
const WraperRef = useRef();
const [startIndex, setStartIndex] = useState(0);
const [scrollTop, setScrollTop] = useState(0);
const [positionCache, setPositionCache] = useState(function () {
const positList = [];
list.forEach((_, i) => {
positList[i] = {
index: i,
height: estimatedItemHeight,
top: i * estimatedItemHeight,
bottom: (i + 1) * estimatedItemHeight,
}
})
return positList;
})
const limit = useMemo(function () {
let sum = 0
let i = 0
for (; i < positionCache.length; i++) {
sum += positionCache[i].height;
if (sum >= containerHeight) {
break
}
}
return i;
}, [positionCache]);
const endIndex = useMemo(function () {
return Math.min(startIndex + limit, list.length - 1);
}, [startIndex, limit]);
const wraperHeight = useMemo(function () {
let len = positionCache.length;
if (len !== 0) {
return positionCache[len - 1].bottom
}
return list.length * estimatedItemHeight;
}, [list, positionCache, estimatedItemHeight])
useEffect(function () {
const nodeList = WraperRef.current.childNodes;
const positList = [...positionCache]
let needUpdate = false;
nodeList.forEach((node, i) => {
let newHeight = node.getBoundingClientRect().height;
const nodeID = Number(node.id.split("-")[1]);
const oldHeight = positionCache[nodeID]["height"];
const dValue = oldHeight - newHeight;
if (dValue) {
needUpdate = true;
positList[nodeID].height = node.getBoundingClientRect().height;
positList[nodeID].bottom = nodeID > 0 ? (positList[nodeID - 1].bottom + positList[nodeID].height) : positList[nodeID].height;
positList[nodeID].top = nodeID > 0 ? positList[nodeID - 1].bottom : 0;
}
})
if (needUpdate) {
setPositionCache(positList)
}
}, [scrollTop])
const getTransform = useCallback(function () {
return `translate3d(0,${startIndex >= 1 ? positionCache[startIndex - 1].bottom : 0}px,0)`
}, [positionCache, startIndex]);
const handleSrcoll = useCallback(function (e) {
if (e.target !== ContainerRef.current) return;
const scrollTop = e.target.scrollTop;
setScrollTop(scrollTop)
const currentStartIndex = getStartIndex(scrollTop);
console.log(currentStartIndex);
if (currentStartIndex !== startIndex) {
setStartIndex(currentStartIndex);
console.log(startIndex + "====--" + limit + "--====" + endIndex)
}
}, [ContainerRef, estimatedItemHeight, startIndex])
const renderList = useCallback(function () {
const rows = [];
for (let i = startIndex; i <= endIndex; i++) {
rows.push(<ItemBox
data={list[i]}
index={i}
key={i}
style={{
width: "100%",
borderBottom: "1px solid #aaa",
}} />)
}
return rows;
}, [startIndex, endIndex, ItemBox])
return (<Container
height={containerHeight + "px"}
ref={ContainerRef}
onScroll={handleSrcoll}>
<ListBox
style={{ height: wraperHeight + "px" }}>
<Wraper
style={{
transform: getTransform()
}}
ref={WraperRef}
>
{renderList()}
</Wraper>
</ListBox>
</Container>)
})
export default VirList4;
复制代码
使用
效果图