無限滾動
規則
- 基於游標的分頁:使用最後一個項目的 ID 或時間戳作為游標 — 不要使用偏移量,偏移量會在插入或刪除時產生問題
- IntersectionObserver:附加到列表底部的哨兵元素 — 當可見時觸發資料擷取
- 虛擬化:對於超過 1,000 個項目的列表,使用
@tanstack/react-virtual — 僅在 DOM 中渲染可見的行
- 載入骨架:在擷取下一頁時,在列表底部顯示佔位項目 — 不要使用旋轉圖示
- 「載入更多」按鈕:提供作為偏好手動控制或網路連線較慢的使用者的備用方案
- 滾動位置恢復:在導航前儲存滾動位置,返回時恢復 — 使用
sessionStorage 或路由狀態
- 空狀態:當列表為空時顯示訊息 — 不要顯示空白頁面
- 列表結尾:當
hasNextPage 為 false 時顯示「您已到達結尾」 — 停止觀察器
模式
import { useInfiniteQuery } from "@tanstack/react-query";
import { useRef, useEffect } from "react";
function InfiniteList() {
const sentinelRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["items"],
queryFn: ({ pageParam }) => fetchItems({ cursor: pageParam, limit: 20 }),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
initialPageParam: undefined as string | undefined,
});
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting && hasNextPage) fetchNextPage(); },
{ rootMargin: "200px" }
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasNextPage, fetchNextPage]);
const allItems = data?.pages.flatMap((page) => page.items) ?? [];
return (
<div>
{allItems.map((item) => <ItemCard key={item.id} item={item} />)}
{isFetchingNextPage && <LoadingSkeleton count={3} />}
{!hasNextPage && allItems.length > 0 && <p>您已到達結尾</p>}
{allItems.length === 0 && !isFetchingNextPage && <EmptyState />}
<div ref={sentinelRef} />
</div>
);
}
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div key={virtualRow.key} style={{
position: "absolute", top: 0, left: 0, width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}>
<ItemCard item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
應避免
- 基於偏移量的分頁 — 插入或刪除會導致所有偏移量移位,造成重複或遺失項目
- 載入所有項目後再在客戶端進行過濾 — 僅從伺服器擷取當前頁面
- 渲染超過 10k 個 DOM 節點 — 使用
@tanstack/react-virtual 僅渲染可見行
- IntersectionObserver 未設定
rootMargin — 添加 100-200 像素的邊距,以便在使用者到達底部之前預先擷取
- 缺少列表結尾指示器 — 使用者需要知道沒有更多內容可以載入