首页 / React 学习笔记 / 36

REACT · Vol.V · LESSON 36 · 路由与数据获取

分页与无限滚动

实战#数据#交互

数据一次拿不完:两种加载范式

第 33 篇把取数变成声明式缓存,第 34 篇收编了加载态、错误态、竞态。但还有一个「量」的问题:表里 100 万条订单,接口不可能一口气全吐给浏览器。「数据怎么切、用户怎么翻」,就是本篇的主题,也是卷五最后一块拼图。

分页(Pagination)无限滚动(Infinite Scroll)
用户动作主动翻页:点「第 3 页」被动供给:滚到底自动续
典型场景后台管理、报表、搜索结果朋友圈、抖音、评论区
关键能力跳页、总数、URL 可收藏(?page=3)沉浸、无感、移动端友好
天生弱点每页一次等待,深翻疲劳到不了「第 87 条」,DOM 越滚越多(第 38 篇收拾)
后端类比:Page<T> 与流式读取

分页像 JPA 的 PageRequest + Page<T>:页码、总条数一应俱全,适合管理端对账;无限滚动像流式处理:往后拉、拉到没有为止。两者可共用同一份领域模型,只是切片参数不同。

抖音为什么不做页码?「停不下来」的前提是用户永远不用做决定。后台为什么必须能跳页?要「直接跳到第 5000 页对账」。范式不是技术偏好,是用户任务的形状

偏移分页与游标分页:LIMIT 100000 的代价

范式落到接口上,就是两种切片方式。先把 SQL 摆出来:

paging.sql · 偏移分页 vs 游标分页
-- 偏移分页:第 3 页,每页 20 条,跳过前 40 条,没问题
SELECT id, title FROM article ORDER BY id DESC LIMIT 40, 20;

-- 翻到第 5000 页:MySQL 没有捷径,按索引顺序扫过前 99980 行,
-- 全部丢弃,再返回 20 行。页越深,白扔的越多
SELECT id, title FROM article ORDER BY id DESC LIMIT 99980, 20;

-- 游标分页(keyset pagination):拿上一页最后一条的 id 做锚点
SELECT id, title FROM article WHERE id < 99981 ORDER BY id DESC LIMIT 20;

LIMIT 99980, 20 的问题:优化器不能「跳过」——必须按索引顺序真扫够 100000 行,扔掉 99980 行再返回 20 行。而游标分页(DBA 口中的 seek method)用 WHERE id < ? 让 B+ 树直接定位到锚点,扫 20 行收工,性能与页深无关

偏移分页(offset / limit)游标分页(cursor / keyset)
深页性能越翻越慢:扫过再丢弃 N 行恒定:索引直接定位,与深度无关
跳页与总数支持(total、页码直达)不支持,只能顺序翻
接口形态?page=5000&size=20 返回 items + total?cursor=Mzk5ODE=&size=20 返回 items + nextCursor

两个细节:游标通常 base64 封一层(不暴露内部 id);按时间排序要用 (created_at, id) 复合游标——同一秒多条记录只按 created_at 切会丢行重行,id 做第二排序键保证锚点唯一。

偏移分页:LIMIT 99980, 20 返回 20 行 先扫过并丢弃前 99980 行,越翻越慢 游标分页:WHERE id < ?,以上页末尾 id 为锚 返回 20 行 索引直接定位到锚点(seek),前面的行碰都不碰 偏移:页越深越慢|游标:每页都快,但不能跳页
图 1深分页的两条路:偏移分页把前面的行扫一遍再丢弃;游标分页拿锚点让索引直接 seek,代价是放弃跳页。
后端类比:seek method 与「页缝」

keyset pagination 就是后端处理深分页的标准答案:OFFSET 是「顺序数到第 N 条才开始要」,游标是「拿钥匙直接开那扇门」。还有「页缝」:翻页间隙有插入时 LIMIT 分页会重读漏读,游标锚定末尾 id 天然不重不漏——信息流永远用游标就是这个原因。

useQuery + placeholderData:每页一条缓存

