陌上人如玉
公子世无双

7.7 分页与无限滚动

7.7 分页与无限滚动

一句话:分页有「页码分页」与「游标分页(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]);

八、面试延伸

  1. 「分页有哪几种方式?」
    两种主流:① offset/limit(或 page/pageSize)——服务端用 OFFSET/LIMIT 查询,优点是「能跳到任意页」(适合后台表格),缺点是「数据漂移」(插入/删除时前后页会重复或漏项)和「深分页性能差」(OFFSET 100000 要扫描前面的行);② cursor/keyset(游标)——用「上一页最后一项的标识」作为游标(WHERE id < cursor ORDER BY id DESC LIMIT 20),优点是「无漂移」「深分页性能稳定」(走索引),缺点是「不能跳页」。信息流/无限滚动用 cursor,后台表格用 offset。

  2. 「useInfiniteQuery 怎么用?」
    关键三点:① initialPageParam(v5 必需)——第一页的参数;② queryFn({ pageParam })——用 pageParam 请求对应页;③ getNextPageParam(lastPage, allPages, lastPageParam)——从最后一页算出「下一页的游标」,返回 undefined 表示「没有下一页」。返回的 data 是 { pages: [...], pageParams: [...] },渲染时用 data.pages.flatMap(p => p.items) 展平。此外还有 fetchNextPage、hasNextPage、isFetchingNextPage 等状态。

  3. 「无限滚动为什么用 IntersectionObserver 而不是 scroll 事件?」
    四点:① 性能——scroll 每次滚动触发多次(要节流),IntersectionObserver 由浏览器在「元素进入/离开视口」时通知,天然高效;② 避免布局抖动——scroll 回调里常要读 scrollTop/offsetHeight(触发强制同步布局),IntersectionObserver 不需要;③ 提前触发——用 rootMargin(如 200px)可以「用户还没滚到底就开始加载」,体验更无感;④ 代码更简单——不用自己算「是否到底」,只要观察一个「哨兵元素」。

  4. 「offset 分页『数据漂移』是什么?」
    指「在分页过程中数据发生增删,导致前后页出现重复或遗漏」。例如:用户看第 1 页(第 1~20 条),此时有人在前面插入了 5 条新数据,用户翻到第 2 页时 OFFSET 20 实际返回的是「原来的第 16~35 条」——于是第 16~20 条重复出现(同时可能漏掉一些)。cursor 分页没有这个问题,因为它基于「上一页最后一项的位置」而不是「偏移量」。这也是信息流普遍用 cursor 的原因。

  5. 「无限滚动的列表越来越长,怎么优化?」
    两个层次:① 减少无用请求/渲染——稳定 key、行组件 memo、避免渲染中做 filter/sort;② 控制 DOM 数量——无限滚动会让 DOM 无限增长(几千条就开始卡),所以需要配合 虚拟滚动(只渲染可视区域内的行,见 5.5),或者「窗口化」——当已加载项超过某个阈值时,移除顶部已滚过的项(并调整 scrollTop 保持视觉位置不变)。「无限滚动 + 虚拟滚动」是长列表的终极形态。

  6. 「返回列表时怎么恢复滚动位置?」
    三种方式:① history.scrollRestoration = 'manual' ——关掉浏览器默认行为,自己接管;② 手动记录/恢复——在组件卸载时把 scrollTop 记到 ref 或 sessionStorage(按路径作 key),在重新挂载时用 useLayoutEffect 恢复;③ 数据路由的 <ScrollRestoration /> ——最省事(它会「前进/后退时恢复位置、普通导航时回到顶部」)。关键前提是「数据必须已就绪」(否则内容高度不够、滚不到目标位置),所以无限列表的缓存不能丢。

  7. 「无限滚动有什么可用性问题?」
    三点:① 找不到底部信息——页脚、联系方式被「永远加载不完的列表」推到不可达的位置(应该提供「加载更多」按钮作为替代入口);② Ctrl+F 搜索不到未加载的内容;③ 滚动疲劳——用户不知道自己看到哪了(可以提供「回到顶部」按钮、显示「已加载 N 条」或用「分页 + 无限滚动」混合模式)。此外「加载失败」必须显式提示 + 重试,否则用户会以为「已经到底了」。

  8. 「分页状态该放 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 />);列表无限增长时要配 虚拟滚动。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.7 分页与无限滚动

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