338 lines
9.3 KiB
TypeScript
338 lines
9.3 KiB
TypeScript
'use client';
|
||
import type React from 'react';
|
||
import { useEffect, useRef } from 'react';
|
||
import { useDebounceFn, useUpdate } from 'ahooks';
|
||
|
||
type VirtualGridProps<T extends any = any> = {
|
||
rowHeight?: number;
|
||
itemRender?: (item: T) => React.ReactNode;
|
||
dataSource?: T[];
|
||
noMoreData?: boolean;
|
||
noMoreDataRender?: (() => React.ReactNode) | null;
|
||
isLoadingMore?: boolean;
|
||
isFirstLoading?: boolean;
|
||
loadMore?: () => void;
|
||
gap?: number;
|
||
padding?: {
|
||
right?: number;
|
||
left?: number;
|
||
};
|
||
columnCalc?: (width: number) => number;
|
||
keySetter?: (item: T) => string | number;
|
||
preRenderHeight?: number;
|
||
header?: React.ReactNode;
|
||
} & React.HTMLAttributes<HTMLDivElement>;
|
||
|
||
const useResizeObserver = (
|
||
ref: React.RefObject<HTMLDivElement | null>,
|
||
callback: () => void
|
||
) => {
|
||
useEffect(() => {
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
callback();
|
||
});
|
||
if (ref.current) {
|
||
resizeObserver.observe(ref.current);
|
||
}
|
||
return () => {
|
||
if (ref.current) {
|
||
resizeObserver.unobserve(ref.current);
|
||
}
|
||
resizeObserver.disconnect();
|
||
};
|
||
}, []);
|
||
};
|
||
|
||
function VirtualGrid<T extends any = any>(
|
||
props: VirtualGridProps<T>
|
||
): React.ReactNode {
|
||
const {
|
||
rowHeight = 0,
|
||
itemRender,
|
||
columnCalc,
|
||
gap = 10,
|
||
dataSource = [],
|
||
noMoreData = false,
|
||
loadMore,
|
||
header,
|
||
keySetter,
|
||
preRenderHeight = 100,
|
||
padding,
|
||
isLoadingMore,
|
||
isFirstLoading,
|
||
noMoreDataRender = () => 'No more data',
|
||
...others
|
||
} = props;
|
||
const { left = 0, right = 0 } = padding || {};
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const headerRef = useRef<HTMLDivElement | null>(null);
|
||
const listHeight = useRef<number>(0);
|
||
const previousDataSourceLength = useRef<number>(0);
|
||
|
||
// 画布数据
|
||
const scrollState = useRef<{
|
||
// 画布测量宽高
|
||
viewWidth: number;
|
||
viewHeight: number;
|
||
// 视图的开始位置距离顶部的位置
|
||
start: number;
|
||
// 列数
|
||
column: number;
|
||
// 每个cell的实际宽度
|
||
width: number;
|
||
// header 的测量高度
|
||
headerHeight: number;
|
||
// 实际可用宽度
|
||
usableWidth: number;
|
||
}>({
|
||
viewWidth: 0,
|
||
viewHeight: 0,
|
||
start: 0,
|
||
column: 0,
|
||
width: 0,
|
||
headerHeight: 0,
|
||
usableWidth: 0,
|
||
});
|
||
// 计算过布局几何属性的数据
|
||
const queueState = useRef<
|
||
{
|
||
// 源数据
|
||
item: T;
|
||
// 到顶部的距离
|
||
y: number;
|
||
// 最终样式
|
||
style: React.CSSProperties;
|
||
}[]
|
||
>([]);
|
||
// 需要实际渲染的数据
|
||
const renderList = useRef<
|
||
{
|
||
item: T;
|
||
y: number;
|
||
style: React.CSSProperties;
|
||
}[]
|
||
>([]);
|
||
// 手动更新,避免多次刷新组件
|
||
const update = useUpdate();
|
||
|
||
// 初始化画布参数,在container size改变时需要调用
|
||
const initScrollState = () => {
|
||
if (!containerRef.current) return;
|
||
|
||
scrollState.current.viewWidth = containerRef.current.clientWidth;
|
||
scrollState.current.viewHeight = containerRef.current.clientHeight;
|
||
// 实际可用宽度为视口宽度减去左右padding
|
||
scrollState.current.usableWidth =
|
||
containerRef.current.clientWidth - left - right;
|
||
// 根据实际可用宽度计算列数
|
||
const column = columnCalc?.(scrollState.current.usableWidth) || 1;
|
||
scrollState.current.column = column;
|
||
scrollState.current.start = containerRef.current.scrollTop;
|
||
// 每个cell的实际宽度(为可用宽度除以列数)
|
||
scrollState.current.width =
|
||
(scrollState.current.usableWidth - (column - 1) * gap!) / column;
|
||
// header 的测量高度
|
||
scrollState.current.headerHeight = headerRef.current?.clientHeight || 0;
|
||
};
|
||
|
||
const genereateRenderList = () => {
|
||
const finalStart = scrollState.current.start - preRenderHeight!;
|
||
const finalEnd =
|
||
scrollState.current.start +
|
||
scrollState.current.viewHeight +
|
||
preRenderHeight!;
|
||
const newRenderList = [];
|
||
|
||
// 注意这里只针对dataSource长度进行遍历,而不是queueState长度
|
||
for (let i = 0; i < dataSource.length; i++) {
|
||
if (
|
||
queueState.current[i].y + rowHeight! >= finalStart &&
|
||
queueState.current[i].y <= finalEnd
|
||
) {
|
||
// 注意这里需要浅拷贝, 不然resize的时候会直接更新到renderList, 导致下面计算条件update不生效
|
||
newRenderList.push({ ...queueState.current[i] });
|
||
}
|
||
}
|
||
if (
|
||
newRenderList.length !== renderList.current.length ||
|
||
newRenderList[0]?.y !== renderList.current[0]?.y
|
||
) {
|
||
update();
|
||
}
|
||
renderList.current = newRenderList;
|
||
};
|
||
|
||
// 重新计算高度和滚动位置
|
||
const resetHeightAndScroll = () => {
|
||
// 最小有一行
|
||
const maxRow = Math.max(
|
||
Math.ceil(dataSource.length / scrollState.current.column),
|
||
1
|
||
);
|
||
// 高度 = 最大行数 * 行高 + (最大行数 - 1) * 间距 + header 高度
|
||
listHeight.current =
|
||
maxRow * rowHeight! +
|
||
(maxRow - 1) * gap! +
|
||
scrollState.current.headerHeight;
|
||
// 如果数据长度小于等于之前的,则滚动到顶部
|
||
if (dataSource.length <= previousDataSourceLength.current) {
|
||
containerRef.current?.scrollTo({
|
||
top: 0,
|
||
behavior: 'instant',
|
||
});
|
||
}
|
||
previousDataSourceLength.current = dataSource.length;
|
||
genereateRenderList();
|
||
};
|
||
|
||
const calculateCellRect = (i: number) => {
|
||
// 第几行, 从0开始
|
||
const row = Math.floor(i / scrollState.current.column);
|
||
// 第几列, 从0开始
|
||
const col = i % scrollState.current.column;
|
||
// 到顶部的距离
|
||
const y = scrollState.current.headerHeight + row * rowHeight! + gap! * row;
|
||
// 到左边的距离
|
||
const x = left + col * scrollState.current.width + col * gap!;
|
||
return {
|
||
y,
|
||
x,
|
||
};
|
||
};
|
||
|
||
// 页面尺寸变化,重新计算布局
|
||
const resizeQueueData = () => {
|
||
for (let i = 0; i < dataSource.length; i++) {
|
||
const { y, x } = calculateCellRect(i);
|
||
queueState.current[i].style = {
|
||
height: rowHeight!,
|
||
width: scrollState.current.width,
|
||
transform: `translate(${x}px, ${y}px)`,
|
||
};
|
||
queueState.current[i].y = y;
|
||
}
|
||
resetHeightAndScroll();
|
||
};
|
||
|
||
const updateQueueData = () => {
|
||
for (let i = 0; i < dataSource.length; i++) {
|
||
const item = dataSource[i];
|
||
if (queueState.current[i]) {
|
||
queueState.current[i].item = item;
|
||
continue;
|
||
}
|
||
|
||
const { y, x } = calculateCellRect(i);
|
||
queueState.current.push({
|
||
item: item,
|
||
y: y,
|
||
style: {
|
||
height: rowHeight!,
|
||
width: scrollState.current.width,
|
||
transform: `translate(${x}px, ${y}px)`,
|
||
},
|
||
});
|
||
}
|
||
resetHeightAndScroll();
|
||
};
|
||
|
||
const handleScroll = () => {
|
||
if (!containerRef.current) return;
|
||
scrollState.current.start = containerRef.current.scrollTop;
|
||
genereateRenderList();
|
||
if (noMoreData || isLoadingMore || isFirstLoading) return;
|
||
const { scrollTop, clientHeight } = containerRef.current;
|
||
if (scrollTop + clientHeight >= listHeight.current) {
|
||
loadMore?.();
|
||
}
|
||
};
|
||
|
||
const { run: handleResize } = useDebounceFn(
|
||
() => {
|
||
initScrollState();
|
||
resizeQueueData();
|
||
},
|
||
{
|
||
wait: 200,
|
||
maxWait: 300,
|
||
}
|
||
);
|
||
|
||
// 监听容器和header的尺寸变化
|
||
useResizeObserver(containerRef, handleResize);
|
||
useResizeObserver(headerRef, handleResize);
|
||
|
||
// 当 dataSource 变化时,重新计算布局
|
||
useEffect(() => {
|
||
// 初始化布局
|
||
if (!scrollState.current.viewWidth) {
|
||
initScrollState();
|
||
}
|
||
// 添加到队列
|
||
updateQueueData();
|
||
}, [dataSource]);
|
||
|
||
// 插槽高度,用于 loading 和 no more data
|
||
const loadingHeight = 20;
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
{...others}
|
||
className={`relative overflow-auto ${others.className}`}
|
||
style={{
|
||
scrollbarGutter: 'stable', // 为滚动条预留空间,防止布局抖动
|
||
...others.style,
|
||
}}
|
||
onScroll={handleScroll}
|
||
>
|
||
<div
|
||
className="relative w-full"
|
||
style={{ height: listHeight.current + loadingHeight }}
|
||
>
|
||
<div ref={headerRef} className="flex">
|
||
{header}
|
||
</div>
|
||
{renderList.current.map(({ item, style }) => (
|
||
<div
|
||
className="absolute top-0 left-0 overflow-hidden"
|
||
key={keySetter?.(item)}
|
||
style={style}
|
||
>
|
||
{itemRender?.(item)}
|
||
</div>
|
||
))}
|
||
|
||
{/* 加载更多 */}
|
||
{!!renderList.current.length && !noMoreData && !isFirstLoading && (
|
||
<div
|
||
className="absolute flex w-full items-center justify-center"
|
||
style={{ top: listHeight.current, height: loadingHeight }}
|
||
>
|
||
{isLoadingMore ? 'Loading more...' : 'load more'}
|
||
</div>
|
||
)}
|
||
|
||
{/* 没有更多数据 */}
|
||
{noMoreData && noMoreDataRender ? (
|
||
<div
|
||
className="absolute flex w-full items-center justify-center"
|
||
style={{ top: listHeight.current, height: loadingHeight }}
|
||
>
|
||
{noMoreDataRender()}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 没有数据 */}
|
||
{!renderList.current.length && !isFirstLoading && (
|
||
<div className="absolute flex w-full items-center justify-center">
|
||
No data
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default VirtualGrid;
|