管理端页码分页用 useQuery 就够,关键在 queryKey 设计:每页一条缓存。page 进 key,翻页就是换 key,第 33 篇的缓存机制原样生效——翻回第 1 页时缓存命中,秒出:

OrderList.tsx · 页码分页 + keepPreviousData
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { fetchOrders } from "./api";

function OrderList() {
  const [page, setPage] = useState(1);

  const { data, isPending, isPlaceholderData } = useQuery({
    // 每页一条缓存:page 进 key,翻回旧页直接命中
    queryKey: ["orders", "list", page],
    queryFn: () => fetchOrders(page, 20),
    // v5 写法:换页瞬间先用上一页数据占位,新页在后台取
    placeholderData: keepPreviousData,
  });

  if (isPending) return <p>首次加载中…</p>;

  return (
    <div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>
      <ul>
        {data.items.map((o) => (
          <li key={o.id}>{o.orderNo} · {o.amount} 元</li>
        ))}
      </ul>
      <button disabled={page <= 1} onClick={() => setPage(page - 1)}>
        上一页
      </button>
      <span>第 {page} / {data.totalPages} 页</span>
      <button
        disabled={isPlaceholderData || page >= data.totalPages}
        onClick={() => setPage(page + 1)}
      >
        下一页
      </button>
    </div>
  );
}

不配 placeholderData,每次翻到没看过的页都是白屏抖动。配上之后,换页瞬间先用上一页数据占位,新页在后台无缝替换;isPlaceholderData 为 true 时把透明度降一点,告诉用户「这是旧页,新的在路上」。另外把 page 放进 URL(第 30 篇),刷新、回退、发链接给同事,现场都不丢。

后端类比:stale-while-revalidate

keepPreviousData 就是 HTTP 缓存的 stale-while-revalidate:先回旧值,同时后台刷新。看到 0.5 秒前的列表,远好于看到 0.5 秒白屏。

为什么不把所有页塞进一个 key 自己 concat?合并、去重、回滚全得回到第 32 篇的手写时代。每页一条缓存,删除文章后 invalidateQueries 前缀一匹配,所有页一次失效。

useInfiniteQuery 与 IntersectionObserver:滚到底自动续杯

无限滚动是两层结构的组合:数据层负责游标推进、逐页累积,触发层负责滚到附近自动要下一页。先看数据层:

Feed.tsx · useInfiniteQuery:一条 key 下挂一串页
import { useInfiniteQuery } from "@tanstack/react-query";

interface FeedPage {
  items: { id: number; title: string }[];
  nextCursor: number | null;
}

