2025-10-31 02:59:49 +00:00
|
|
|
|
'use client';
|
2025-10-28 07:59:26 +00:00
|
|
|
|
import type React from 'react';
|
2025-10-31 02:59:49 +00:00
|
|
|
|
import { useEffect, useRef } from 'react';
|
2025-10-28 07:59:26 +00:00
|
|
|
|
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;
|
2025-10-31 02:59:49 +00:00
|
|
|
|
isFirstLoading?: boolean;
|
2025-10-28 07:59:26 +00:00
|
|
|
|
loadMore?: () => void;
|
|
|
|
|
|
gap?: number;
|
|
|
|
|
|
padding?: {
|
|
|
|
|
|
right?: number;
|
|
|
|
|
|
left?: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
columnCalc?: (width: number) => number;
|
2025-10-31 02:59:49 +00:00
|
|
|
|
keySetter?: (item: T) => string | number;
|
2025-10-28 07:59:26 +00:00
|
|
|
|
preRenderHeight?: number;
|
|
|
|
|
|
header?: React.ReactNode;
|
|
|
|
|
|
} & React.HTMLAttributes<HTMLDivElement>;
|
|
|
|
|
|
|
2025-10-31 02:59:49 +00:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-28 07:59:26 +00:00
|
|
|
|
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,
|
2025-10-31 02:59:49 +00:00
|
|
|
|
isFirstLoading,
|
|
|
|
|
|
noMoreDataRender = () => 'No more data',
|
2025-10-28 07:59:26 +00:00
|
|
|
|
...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 = [];
|
2025-10-31 02:59:49 +00:00
|
|
|
|
|
|
|
|
|
|
// 注意这里只针对dataSource长度进行遍历,而不是queueState长度
|
2025-10-28 07:59:26 +00:00
|
|
|
|
for (let i = 0; i < dataSource.length; i++) {
|
|
|
|
|
|
if (
|
|
|
|
|
|
queueState.current[i].y + rowHeight! >= finalStart &&
|
|
|
|
|
|
queueState.current[i].y <= finalEnd
|
|
|
|
|
|
) {
|
2025-10-31 02:59:49 +00:00
|
|
|
|
// 注意这里需要浅拷贝, 不然resize的时候会直接更新到renderList, 导致下面计算条件update不生效
|
|
|
|
|
|
newRenderList.push({ ...queueState.current[i] });
|
2025-10-28 07:59:26 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
newRenderList.length !== renderList.current.length ||
|
2025-10-31 02:59:49 +00:00
|
|
|
|
newRenderList[0]?.y !== renderList.current[0]?.y
|
2025-10-28 07:59:26 +00:00
|
|
|
|
) {
|
|
|
|
|
|
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]) {
|
2025-10-31 02:59:49 +00:00
|
|
|
|
queueState.current[i].item = item;
|
2025-10-28 07:59:26 +00:00
|
|
|
|
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();
|
2025-10-31 02:59:49 +00:00
|
|
|
|
if (noMoreData || isLoadingMore || isFirstLoading) return;
|
2025-10-28 07:59:26 +00:00
|
|
|
|
const { scrollTop, clientHeight } = containerRef.current;
|
|
|
|
|
|
if (scrollTop + clientHeight >= listHeight.current) {
|
|
|
|
|
|
loadMore?.();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const { run: handleResize } = useDebounceFn(
|
|
|
|
|
|
() => {
|
|
|
|
|
|
initScrollState();
|
|
|
|
|
|
resizeQueueData();
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
wait: 200,
|
|
|
|
|
|
maxWait: 300,
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 监听容器和header的尺寸变化
|
2025-10-31 02:59:49 +00:00
|
|
|
|
useResizeObserver(containerRef, handleResize);
|
|
|
|
|
|
useResizeObserver(headerRef, handleResize);
|
2025-10-28 07:59:26 +00:00
|
|
|
|
|
|
|
|
|
|
// 当 dataSource 变化时,重新计算布局
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
// 初始化布局
|
|
|
|
|
|
if (!scrollState.current.viewWidth) {
|
|
|
|
|
|
initScrollState();
|
|
|
|
|
|
}
|
2025-10-31 02:59:49 +00:00
|
|
|
|
// 添加到队列
|
2025-10-28 07:59:26 +00:00
|
|
|
|
updateQueueData();
|
|
|
|
|
|
}, [dataSource]);
|
|
|
|
|
|
|
2025-10-31 02:59:49 +00:00
|
|
|
|
// 插槽高度,用于 loading 和 no more data
|
2025-10-28 07:59:26 +00:00
|
|
|
|
const loadingHeight = 20;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
|
{...others}
|
2025-10-31 02:59:49 +00:00
|
|
|
|
className={`relative overflow-auto ${others.className}`}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
scrollbarGutter: 'stable', // 为滚动条预留空间,防止布局抖动
|
|
|
|
|
|
...others.style,
|
|
|
|
|
|
}}
|
2025-10-28 07:59:26 +00:00
|
|
|
|
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>
|
|
|
|
|
|
))}
|
2025-10-31 02:59:49 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 加载更多 */}
|
2025-11-12 07:14:08 +00:00
|
|
|
|
{!!renderList.current.length && !noMoreData && !isFirstLoading && (
|
2025-10-28 07:59:26 +00:00
|
|
|
|
<div
|
|
|
|
|
|
className="absolute flex w-full items-center justify-center"
|
|
|
|
|
|
style={{ top: listHeight.current, height: loadingHeight }}
|
|
|
|
|
|
>
|
2025-10-31 02:59:49 +00:00
|
|
|
|
{isLoadingMore ? 'Loading more...' : 'load more'}
|
2025-10-28 07:59:26 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-31 02:59:49 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 没有更多数据 */}
|
|
|
|
|
|
{noMoreData && noMoreDataRender ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute flex w-full items-center justify-center"
|
|
|
|
|
|
style={{ top: listHeight.current, height: loadingHeight }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{noMoreDataRender()}
|
2025-10-28 07:59:26 +00:00
|
|
|
|
</div>
|
2025-10-31 02:59:49 +00:00
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 没有数据 */}
|
|
|
|
|
|
{!renderList.current.length && !isFirstLoading && (
|
2025-10-28 07:59:26 +00:00
|
|
|
|
<div className="absolute flex w-full items-center justify-center">
|
|
|
|
|
|
No data
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default VirtualGrid;
|