REACT · Vol.V · LESSON 36 · 路由与数据获取
分页与无限滚动
数据一次拿不完:两种加载范式
第 33 篇把取数变成声明式缓存,第 34 篇收编了加载态、错误态、竞态。但还有一个「量」的问题:表里 100 万条订单,接口不可能一口气全吐给浏览器。「数据怎么切、用户怎么翻」,就是本篇的主题,也是卷五最后一块拼图。
| 分页(Pagination) | 无限滚动(Infinite Scroll) | |
|---|---|---|
| 用户动作 | 主动翻页:点「第 3 页」 | 被动供给:滚到底自动续 |
| 典型场景 | 后台管理、报表、搜索结果 | 朋友圈、抖音、评论区 |
| 关键能力 | 跳页、总数、URL 可收藏(?page=3) | 沉浸、无感、移动端友好 |
| 天生弱点 | 每页一次等待,深翻疲劳 | 到不了「第 87 条」,DOM 越滚越多(第 38 篇收拾) |
分页像 JPA 的 PageRequest + Page<T>:页码、总条数一应俱全,适合管理端对账;无限滚动像流式处理:往后拉、拉到没有为止。两者可共用同一份领域模型,只是切片参数不同。
偏移分页与游标分页:LIMIT 100000 的代价
范式落到接口上,就是两种切片方式。先把 SQL 摆出来:
-- 偏移分页:第 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 做第二排序键保证锚点唯一。
keyset pagination 就是后端处理深分页的标准答案:OFFSET 是「顺序数到第 N 条才开始要」,游标是「拿钥匙直接开那扇门」。还有「页缝」:翻页间隙有插入时 LIMIT 分页会重读漏读,游标锚定末尾 id 天然不重不漏——信息流永远用游标就是这个原因。
useQuery + placeholderData:每页一条缓存
管理端页码分页用 useQuery 就够,关键在 queryKey 设计:每页一条缓存。page 进 key,翻页就是换 key,第 33 篇的缓存机制原样生效——翻回第 1 页时缓存命中,秒出:
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 篇),刷新、回退、发链接给同事,现场都不丢。
keepPreviousData 就是 HTTP 缓存的 stale-while-revalidate:先回旧值,同时后台刷新。看到 0.5 秒前的列表,远好于看到 0.5 秒白屏。
invalidateQueries 前缀一匹配,所有页一次失效。useInfiniteQuery 与 IntersectionObserver:滚到底自动续杯
无限滚动是两层结构的组合:数据层负责游标推进、逐页累积,触发层负责滚到附近自动要下一页。先看数据层:
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。
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 里删掉按钮,列表末尾挂哨兵
const sentinelRef = useIntersection(
() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
},
hasNextPage === true // 没有下一页就不再观察
);
// JSX 末尾放哨兵元素:一个几乎不可见的 div,负责「露脸报信」
// <div ref={sentinelRef} style={{ height: 1 }} />
为什么不用 scroll 事件 + getBoundingClientRect() 手算?scroll 高频触发、每次读位置可能强制布局;IntersectionObserver 把「是否与视口相交」交给浏览器底层——滚动时你的代码一行不跑,只在相交状态变化的瞬间回调一次。
scroll 监听像轮询——你不停问「到了吗」;IntersectionObserver 像消息订阅(观察者模式)——浏览器持续盯着,状态变化才推一条消息。把高频轮询换成事件推送,你做消息队列选型时的理由,在这里原样成立。
混用范式,顺便给卷五收官
真实产品很少二选一,而是按页面任务各用各的:信息流用无限滚动(游标接口),管理后台用页码分页(偏移接口)。选型速查:
| 场景 | 范式 | 切片方式 | 前端工具 |
|---|---|---|---|
| 管理后台、报表 | 页码分页 | 偏移(要 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 · 评论