async function fetchFeed(cursor: number | null): Promise<FeedPage> {
  const qs = cursor === null ? "" : `&cursor=${cursor}`;
  const res = await fetch(`/api/feed?size=20${qs}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

function Feed() {
  const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ["feed"],
      queryFn: ({ pageParam }) => fetchFeed(pageParam),
      initialPageParam: null as number | null, // v5 必填:第一页没有游标
      // 下一页的游标从上一页响应里取;返回 undefined 表示到底了
      getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    });

  if (isPending) return <p>加载中…</p>;

  // pages 是已加载的每一页,flatMap 摊平成一条连续的流
  const items = data.pages.flatMap((p) => p.items);

  return (
    <div>
      {items.map((it) => (
        <article key={it.id}>{it.title}</article>
      ))}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? "加载中…" : hasNextPage ? "加载更多" : "没有更多了"}
      </button>
    </div>
  );
}

和页码分页「每页一条 key」不同,无限滚动是一条 key 下挂一个 pages 数组:已加载的 137 条是一个缓存条目,getNextPageParam 负责告诉库「下一页的 pageParam 怎么从上一页响应里算」。「加载更多」按钮已是九成,最后一步换成「滚到底自动触发」,专用 API:IntersectionObserver。

useIntersection.ts · 哨兵进入视口就开火
import { useEffect, useRef } from "react";

// 哨兵元素进入视口时回调 onIntersect
function useIntersection(onIntersect: () => void, enabled: boolean) {
  const sentinelRef = useRef<HTMLDivElement | null>(null);

  // 回调放进 ref:观察器只注册一次,触发时拿到最新回调(防闭包过期,第 10 篇)
  const cbRef = useRef(onIntersect);
  cbRef.current = onIntersect;

  useEffect(() => {
    const el = sentinelRef.current;
    if (!el || !enabled) return;

    const io = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) cbRef.current();
      },
      { rootMargin: "200px 0px" } // 还差 200px 就提前加载,用户无感
    );
    io.observe(el);
    return () => io.disconnect(); // 卸载断开观察(第 9 篇的清理纪律)
  }, [enabled]);

  return sentinelRef;
}

export default useIntersection;
Feed.tsx · 把按钮换成哨兵
// Feed 里删掉按钮,列表末尾挂哨兵
const sentinelRef = useIntersection(
  () => {
    if (hasNextPage && !isFetchingNextPage) fetchNextPage();
  },
  hasNextPage === true // 没有下一页就不再观察
);

// JSX 末尾放哨兵元素:一个几乎不可见的 div,负责「露脸报信」
// <div ref={sentinelRef} style={{ height: 1 }} />

为什么不用 scroll 事件 + getBoundingClientRect() 手算?scroll 高频触发、每次读位置可能强制布局;IntersectionObserver 把「是否与视口相交」交给浏览器底层——滚动时你的代码一行不跑,只在相交状态变化的瞬间回调一次。

后端类比:轮询换订阅

scroll 监听像轮询——你不停问「到了吗」;IntersectionObserver 像消息订阅(观察者模式)——浏览器持续盯着,状态变化才推一条消息。把高频轮询换成事件推送,你做消息队列选型时的理由,在这里原样成立。

fetchNextPage 连点会发两个请求吗?isFetchingNextPage 的守卫已挡住,Query 内部对相同 key 也会去重——但显式守卫语义更清楚。

混用范式,顺便给卷五收官

真实产品很少二选一,而是按页面任务各用各的:信息流用无限滚动(游标接口),管理后台用页码分页(偏移接口)。选型速查:

场景范式切片方式前端工具
管理后台、报表页码分页偏移(要 total、可跳页)useQuery + keepPreviousData
信息流、评论区无限滚动游标(深页性能稳定)useInfiniteQuery + IntersectionObserver
feed 滚到几百页无限滚动 + 虚拟化游标再叠一层虚拟列表(第 38 篇)

顺手两个组合技:信息流分享某条,靠详情页直达(第 31 篇动态路由);管理端 URL 带 ?page=3,配合第 35 篇路由守卫,回来现场还在。

核心要点
  • 范式由用户任务决定:分页是「我主动要」(可跳页、要总数),无限滚动是「系统主动给」(沉浸无感)。
  • 偏移分页对照 LIMIT offset, size,深页越翻越慢;游标分页对照 keyset(WHERE id < ?),索引直接定位、性能恒定,代价是不能跳页。
  • 页码分页:每页一个 queryKey + placeholderData: keepPreviousData——翻回旧页秒出,翻新页先占位后刷新。
  • 无限滚动:useInfiniteQuery(v5 必填 initialPageParam;getNextPageParam 返回 undefined 即到底;pages.flatMap 摊平),一条 key 下挂一串 pages。
  • 触发层用 IntersectionObserver + 哨兵元素,rootMargin 做预加载;回调塞进 useRef 防闭包过期,卸载 disconnect。

本章回顾

本篇把「数据量」切干净:管理端偏移分页 + 每页一条缓存,信息流游标分页 + 哨兵自动续杯,共用第 33 篇的缓存底座。至此卷五(第 30~36 篇)收官:路由与参数(30、31)装上地址系统;取数三部曲(32、33、34)从手写 fetch 走到声明式缓存;认证与守卫(35)织进登录态;分页(36)让数据量不再失控。卷六开始问下一个问题:快不快。下一篇第 37 篇,从最常见的性能误区下手——重渲染剖析:父组件一动,子组件都要跟着刷吗?memo 该加在哪?

Comments · 评论