import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; import { cn } from '@/lib/utils'; import { useSize } from 'ahooks'; /** * 虚拟渲染项组件 * 只渲染在视口内或附近的内容,离开视口时卸载 */ interface VirtualItemProps { children: ReactNode; rootMargin?: string; placeholder?: ReactNode; } function VirtualItem({ children, rootMargin = '300px', placeholder }: VirtualItemProps) { const ref = useRef(null); const [isVisible, setIsVisible] = useState(false); useEffect(() => { const element = ref.current; if (!element) return; const observer = new IntersectionObserver( ([entry]) => { setIsVisible(entry.isIntersecting); }, { rootMargin, threshold: 0, } ); observer.observe(element); return () => { observer.disconnect(); }; }, [rootMargin]); return
{isVisible ? children : placeholder}
; } interface InfiniteScrollListProps { /** * 数据项数组 */ items: T[]; /** * 渲染每个数据项的函数 */ renderItem: (item: T, index: number) => ReactNode; /** * 获取每个数据项的唯一key */ getItemKey: (item: T, index: number) => string | number; /** * 是否有更多数据可以加载 */ hasNextPage: boolean; /** * 是否正在加载 */ isLoading: boolean; /** * 加载下一页的函数 */ fetchNextPage: () => void; /** * 列表容器的className */ className?: string; /** * 网格列数(支持响应式) */ columns?: | { xs?: number; sm?: number; md?: number; lg?: number; xl?: number; } | number | ((width: number) => number); /** * 网格间距 */ gap?: number; /** * 加载状态的骨架屏组件 */ LoadingSkeleton?: React.ComponentType; /** * 加载更多时显示的组件 */ LoadingMore?: React.ComponentType; /** * 空状态组件 */ EmptyComponent?: React.ComponentType; /** * 错误状态组件 */ ErrorComponent?: React.ComponentType<{ onRetry: () => void }>; /** * 是否有错误 */ hasError?: boolean; /** * 重试函数 */ onRetry?: () => void; /** * 触发加载的阈值(px) */ threshold?: number; /** * 是否启用无限滚动 */ enabled?: boolean; /** * 是否启用虚拟渲染(离开视口时卸载内容,默认开启) */ enableVirtualRender?: boolean; /** * 虚拟渲染的根边距(提前多少px开始渲染,默认300px) */ virtualRenderMargin?: string; /** * 虚拟渲染时的占位符组件 */ VirtualPlaceholder?: React.ComponentType; } const defaultVirtualPlaceholder = () => (
); /** * 通用的无限滚动列表组件 * 支持网格布局、骨架屏、错误状态等功能 */ export function InfiniteScrollList({ items, renderItem, getItemKey, hasNextPage, isLoading, fetchNextPage, className, columns = 4, gap = 4, LoadingSkeleton, LoadingMore, EmptyComponent, ErrorComponent, hasError = false, onRetry, threshold = 200, enabled = true, enableVirtualRender = true, virtualRenderMargin = '300px', VirtualPlaceholder = defaultVirtualPlaceholder, }: InfiniteScrollListProps) { const ref = useRef(null); const size = useSize(ref); const { loadMoreRef, isFetching } = useInfiniteScroll({ hasNextPage, isLoading, fetchNextPage, threshold, enabled, isError: hasError, }); // 生成网格列数的CSS类名映射 const gridColsClass = useMemo(() => { const gridClassMap: Record = { 1: 'grid-cols-1', 2: 'grid-cols-2', 3: 'grid-cols-3', 4: 'grid-cols-4', 5: 'grid-cols-5', 6: 'grid-cols-6', }; if (typeof columns === 'number') { return gridClassMap[columns] || 'grid-cols-4'; } if (typeof columns === 'function') { const col = columns(size?.width || 0); return gridClassMap[col] || 'grid-cols-4'; } // 使用完整的类名字符串,让 Tailwind 能够正确识别 const classes: string[] = []; // xs 断点 if (columns.xs === 1) classes.push('xs:grid-cols-1'); if (columns.xs === 2) classes.push('xs:grid-cols-2'); if (columns.xs === 3) classes.push('xs:grid-cols-3'); if (columns.xs === 4) classes.push('xs:grid-cols-4'); if (columns.xs === 5) classes.push('xs:grid-cols-5'); if (columns.xs === 6) classes.push('xs:grid-cols-6'); // sm 断点 if (columns.sm === 1) classes.push('sm:grid-cols-1'); if (columns.sm === 2) classes.push('sm:grid-cols-2'); if (columns.sm === 3) classes.push('sm:grid-cols-3'); if (columns.sm === 4) classes.push('sm:grid-cols-4'); if (columns.sm === 5) classes.push('sm:grid-cols-5'); if (columns.sm === 6) classes.push('sm:grid-cols-6'); // md 断点 if (columns.md === 1) classes.push('md:grid-cols-1'); if (columns.md === 2) classes.push('md:grid-cols-2'); if (columns.md === 3) classes.push('md:grid-cols-3'); if (columns.md === 4) classes.push('md:grid-cols-4'); if (columns.md === 5) classes.push('md:grid-cols-5'); if (columns.md === 6) classes.push('md:grid-cols-6'); // lg 断点 if (columns.lg === 1) classes.push('lg:grid-cols-1'); if (columns.lg === 2) classes.push('lg:grid-cols-2'); if (columns.lg === 3) classes.push('lg:grid-cols-3'); if (columns.lg === 4) classes.push('lg:grid-cols-4'); if (columns.lg === 5) classes.push('lg:grid-cols-5'); if (columns.lg === 6) classes.push('lg:grid-cols-6'); // xl 断点 if (columns.xl === 1) classes.push('xl:grid-cols-1'); if (columns.xl === 2) classes.push('xl:grid-cols-2'); if (columns.xl === 3) classes.push('xl:grid-cols-3'); if (columns.xl === 4) classes.push('xl:grid-cols-4'); if (columns.xl === 5) classes.push('xl:grid-cols-5'); if (columns.xl === 6) classes.push('xl:grid-cols-6'); return classes.join(' '); }, [columns, size?.width]); // 生成间距类名 const gapClass = useMemo(() => { const gapClassMap: Record = { 1: 'gap-1', 2: 'gap-2', 3: 'gap-3', 4: 'gap-4', 5: 'gap-5', 6: 'gap-6', 8: 'gap-8', }; return gapClassMap[gap] || 'gap-4'; }, [gap]); let finalDom = ( <> {/* 主要内容 */}
{items.map((item, index) => { const key = getItemKey(item, index); const content = renderItem(item, index); if (enableVirtualRender) { return ( } > {content} ); } return {content}; })}
{/* 加载更多触发器 - 只在没有错误时显示 */} {hasNextPage && !hasError && (
{LoadingMore ? ( ) : (
{(isFetching || isLoading) && (
)} {isFetching || isLoading ? 'Loading...' : ''}
)}
)} ); // 错误状态 if (hasError && ErrorComponent && onRetry) { finalDom = ; } // 首次加载状态 if (isLoading && items.length === 0) { if (LoadingSkeleton) { finalDom = (
{Array.from({ length: 8 }).map((_, index) => ( ))}
); } finalDom = (
{Array.from({ length: 8 }).map((_, index) => (
))}
); } // 空状态 if (items.length === 0 && EmptyComponent) { finalDom = ; } return (
{finalDom}
); }