
一句话:分页有「页码分页」与「游标分页(cursor)」两类;列表页用
useInfiniteQuery+IntersectionObserver做无限滚动最省事,而返回列表时恢复滚动位置是体验的关键细节。
一、三种分页方式
① offset / limit(或 page / pageSize)
GET /items?page=2&size=20 → 服务端用 OFFSET 20 LIMIT 20
优点:实现简单,能「跳到任意页」
缺点:① 数据插入/删除时「漂移」(第 2 页可能重复或漏数据)
② 深分页性能差(OFFSET 100000 要扫描 100000 行)
② cursor / keyset(游标)
GET /items?cursor=eyJpZCI6MTAwfQ&limit=20 → WHERE id < 100 ORDER BY id DESC LIMIT 20
优点:① 没有漂移(基于「上一页最后一项」定位)
② 深分页性能稳定(走索引,不扫描前面所有行)
缺点:① 不能「跳到第 N 页」(只能顺序前后)
② 实现稍复杂(要维护 cursor 的编解码)
③ 无限滚动(前端概念,底层通常是 cursor 或 offset)
· 用户滚动到底部 → 加载下一页 → 追加到列表
· 没有「页码」,只有「已加载多少」
| 方式 | 能否跳页 | 数据漂移 | 深分页性能 | 适合 |
|---|---|---|---|---|
| offset/limit | ✅ | ⚠️ 有 | ❌ 差 | 后台表格(需要跳页) |
| cursor | ❌ | ✅ 无 | ✅ 好 | 信息流、无限滚动 |
| 全量(前端分页) | ✅ | ✅ | ⚠️ 数据量大时不行 | 数据量小(< 1000) |
二、页码分页的实现
function PaginatedList() {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page') ?? '1'); // ⭐ 页码放 URL(可分享)
const { data, isLoading, isPlaceholderData, isError, refetch } = useQuery({
queryKey: ['items', { page }],
queryFn: () => api.getItems({ page, size: 20 }),
placeholderData: (prev) => prev, // ⭐ 翻页时保留上一页数据(避免闪白)
});
if (isLoading) return <TableSkeleton rows={20} />;
if (isError) return <ErrorView onRetry={refetch} />;
return (
<div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>
<Table rows={data.items} />
<Pagination
current={page}
total={Math.ceil(data.total / 20)}
onChange={(p) => setSearchParams((prev) => { prev.set('page', String(p)); return prev; })}
/>
</div>
);
}
✅ 页码分页的四个要点:
① 页码放 URL(可分享、可刷新、可后退)
② placeholderData 保留上一页(避免列表闪白)
③ 翻页时「禁用依赖旧数据的操作」或半透明提示
④ 筛选变化时要「重置到第 1 页」(否则出现「第 5 页但只有 2 页数据」)
三、useInfiniteQuery
import { useInfiniteQuery } from '@tanstack/react-query';
function InfiniteList() {
const {
data, // { pages: [...], pageParams: [...] }
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
refetch,
} = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam }) => api.getItems({ cursor: pageParam, size: 20 }),
initialPageParam: undefined as string | undefined, // ⭐ 第一页的游标
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
// ↑ 返回 undefined 表示「没有下一页」
});
if (isLoading) return <ListSkeleton count={5} />;
const items = data?.pages.flatMap((p) => p.items) ?? []; // ⭐ 展平所有页
return (
<>
<ul>{items.map((it) => <Row key={it.id} item={it} />)}</ul>
{isFetchingNextPage && <ListSkeleton count={2} />}
{hasNextPage ? (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
) : (
<p className="end">没有更多了</p> {/* ⭐ 结束态 */}
)}
</>
);
}
| 参数 | 作用 |
|---|---|
initialPageParam |
第一页的 pageParam(v5 必需) |
getNextPageParam(lastPage, allPages, lastPageParam) |
从最后一页算出「下一页的游标」;返回 undefined 表示到底 |
queryFn({ pageParam }) |
用 pageParam 请求对应页 |
fetchNextPage() |
加载下一页(追加到 pages) |
hasNextPage |
是否还有下一页 |
isFetchingNextPage |
是否正在加载下一页(用于底部 loading) |
// ✅ offset 版无限滚动(服务端只有 page 参数时)
useInfiniteQuery({
queryKey: ['items', filters],
queryFn: ({ pageParam = 1 }) => api.getItems({ page: pageParam, size: 20, ...filters }),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((n, p) => n + p.items.length, 0);
return loaded < lastPage.total ? allPages.length + 1 : undefined; // ⭐ 判断是否到底
},
});
⚠️ 为什么 offset 版要「用累计条数与 total 比较」?
不能用 `lastPage.items.length < size` 判断——
因为最后一页可能刚好「整页返回」(length === size)但确实是最后一页。
四、用 IntersectionObserver 自动加载
// hooks/useInfiniteScroll.ts
export function useInfiniteScroll(
onLoadMore: () => void,
{ hasMore, loading, rootMargin = '200px' }: { hasMore: boolean; loading: boolean; rootMargin?: string }
) {
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el || !hasMore || loading) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) onLoadMore(); // ⭐ 哨兵进入视口 → 加载下一页
},
{ rootMargin } // 提前 200px 触发(减少等待感)
);
io.observe(el);
return () => io.disconnect(); // ⭐ 必须清理
}, [onLoadMore, hasMore, loading, rootMargin]);
return sentinelRef; // ⭐ 挂到列表底部的哨兵元素上
}
// 使用
function InfiniteList() {
const {
data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading,
} = useInfiniteQuery({ /* ... */ });
const sentinelRef = useInfiniteScroll(fetchNextPage, {
hasMore: !!hasNextPage,
loading: isFetchingNextPage,
});
if (isLoading) return <ListSkeleton count={5} />;
const items = data?.pages.flatMap((p) => p.items) ?? [];
return (
<ul>
{items.map((it) => <Row key={it.id} item={it} />)}
{/* ⭐ 哨兵元素(滚到这里就加载下一页) */}
<li ref={sentinelRef} aria-hidden />
{isFetchingNextPage && <ListSkeleton count={2} />}
{!hasNextPage && items.length > 0 && <li className="end">没有更多了</li>}
</ul>
);
}
⭐ 为什么用 IntersectionObserver 而不是 scroll 事件?
① scroll 事件触发极其频繁(每帧多次)→ 需要节流,容易写错
② IntersectionObserver 由浏览器在「元素进入视口」时通知 → 天然高效
③ 用 rootMargin 可以「提前触发」(用户还没滚到底就开始加载)→ 无感
④ 不需要读 scrollTop(避免强制同步布局)
五、到底了 / 重新加载 / 空状态
// ✅ 无限滚动的四种状态要写全
function Feed() {
const { data, isLoading, isError, error, refetch, isFetchingNextPage, hasNextPage } = useInfiniteQuery({ /* ... */ });
if (isLoading) return <FeedSkeleton />; // ① 首次加载
if (isError) return <ErrorView error={error} onRetry={refetch} />; // ② 错误
const items = data?.pages.flatMap((p) => p.items) ?? [];
if (items.length === 0) return <EmptyState />; // ③ 空(重要!)
return (
<ul>
{items.map((it) => <Row key={it.id} item={it} />)}
{/* ④ 底部状态:加载中 / 没有更多 / 加载失败 */}
{isFetchingNextPage && <li>加载中…</li>}
{!hasNextPage && <li className="end">已经到底了</li>}
{hasNextPage && !isFetchingNextPage && (
<li><button onClick={() => fetchNextPage()}>加载更多</button></li>
)}
</ul>
);
}
// ✅ 「加载下一页失败」的处理(不能让它静默失败)
const { isFetchNextPageError, fetchNextPage } = useInfiniteQuery({ /* ... */ });
{isFetchNextPageError && (
<li className="error">
加载失败 <button onClick={() => fetchNextPage()}>重试</button>
</li>
)}
六、返回列表时恢复滚动位置
// ❌ 常见问题:用户从列表进详情、再返回,列表滚动位置没了(回到顶部)
// ✅ 方案一:浏览器原生行为(history.scrollRestoration)
useEffect(() => {
const prev = history.scrollRestoration;
history.scrollRestoration = 'manual'; // ⭐ 自己接管滚动恢复
return () => { history.scrollRestoration = prev; };
}, []);
// ✅ 方案二:自己记录并恢复(更可控,适合 SPA)
// hooks/useScrollRestoration.ts
export function useScrollRestoration(key: string) {
const location = useLocation();
const positions = useRef<Record<string, number>>({});
// 离开时记录
useEffect(() => {
const el = document.scrollingElement!;
return () => {
positions.current[key] = el.scrollTop;
};
}, [key]);
// 回来时恢复
useLayoutEffect(() => {
const el = document.scrollingElement!;
el.scrollTop = positions.current[key] ?? 0;
}, [key, location.pathname]);
}
// ✅ 方案三:数据路由提供 <ScrollRestoration />
// (数据路由场景下最省事)
import { ScrollRestoration } from 'react-router-dom';
function RootLayout() {
return (
<>
<ScrollRestoration /> {/* ⭐ 自动「前进/后退恢复位置、普通导航回顶部」 */}
<Outlet />
</>
);
}
⚠️ 恢复滚动位置的三个注意点:
① 数据必须「已经就绪」再恢复(否则内容高度不够,滚不到目标位置)
→ 在数据加载完成后恢复(或用 useLayoutEffect + 等待数据)
② 无限列表要「保持已加载的页数」(缓存不能丢,否则列表变短)
③ 用「返回键」与「点击链接」的行为可能不同(前者恢复、后者回顶部)
七、常见坑
// ① 用 offset 分页做无限滚动(数据会漂移:重复/漏项)
// ② getNextPageParam 判断条件写错(永远 hasNextPage 或提前结束)
getNextPageParam: (last) => last.items.length === 20 ? last.page + 1 : undefined;
// ⚠️ 最后一页刚好 20 条时会多请求一次(应该用 total 比较)
// ③ 忘记 initialPageParam(v5 会报错)
// ④ flatMap 忘了去重(offset 分页的漂移会导致重复 key → React 警告)
const items = data.pages.flatMap((p) => p.items); // ⚠️ 可能有重复
const unique = useMemo(() => Array.from(new Map(items.map((i) => [i.id, i])).values()), [items]); // ✅
// ⑤ IntersectionObserver 没 disconnect(内存泄漏 + 重复触发)
// ⑥ 哨兵元素「被列表项挡住」或「在列表内高度为 0」→ 触发时机异常
// ⑦ 没做「防重入」(loading 时又触发一次 fetchNextPage)
// ⑧ 忘记「到底了」的提示(用户一直滚以为还有)
// ⑨ 忘记「加载下一页失败」的处理(静默失败,用户以为到底了)
// ⑩ 空状态没处理(列表空白)
// ⑪ 切换筛选条件时「没有重置分页状态」(新旧条件的数据混在一起)
useInfiniteQuery({ queryKey: ['items', filters], ... });
// ⭐ 只要 filters 进了 queryKey,切换时就是「新的查询」(不会混)
// ⑫ 返回列表时滚动位置丢失(没做恢复)
// ⑬ 用 scroll 事件 + 节流做无限滚动(容易卡顿、读布局属性导致抖动)
// ⑭ 每页数据量太小(请求过多)或太大(首屏慢)
// ⑮ 无限滚动导致「列表无限增长」(几万条 DOM)→ 需要配合虚拟滚动(见 5.5)
// ⑯ 用 index 作 key(追加时虽然没错,但去重后可能错位)
// ⑦ 的修复(在 Hook 内部防重入)
useEffect(() => {
const el = sentinelRef.current;
if (!el || !hasMore || loading) return;
// ⭐ loading 为 true 时直接不注册 observer
const io = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting && !loading) onLoadMore();
}, { rootMargin });
io.observe(el);
return () => io.disconnect();
}, [onLoadMore, hasMore, loading, rootMargin]);
八、面试延伸
- 「分页有哪几种方式?」
两种主流:① offset/limit(或 page/pageSize)——服务端用OFFSET/LIMIT查询,优点是「能跳到任意页」(适合后台表格),缺点是「数据漂移」(插入/删除时前后页会重复或漏项)和「深分页性能差」(OFFSET 100000要扫描前面的行);② cursor/keyset(游标)——用「上一页最后一项的标识」作为游标(WHERE id < cursor ORDER BY id DESC LIMIT 20),优点是「无漂移」「深分页性能稳定」(走索引),缺点是「不能跳页」。信息流/无限滚动用 cursor,后台表格用 offset。 -
「
useInfiniteQuery怎么用?」
关键三点:①initialPageParam(v5 必需)——第一页的参数;②queryFn({ pageParam })——用pageParam请求对应页;③getNextPageParam(lastPage, allPages, lastPageParam)——从最后一页算出「下一页的游标」,返回undefined表示「没有下一页」。返回的data是{ pages: [...], pageParams: [...] },渲染时用data.pages.flatMap(p => p.items)展平。此外还有fetchNextPage、hasNextPage、isFetchingNextPage等状态。 -
「无限滚动为什么用
IntersectionObserver而不是 scroll 事件?」
四点:① 性能——scroll每次滚动触发多次(要节流),IntersectionObserver由浏览器在「元素进入/离开视口」时通知,天然高效;② 避免布局抖动——scroll 回调里常要读scrollTop/offsetHeight(触发强制同步布局),IntersectionObserver 不需要;③ 提前触发——用rootMargin(如200px)可以「用户还没滚到底就开始加载」,体验更无感;④ 代码更简单——不用自己算「是否到底」,只要观察一个「哨兵元素」。 -
「offset 分页『数据漂移』是什么?」
指「在分页过程中数据发生增删,导致前后页出现重复或遗漏」。例如:用户看第 1 页(第 1~20 条),此时有人在前面插入了 5 条新数据,用户翻到第 2 页时OFFSET 20实际返回的是「原来的第 16~35 条」——于是第 16~20 条重复出现(同时可能漏掉一些)。cursor 分页没有这个问题,因为它基于「上一页最后一项的位置」而不是「偏移量」。这也是信息流普遍用 cursor 的原因。 -
「无限滚动的列表越来越长,怎么优化?」
两个层次:① 减少无用请求/渲染——稳定key、行组件memo、避免渲染中做filter/sort;② 控制 DOM 数量——无限滚动会让 DOM 无限增长(几千条就开始卡),所以需要配合 虚拟滚动(只渲染可视区域内的行,见 5.5),或者「窗口化」——当已加载项超过某个阈值时,移除顶部已滚过的项(并调整scrollTop保持视觉位置不变)。「无限滚动 + 虚拟滚动」是长列表的终极形态。 -
「返回列表时怎么恢复滚动位置?」
三种方式:①history.scrollRestoration = 'manual'——关掉浏览器默认行为,自己接管;② 手动记录/恢复——在组件卸载时把scrollTop记到ref或sessionStorage(按路径作 key),在重新挂载时用useLayoutEffect恢复;③ 数据路由的<ScrollRestoration />——最省事(它会「前进/后退时恢复位置、普通导航时回到顶部」)。关键前提是「数据必须已就绪」(否则内容高度不够、滚不到目标位置),所以无限列表的缓存不能丢。 -
「无限滚动有什么可用性问题?」
三点:① 找不到底部信息——页脚、联系方式被「永远加载不完的列表」推到不可达的位置(应该提供「加载更多」按钮作为替代入口);②Ctrl+F搜索不到未加载的内容;③ 滚动疲劳——用户不知道自己看到哪了(可以提供「回到顶部」按钮、显示「已加载 N 条」或用「分页 + 无限滚动」混合模式)。此外「加载失败」必须显式提示 + 重试,否则用户会以为「已经到底了」。 -
「分页状态该放 URL 还是组件 state?」
判断标准是「这个状态是否需要被分享/刷新保留」:① 页码分页——页码放 URL(?page=2)更好(可分享、可刷新、可后退);② 无限滚动——通常不需要放 URL(无法用 URL 表达「滚动了多远」),但筛选条件应该放 URL(这样「复制链接给别人」能看到同样的筛选结果);③ 组件 state 适合「纯临时的 UI 状态」(如「是否展开了某一项」)。URL 的额外好处是「刷新后状态不丢」。
一句话速记
分页两条路:offset/page(能跳页但会「数据漂移」+ 深分页慢) 与 cursor/keyset(无漂移、深分页快,但不能跳页)——信息流用 cursor,后台表格用 offset;无限滚动用
useInfiniteQuery(initialPageParam+getNextPageParam返回undefined表示到底,data.pages.flatMap展平)+IntersectionObserver哨兵(比 scroll 事件好:高效、可rootMargin提前触发、不用读布局属性,必须disconnect);四态要写全(首次加载 / 错误 / 空 / 到底),并且「加载下一页失败」必须显式提示 + 重试;筛选条件进queryKey就自动隔离数据;返回列表要恢复滚动位置(history.scrollRestoration='manual'自管 /<ScrollRestoration />);列表无限增长时要配 虚拟滚动。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!