简单的高性能虚拟列表1.0
仅渲染start-end直接的数据 使页面的节点保持尽可能的少,这样不会造成过重渲染重绘开销。
这种模式即使是百万级的数据量也可以做到实时渲染
容器层
.container {
background-color: #323e52;
width: 100%;
height: 100%;
}
.tools {
padding: 10px;
color: #fff;
}
.item {
min-height: 12px;
background: #181f29;
width: 100%;
padding: 2px;
margin: 2px;
color: #fff;
font-size: 12px;
}
import * as React from 'react';
import { randomData } from './helper';
import { IData, IListOption } from './type';
import { Scrollbars } from './Component';
import './index.css';
interface IProps {}
interface IStates {
dataSource: IData[];
range: number;
}
class VirtualList extends React.PureComponent<IProps, IStates> {
private scrollRef: React.RefObject<Scrollbars<IData>> = React.createRef();
constructor(props: IProps) {
super(props);
this.state = { dataSource: randomData(10000), range: 0 };
}
cellRender = (node: IData, { index }: IListOption) => {
return (
<div className="item">
序号:{index}-{node.name}
</div>
);
};
handleScroll = (): void => {
const { range } = this.state;
this.scrollRef.current.scrollToRow(range);
};
render(): React.ReactNode {
const { dataSource, range } = this.state;
return (
<div className="container">
<div className="tools">
<span>总数据量:{dataSource.length}</span>
<input
type="range"
value={range}
min={0}
max={dataSource.length - 1}
onChange={(e) => {
this.setState({ range: Number(e.target.value) });
}}
/>
{range}
<button onClick={this.handleScroll}>滚动到</button>
</div>
<Scrollbars
ref={this.scrollRef}
style={{ width: 200, height: 500 }}
className="custom-virtual-list"
itemHeight={40}
dataSource={dataSource}
cellRender={this.cellRender}
/>
</div>
);
}
}
export default VirtualList;
滚动组件
.virtual-list-scroll {
overflow: auto;
position: relative;
scroll-behavior: smooth;
will-change: transform;
background-color: #202735;
}
import * as React from 'react';
import { binarySearch } from '../helper';
import { CompareResult, IListCachedPosition, IListOption } from '../type';
import Grid from './Grid';
import './ScrollBar.css';
interface IProps<T> {
overScan?: number; // 超出的预加载数量
style?: React.CSSProperties; // 滚动的样式
className?: string;
itemHeight?: number;
dataSource: T[]; // 数据
cellRender: (node: T, option: IListOption) => React.ReactNode; // 自定义节点渲染
}
interface IStates<T> {
scrollTop: number;
cachedPositions: IListCachedPosition[];
}
/**
* 虚拟滚动列表
*
* 仅显示可见区域的节点
* TODO: 实现行高自适配
*/
class ScrollBar<T> extends React.Component<React.PropsWithChildren<IProps<T>>, IStates<T>> {
private domRef: React.RefObject<HTMLDivElement> = React.createRef();
private cachedPositions: IListCachedPosition[] = []; // 缓存的节点位置列表
private startIndex = 0;
private endIndex = 0;
private originStartIdx = 0;
private limit = 0;
private total = 0;
private phantomHeight = 0; // 滚动区高度
private estimateMinHeight = 25; // 预估节点高度
static defaultProps = {
overScan: 2,
};
constructor(props: IProps<T>) {
super(props);
this.total = props.dataSource.length;
this.estimateMinHeight = props.itemHeight ?? this.estimateMinHeight;
this.initCachedPositions();
this.state = {
cachedPositions: this.cachedPositions,
scrollTop: 0,
};
this.phantomHeight = this.cachedPositions[this.cachedPositions.length - 1].bottom;
this.originStartIdx = 0;
}
componentDidMount(): void {
const contentHeight = this.domRef.current?.getBoundingClientRect().height;
this.limit = Math.ceil(contentHeight / this.estimateMinHeight);
this.startIndex = Math.max(this.originStartIdx - this.props.overScan, 0);
this.endIndex = Math.min(this.originStartIdx + this.limit + this.props.overScan, this.total - 1);
this.setState({ scrollTop: this.domRef.current.scrollTop });
}
// 初始化cachedPositions
initCachedPositions = (): void => {
const { estimateMinHeight } = this;
this.cachedPositions = [];
for (let i = 0; i < this.total; ++i) {
this.cachedPositions[i] = {
index: i,
height: estimateMinHeight, // 先使用estimateHeight估计
top: i * estimateMinHeight, // 同上
bottom: (i + 1) * estimateMinHeight,
dValue: 0,
};
}
};
getStartIndex = (scrollTop = 0): number => {
let idx = binarySearch<IListCachedPosition, number>(
this.cachedPositions,
scrollTop,
(currentValue: IListCachedPosition, targetValue: number) => {
const currentCompareValue = currentValue.bottom;
if (currentCompareValue === targetValue) {
return CompareResult.eq;
}
if (currentCompareValue < targetValue) {
return CompareResult.lt;
}
return CompareResult.gt;
},
);
const targetItem = this.cachedPositions[idx];
// Incase of binarySearch give us a not visible data(an idx of current visible - 1)...
if (targetItem.bottom < scrollTop) {
idx += 1;
}
return idx;
};
handleScroll = (): void => {
const {
total,
limit,
originStartIdx,
props: { overScan },
} = this;
const scrollTop = this.domRef.current.scrollTop;
const currIndex = this.getStartIndex(scrollTop);
if (originStartIdx !== currIndex) {
this.originStartIdx = currIndex;
this.startIndex = Math.max(currIndex - overScan, 0);
this.endIndex = Math.min(currIndex + limit + overScan, total - 1);
this.setState({
scrollTop: scrollTop,
});
}
};
scrollToRow(index: number): void {
this.domRef.current.scrollTo({ top: this.cachedPositions[index].top });
}
render(): React.ReactNode {
const {
startIndex,
endIndex,
phantomHeight,
state: { cachedPositions },
props: { className, style, dataSource, cellRender },
} = this;
return (
<div ref={this.domRef} className={`virtual-list-scroll ${className}`} style={style} onScroll={this.handleScroll}>
<Grid
start={startIndex}
end={endIndex}
contentHeight={phantomHeight}
dataSource={dataSource}
cache={cachedPositions}
cellRender={cellRender}
/>
</div>
);
}
}
export default ScrollBar;
滚动区
import * as React from 'react';
import { IListCachedPosition, IListOption } from '../type';
import Cell from './Cell';
interface IProps<T> {
contentHeight: number;
dataSource: T[];
start: number;
end: number;
cache: IListCachedPosition[];
cellRender: (node: T, option: IListOption) => React.ReactNode;
}
interface IStates {}
/**
* 网格类
* TODO: 实现多行多列布局
*/
class Grid<T> extends React.PureComponent<IProps<T>, IStates> {
render(): React.ReactNode {
const { start, contentHeight, dataSource, end, cache, cellRender } = this.props;
return (
<>
<div className="virtual-list-grid" style={{ height: contentHeight }}>
{dataSource.map((item, index) => {
return (
index >= start &&
index <= end && (
<Cell key={index} index={index} cache={cache[index]}>
{cellRender(item, { index })}
</Cell>
)
);
})}
</div>
</>
);
}
}
export default Grid;
节点
.virtual-list-cell {
width: 100%;
position: absolute;
display: flex;
}
import * as React from 'react';
import { IListCachedPosition } from '../type';
import './Cell.css';
interface IProps {
index: number;
cache: IListCachedPosition;
onMount?: (dom: Element, index: number) => void;
}
interface IStates {}
/**
* 当行类
* 自定义的渲染
*/
class Cell extends React.PureComponent<React.PropsWithChildren<IProps>, IStates> {
render(): React.ReactNode {
const { children, index, cache } = this.props;
return (
<div
className="virtual-list-cell"
id={`dt-virtual-cell-${index}`}
style={{ height: cache.height, top: cache.top }}
>
{children}
</div>
);
}
}
export default Cell;
数据函数
interface IData {
name: string;
id: number;
}
interface IListOption {
index: number;
}
interface IListCachedPosition {
index: number; // 当前pos对应的元素的下标
top: number; // 顶部位置
bottom: number; // 底部位置
height: number; // 元素高度
dValue: number; // 高度是否和之前(estimate)存在不同
}
enum CompareResult {
eq = 1,
lt,
gt,
}
export { IData, IListOption, IListCachedPosition, CompareResult };
import { CompareResult, IData } from './type';
function randomSingle(): IData {
return { name: `${Math.round(Math.random() * 1e5)}`, id: Math.random() * 1e5 };
}
function randomData(seed: number): IData[] {
return new Array(seed).fill(1).map(() => randomSingle());
}
function binarySearch<T, VT>(list: T[], value: VT, compareFunc: (current: T, value: VT) => CompareResult) {
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while (start <= end) {
tempIndex = Math.floor((start + end) / 2);
const midValue = list[tempIndex];
const compareRes: CompareResult = compareFunc(midValue, value);
if (compareRes === CompareResult.eq) {
return tempIndex;
}
if (compareRes === CompareResult.lt) {
start = tempIndex + 1;
} else if (compareRes === CompareResult.gt) {
end = tempIndex - 1;
}
}
return tempIndex;
}
export { randomSingle, randomData, binarySearch };