如何实现一个可以加载10万+数据的长列表(二)
如何实现非固定高度的长列表
在之前的基础上需要做一下改动
设置两个容器,一个为真实的滚动区容器,另一个为显示容器。
显示容器固定显示,通过更改滚动容器高度来适配
结构如下:
<div ref={this.domRef} className={`virtual-list-scroll ${className}`} style={style} onScroll={this.handleScroll}>
<div className="virtual-list-grid-virtual" style={{ height: this.manage.getHeight() }} />
<div ref={this.containerRef} className="virtual-list-grid">
{dataSource.map((item, index) => {
if ((index >= start && index <= end) || debug) {
return (
<Cell
key={index - start}
index={index}
max={dataSource.length - 1}
manage={manage}
cellRender={cellRender}
item={item}
/>
);
}
return null;
})}
</div>
</div>
如此,在滚动时更新起始索引和结束索引达到数据更新效果。
当某个节点加载后在更新对应节点的缓存信息,
// 更新缓存数据
measure(rowIndex: number, dom: HTMLElement): void {
this.checkIndex(rowIndex);
const top = this.topList[rowIndex];
if (!top) {
return;
}
const height = dom.offsetHeight;
const offset = height - top.height;
this.topList[rowIndex] = { top: top.top, height };
if (offset) {
for (let i = rowIndex + 1; i < this.topList.length; i++) {
this.topList[i].top += offset;
}
}
}
如此便达到了自适应的高度。
全部代码贴上:
滚动区
import * as React from 'react';
import Grid from './Grid';
import { IListOption } from '../../type';
import { PositionManage, IPositionManage } from './Measure';
import './ScrollBar.css';
interface IProps<T> {
overScan?: number; // 超出的预加载数量
style?: React.CSSProperties; // 滚动的样式
debug?: boolean;
className?: string;
estimateMinHeight?: number;
dataSource: T[]; // 数据
cellRender: (node: T, option: IListOption) => React.ReactNode; // 自定义节点渲染
}
interface IStates<T> {
scrollTop: number;
}
/**
* 虚拟滚动列表
*
* 仅显示可见区域的节点
* TODO: 实现行高自适配
*/
class ScrollBar<T> extends React.Component<React.PropsWithChildren<IProps<T>>, IStates<T>> {
private domRef: React.RefObject<HTMLDivElement> = React.createRef();
private gridRef: React.RefObject<Grid<T>> = React.createRef();
private manage: IPositionManage;
private startIndex = 0;
private endIndex = 0;
private originStartIdx = 0;
private limit = 0;
private total = 0;
private estimateMinHeight = 25; // 预估节点高度
private contentHeight = 0;
static defaultProps = {
overScan: 2,
};
constructor(props: IProps<T>) {
super(props);
this.total = props.dataSource.length;
this.estimateMinHeight = props.estimateMinHeight ?? this.estimateMinHeight;
this.manage = new PositionManage(this.total - 1);
this._initCachedPositions();
this.state = {
scrollTop: 0,
};
}
componentDidMount(): void {
this.contentHeight = this.domRef.current?.getBoundingClientRect().height;
this.limit = Math.ceil(this.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 });
}
private _initCachedPositions = (): void => {
const { estimateMinHeight } = this;
for (let i = 0; i < this.total; ++i) {
this.manage.cache(i, {
top: i * estimateMinHeight,
height: estimateMinHeight,
});
}
};
private getStartIndex = (scrollTop = 0): number => {
return this.manage.getStartIndex(scrollTop);
};
private handleScroll = (): void => {
const {
total,
originStartIdx,
props: { overScan },
} = this;
const scrollTop = this.domRef.current.scrollTop;
const currIndex = this.getStartIndex(scrollTop) - this.props.overScan;
if (originStartIdx !== currIndex) {
this.originStartIdx = currIndex;
this.startIndex = Math.max(currIndex - overScan, 0);
const endIndex = this.getStartIndex(scrollTop + this.contentHeight);
this.endIndex = Math.min(endIndex + overScan, total - 1);
const prev = this.manage.get(this.startIndex - 1);
this.gridRef.current.containerRef.current.style.top = `${prev ? prev?.top + prev?.height : 0}px`;
this.setState({
scrollTop: scrollTop,
});
}
};
scrollToRow(index: number): void {
this.domRef.current.scrollTo({ top: this.manage.get(index).top });
window.setTimeout(() => this.domRef.current.scrollTo({ top: this.manage.get(index).top }));
}
render(): React.ReactNode {
const {
startIndex,
endIndex,
props: { className, style, dataSource, debug, cellRender },
} = this;
return (
<div ref={this.domRef} className={`virtual-list-scroll ${className}`} style={style} onScroll={this.handleScroll}>
<div className="virtual-list-grid-virtual" style={{ height: this.manage.getHeight() }} />
<Grid
ref={this.gridRef}
start={startIndex}
debug={debug}
manage={this.manage}
end={endIndex}
dataSource={dataSource}
cellRender={cellRender}
/>
</div>
);
}
}
export default ScrollBar;
网格
import * as React from 'react';
import { IListOption } from '../../type';
import { IPositionManage } from './Measure';
import Cell from './Cell';
import './Grid.css';
interface IProps<T> {
manage: IPositionManage;
dataSource: T[];
debug?: boolean;
start: number;
end: number;
cellRender: (node: T, option: IListOption) => React.ReactNode;
}
/**
* 网格类
* TODO: 实现多行多列布局
*/
class Grid<T> extends React.Component<IProps<T>> {
public containerRef: React.RefObject<HTMLDivElement> = React.createRef();
render(): React.ReactNode {
const { start, debug, manage, dataSource, end, cellRender } = this.props;
return (
<div ref={this.containerRef} className="virtual-list-grid">
{dataSource.map((item, index) => {
if ((index >= start && index <= end) || debug) {
return (
<Cell
key={index - start}
index={index}
max={dataSource.length - 1}
manage={manage}
cellRender={cellRender}
item={item}
/>
);
}
return null;
})}
</div>
);
}
}
export default Grid;
行
import * as React from 'react';
import { IListOption } from '../../type';
import { IPositionManage } from './Measure';
import './Cell.css';
interface IProps<T> {
manage: IPositionManage;
index: number;
max: number;
item: T;
cellRender: (node: T, option: IListOption) => React.ReactNode;
}
interface IStates {}
/**
* 当行类
* 自定义的渲染
*/
class Cell<T> extends React.Component<IProps<T>, IStates> {
public ref: React.RefObject<HTMLDivElement> = React.createRef();
private measured: boolean = false;
componentDidMount(): void {
this._measure();
}
componentDidUpdate(): void {
this._measure();
}
private _measureOut = (index: number) => {
this.props.manage.measure(index, this.ref.current);
};
private _measure = () => {
if (this.ref.current && !this.measured) {
this.measured = true;
this._measureOut(this.props.index);
}
};
render(): React.ReactNode {
const { item, index, cellRender } = this.props;
return (
<div ref={this.ref} className="virtual-list-cell">
{cellRender(item, { index, measure: this._measureOut })}
</div>
);
}
}
export default Cell;
测量类
import { binarySearch } from '../../helper';
import { CompareResult } from '../../type';
interface IPositionData {
top: number;
height: number;
}
interface IPositionManage {
cache(rowIndex: number, top: IPositionData): void;
get(rowIndex: number): IPositionData | undefined;
measure(index: number, dom: HTMLElement): void;
getHeight(): number;
getStartIndex(top: number): number;
}
class PositionManage {
private topList: IPositionData[] = [];
constructor(public max: number) {}
checkIndex(rowIndex: number): void {
if (rowIndex > this.max || rowIndex < 0) {
throw new Error('错误索引!');
}
}
cache(rowIndex: number, position: IPositionData): void {
this.checkIndex(rowIndex);
this.topList[rowIndex] = position;
}
getHeight(): number {
return this.topList[this.max].top + this.topList[this.max].height;
}
measure(rowIndex: number, dom: HTMLElement): void {
this.checkIndex(rowIndex);
const top = this.topList[rowIndex];
if (!top) {
return;
}
const height = dom.offsetHeight;
const offset = height - top.height;
this.topList[rowIndex] = { top: top.top, height };
if (offset) {
for (let i = rowIndex + 1; i < this.topList.length; i++) {
this.topList[i].top += offset;
}
}
}
get(rowIndex: number): IPositionData | undefined {
return this.topList[rowIndex];
}
getStartIndex(top: number): number {
return binarySearch<IPositionData, number>(
this.topList,
top,
(currentValue: IPositionData, targetValue: number) => {
const currentCompareValue = currentValue.top;
if (currentCompareValue === targetValue) {
return CompareResult.eq;
}
if (currentCompareValue < targetValue) {
return CompareResult.lt;
}
return CompareResult.gt;
},
);
}
}
export { PositionManage, IPositionManage };
使用方式:
<VirtualList
ref={this.scrollRef}
style={{ width: 150, height: 500 }}
className={`custom-virtual-list ${theme}`}
dataSource={dataSource}
cellRender={this.cellRender}
/>