'use client'; import type React from 'react'; import { useEffect, useRef } from 'react'; import { useDebounceFn, useUpdate } from 'ahooks'; type VirtualGridProps = { 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; const useResizeObserver = ( ref: React.RefObject, 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( props: VirtualGridProps ): 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(null); const headerRef = useRef(null); const listHeight = useRef(0); const previousDataSourceLength = useRef(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 (
{header}
{renderList.current.map(({ item, style }) => (
{itemRender?.(item)}
))} {/* 加载更多 */} {!!renderList.current.length && !noMoreData && (
{isLoadingMore ? 'Loading more...' : 'load more'}
)} {/* 没有更多数据 */} {noMoreData && noMoreDataRender ? (
{noMoreDataRender()}
) : null} {/* 没有数据 */} {!renderList.current.length && !isFirstLoading && (
No data
)}
); } export default VirtualGrid;