极致体验的 React 无限滚动组件封装
本文将带你从零打造一个优雅、可复用、国际化友好的 React 无限滚动(Infinite Scroll)组件
1. 背景与需求
在现代 Web 应用中,长列表的高性能渲染和流畅的用户体验是不可或缺的。传统的"分页"已无法满足用户对"无缝浏览"的期待。无限滚动(Infinite Scroll)成为了更自然的选择。
但市面上的无限滚动方案往往存在如下痛点:
- 代码侵入性强,难以复用
- 交互细节粗糙,缺乏美感
- 数据同步与状态管理混乱
本项目基于 React ,封装了一个极致体验的 InfiniteScroll 组件,并在实际业务(如告警历史列表)演示使用。
2. 组件设计理念
✨ 设计目标
- 极简 API:只需传递初始数据、加载函数、渲染函数即可
- 自动国际化:内置多语言提示,支持自定义
- 优雅交互:加载动画、无数据提示、分割线美学
- 数据同步:外部数据变化自动刷新内部状态
- 类型安全:泛型支持,适配任意数据结构
🧩 组件核心代码
// components/infinite-scroll.tsx.tsx
"use client";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
interface InfiniteScrollProps<T> {
initialItems: T[];
loadMore: (page: number) => Promise<T[]>;
renderItem: (item: T, index: number) => React.ReactNode;
pageSize?: number;
hasMore?: boolean;
loadingText?: string;
endMessage?: string;
className?: string;
noData?: () => React.ReactNode;
}
export function InfiniteScroll<T>({
initialItems,
loadMore,
renderItem,
pageSize = 10,
hasMore: initialHasMore = true,
loadingText,
endMessage,
noData,
className = "",
}: InfiniteScrollProps<T>) {
const t = useTranslations("InfiniteScroll");
const [items, setItems] = useState<T[]>(initialItems);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(initialHasMore);
const loaderRef = useRef<HTMLDivElement>(null);
// 外部数据变化时自动同步
useEffect(() => {
setItems(initialItems);
setPage(1);
setHasMore(initialHasMore);
}, [initialItems, initialHasMore]);
// 加载更多数据
const fetchMoreData = async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const newItems = await loadMore(page);
if (newItems.length === 0 || newItems.length < pageSize) setHasMore(false);
setItems((prev) => [...prev, ...newItems]);
setPage((prev) => prev + 1);
} finally {
setLoading(false);
}
};
// Intersection Observer 监听滚动
useEffect(() => {
const currentLoaderRef = loaderRef.current;
if (!currentLoaderRef) return;
const observer = new IntersectionObserver(
(entries) => {
const target = entries[0];
if (target.isIntersecting && hasMore && !loading) fetchMoreData();
},
{ threshold: 0.1 }
);
observer.observe(currentLoaderRef);
return () => {
if (currentLoaderRef) observer.unobserve(currentLoaderRef);
};
}, [hasMore, loading]);
return (
<div className={`w-full ${className}`}>
<div className="w-full">
{items.length > 0 ? (
items.map((item, index) => (
<div key={index} className="w-full">
{renderItem(item, index)}
</div>
))
) : (
<div className="w-full">{noData ? noData() : t("noData")}</div>
)}
</div>
{(loading || hasMore) && (
<div
ref={loaderRef}
className="flex w-full items-center justify-center py-4 text-sm text-muted-foreground"
>
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{loadingText || t("loadingText")}</span>
</div>
) : hasMore ? (
<div className="h-8" />
) : (
<span>{endMessage || t("endMessage")}</span>
)}
</div>
)}
</div>
);
}
3. 业务场景应用示例
以告警历史列表为例,配合 InfiniteScroll 组件实现无限滚动:
// src/app/[locale]/history/page.tsx
<InfiniteScroll
className="tg-theme-card rounded-md overflow-hidden"
initialItems={histories}
loadMore={loadMore}
hasMore={hasMore}
pageSize={PAGE_SIZE}
renderItem={(item, index) => (
<HistoryList items={[item]} filter={filter} />
)}
/>
- initialItems:首次加载的数据
- loadMore:分页加载函数,返回 Promise
- renderItem:自定义每一项的渲染
4. 细节美学与体验优化
- 加载动画:加载中有旋转动画,用户感知流畅
- 无数据提示:优雅的空状态,支持自定义
- 数据同步:外部数据变化自动刷新,无需手动刷新页面
- 移动端友好:交互区域大,动画反馈自然
1360

被折叠的 条评论
为什么被折叠?



