背景
云仓数据大盘中采购单和订单列表存在一单多货的情况,无法使用egGrid组件。
分页每一页数据量较大时,一次性渲染大量dom,势必造成页面卡顿。
原理
无论页面中有多少条数据,我们在一屏范围内能看到的只有那么几条。所以在页面滚动的时候只渲染视口范围内的数据。
效果:
未命名.gif
对于数据层来说,就是需要在列表滚动的时候动态地维护一个出现在视口范围内的数据list。
现成轮子
目前,业内虚拟滚动做的比较好的方案有react-virtualized和react-window,antd的select框的虚拟滚动就是采用的该方案。针对我们这次的需求结合调研我们发现,无论是我们的采购单还是订单都是一单多货,也就是说我们列表项的每一项高度都是不固定的,因此没法用比较轻量级的react-window(不到2K), 如果将react-virtualized引入项目中,会使项目体积变大,由于该项目暂时没有其他组件需要使用虚拟滚动,我暂时选择了自己封装虚拟组件这个方案。
截屏2022-05-29 15.08.06.png
思路
在视图层,需要两个容器, 外层的wrapper视口区域,高度确定。内层的container为所有列表高度之和。
....
由于可见列表项是动态渲染的。不能采用普通的布局,所有的列表项必须使用绝对定位。因此,在数据请求回来后,就要计算出列表的总高度和每一项的高度,放在一个list中存储起来,每一个列表的样式也从这个数据中取。这个list的数据结构如下所示。
interface IPosition {
id: IdType;
height: number;
top: number;
}
type Positions = IPosition[]
列表首次渲染时,计算出上述的位置信息和列表总高度。
useEffect(() => {
// 计算wrapper高度
const newHeight = wrapperRef.current?.offsetHeight || 0;
if (height !== newHeight) {
setHeight(newHeight);
}
updatePosition();
}, [list]);
监听列表的滚动事件,实时计算出现在视口区域内的列表的起始和结束索引位置。
const getStartIndex = () => {
let sum = 0;
for (let i = 0; i < list.length; i++) {
const eachHeight = positions[i]?.height || 0;
sum += eachHeight;
if (sum >= scrollTop) {
return i;
}
}
return 0;
};
const getLastIndex = () => {
let sum = 0;
// 每一项高度都不确定第一项的高度不能列入可视区域计算
for (let i = startIndex + 1; i < list.length; i++) {
const eachHeight = positions[i]?.height || 0;
sum += eachHeight;
if (sum > height) {
return i;
}
}
return list.length - 1;
};
通过这个两个索引位置得到可视区域的slicedList
const startIndex = getStartIndex();
const endIndex = Math.min(getLastIndex(), list.length - 1);
const visibleList = list.slice(startIndex, endIndex + 1);
完整代码
import React, { useState, useRef, useEffect } from 'react';
import styles from './index.less';
import type { VirtualListProps, IPosition, IdType } from './interface';
import VirtualListItem from './listItem';
// 计算每一项的高度
const calcItemHeight = (num: number, estimatedHeight: number): number => {
return 46 + num * estimatedHeight + (num - 1);
};
export const VirtualList: React.FC = (props) => {
const { list, eachProductHeight, renderItem, itemMargin } = props;
const [
positions,
setPositions,
] = useState([]);
const [
scrollTop,
setScrollTop,
] = useState(0);// 滚动条卷去的高度
const [
totalHeight,
setTotalHeight,
] = useState(0);// 列表项总高度
const [
height,
setHeight,
] = useState(0);// 容器高度
const wrapperRef = useRef(null);
function handleScroll() {
if (wrapperRef.current !== null) {
setScrollTop(wrapperRef.current.scrollTop);
}
}
const getStartIndex = () => {
let sum = 0;
for (let i = 0; i < list.length; i++) {
const eachHeight = positions[i]?.height || 0;
sum += eachHeight;
if (sum >= scrollTop) {
return i;
}
}
return 0;
};
const getLastIndex = () => {
let sum = 0;
// 每一项高度都不确定第一项的高度不能列入可视区域计算
for (let i = startIndex + 1; i < list.length; i++) {
const eachHeight = positions[i]?.height || 0;
sum += eachHeight;
if (sum > height) {
return i;
}
}
return list.length - 1;
};
const getItemStyle = (id: IdType) => {
const it = positions.find((it) => it.id === id);
return it
? {
height: `${it.height}px`,
transform: `translateY(${it.top}px`,
}
: {};
};
// 计算位置信息
const updatePosition = () => {
const result: IPosition[] = [];
list.forEach((it, index) => {
const prev = result[index - 1];
const value = {
top: prev ? prev.top + prev.height : 0,
height: calcItemHeight(it.detail?.length, eachProductHeight),
id: it.id,
};
result[index] = value;
});
const last = result[result.length - 1];
const totalHeight = last ? last.height + last.top : 0;
setTotalHeight(totalHeight);
setPositions(result);
};
useEffect(() => {
// 计算wrapper高度
const newHeight = wrapperRef.current?.offsetHeight || 0;
if (height !== newHeight) {
setHeight(newHeight);
}
updatePosition();
}, [list]);
const startIndex = getStartIndex();
const endIndex = Math.min(getLastIndex(), list.length - 1);
const visibleList = list.slice(startIndex, endIndex + 1);
return (
{
visibleList.map((item, index) => (
{renderItem(item)}
))
}
);
};