成为全栈·Next.js 网站前台篇·文章列表:服务端首屏与客户端持续加载怎样协作
首屏内容适合由服务端直接交付,继续加载需要浏览器管理交互状态。两者合在一起时,真正难的是让第 2 页从第 1 页的下一步开始,而不是重新发明一份列表。
前言
文章列表看起来是前端最熟悉的页面之一:请求第 1 页,循环渲染,点击加载更多时请求第 2 页。可当第 1 页由 Server Component 获取,后续页由 React Query 管理时,列表同时有了两个执行环境。
我们遇到的第一个问题不是请求失败,而是登录状态恢复时清空整个 QueryClient。这个操作原本是为了防止上一个账号的私有数据残留,却把正在展示的公开文章流也一起清掉。首屏已经渲染出来,客户端接管后却突然回到加载状态。
这个故障说明:服务端首屏和客户端续页不只是两段代码,它们共同管理一份用户正在阅读的数据。
第 1 页是客户端查询的初始事实
首页 Server Component 先请求最新 10 篇文章,再传给Feed:
const page = await listArticles({ pageSize: 10, sort: '-publishedAt' }) return <Feed initial={page} categories={tree} />Feed将这份结果作为“全部分类”查询的initialData:
useInfiniteQuery({queryKey:['public-feed',category],initialPageParam:1,initialData:category?undefined:{pages:[initial],pageParams:[1]},queryFn:({pageParam})=>request('/articles',{skipAuth:true,skipRefresh:true,query:{page:pageParam,pageSize:10,sort:'-publishedAt',category},}),})这样 hydration 完成后,浏览器不会立即再请求一次第 1 页来替换同样的内容。对服务端和客户端来说,第 1 页是同一份初始事实,客户端从它计算第 2 页。
但切换到某个分类时,不能继续使用“全部文章”的首页数据。queryKey包含 category,分类非空时initialData变为undefined,React Query 会为新键请求对应的第 1 页。否则切换页签时会短暂显示错误分类的文章。
公开列表不应被会话恢复误伤
公开文章不依赖当前账号。它的 query key 不应在登录、退出或恢复会话时被当作私有数据清理。
当初直接调用queryClient.clear()虽然简单,它实际上宣布了“所有客户端服务端状态都属于当前账号”。这和项目的数据所有权不符。更稳妥的方式是让会话切换只失效或移除me、favorites、notifications等私有 key,保留public-feed、公开标签与文章摘要。
这类错误之所以难发现,是因为登录页和文章列表单独测都正常。只有“在首页滚动文章流,同时恢复会话”这条组合路径才会暴露数据所有权判断错误。
页码分页不是一份不变的快照
文章 API 使用页码与 pageSize。当用户看完第 1 页之前,如果后台又发布了新文章,原第 1 页的最后一篇可能被挤到第 2 页。客户端接着请求第 2 页时,就会再次收到这篇文章。
因此展示前要按稳定 id 去重:
constitems=Array.from(newMap(pages.flatMap((page)=>page.list).map((article)=>[article.id,article]),).values(),)这段逻辑可以避免同一篇文章在页面里出现两次,却无法保证零遗漏。若文章在分页间向前移动,某篇还是可能在用户获取续页前跨过了页边界。要得到稳定快照,接口需要游标、快照时间或稳定排序键支持,不能由前端去重假装完成。
所以本项目的承诺很明确:防止重复展示,不承诺动态发布过程中的绝对无遗漏。对博客文章流,这个取舍可以接受;对账单、订单和审计记录,就应该改用更稳定的分页契约。
自动加载要防止并发重入
IntersectionObserver的回调可能在哨兵元素停留在视口时多次触发。isFetchingNextPage阻止前一页未返回时再发一次,hasNextPage阻止越过最后一页,query.isError让失败后的恢复交给明确按钮,count >= 3则限制自动追加批次。
这些条件不是“多写几个 if 更保险”,而是四个独立不变量:同一时刻只追加一页,不请求不存在的下一页,错误后不自动风暴重试,页脚不被无限推走。
下一页的计算也必须只相信服务端返回的分页信息:
getNextPageParam:(lastPage)=>lastPage.page<lastPage.pages?lastPage.page+1:undefined不能用“这一页刚好有 10 条”推断还有下一页。总数恰好是 10 的倍数时,它会多发一次;服务端过滤或删除数据时也可能产生误判。
观察器需要随查询状态正确建立与清理:
useEffect(() => { const element = sentinel.current if (!element || !hasNextPage || isFetchingNextPage || query.isError || autoPages >= 2) return const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) void fetchNextPage() }, { rootMargin: '240px' }) observer.observe(element) return () => observer.disconnect() }, [hasNextPage, isFetchingNextPage, query.isError, autoPages, fetchNextPage])rootMargin让请求在哨兵真正进入视口前启动,减少读者看到加载空档的概率;清理函数则防止分类切换后旧观察器继续触发。
追加失败不应该删掉已读内容
列表首次请求失败和第 3 页追加失败,恢复策略并不相同。前者没有可展示内容,重试时应该refetch();后者已经有两页可阅读内容,页面应该保留它们,只对失败的下一页再次调用fetchNextPage()。
<Failure retry={() => { void (query.isFetchNextPageError ? fetchNextPage() : query.refetch()) }} />这条分支保护的是用户已经获得的阅读上下文。如果追加失败就把整个列表换成错误页,用户不仅没拿到新内容,连原来看到的也失去了。
全部文章页为什么仍然保留传统分页
首页文章流用于发现,持续加载能降低每次点击翻页的中断感。全部文章页则用于定位:用户可能要分享第 4 页、切换排序、返回原来位置,搜索引擎也需要可追踪链接。
因此/articles采用 Server Component 读取 URL 里的 page、sort 和 category,渲染普通上下页链接。首页文章流底部也保留“按页浏览全部文章”入口。两种交互服务于不同任务,没有必要为了界面统一二选一。
<nav aria-label="文章分页"> {page > 1 && <Link href={withPage(page - 1)}>上一页</Link>} {visiblePages.map((number) => ( <Link key={number} href={withPage(number)} aria-current={number === page ? 'page' : undefined}> {number} </Link> ))} {page < pages && <Link href={withPage(page + 1)}>下一页</Link>} </nav>服务端与客户端各自维护什么
| 责任 | 服务端首屏 | 客户端续页 |
|---|---|---|
| 第 1 页文章 | 获取并写入 HTML | 作为initialData接管 |
| SEO 与无脚本可读 | 负责 | 不覆盖 |
| 分类切换 | 提供分类树 | query key 隔离并重新请求 |
| 第 2 页以后 | 不处理 | fetchNextPage() |
| 跨页重复 | 无法预知后续变化 | 按稳定 id 去重 |
| 追加错误 | 不处理 | 保留已有页,重试失败页 |
验收时要覆盖“过程中发生变化”
constscenarios=['首屏水合后不重复请求第 1 页','第 2 页请求期间观察器重复触发','加载第 2 页前后台发布一篇新文章','第 3 页返回 500 后点击重试','自动追加两批后手动加载并抵达 Footer','会话从游客恢复为会员时公开列表仍保留',]静态数据库只能验证正常分页;真正容易出错的是请求期间的发布、登录恢复与失败重试。把这些过程固定为回归清单,才符合混合列表的实际风险。
适用边界
服务端首屏加客户端续页很适合公开内容流、商品列表和社区时间线。如果列表对绝对无重无漏有强要求,需要先升级后端分页契约,而不是在客户端继续加更多 Map 和补偿请求。
如果列表数量很少,或用户需要精确跳页,传统分页仍然更简单。持续加载并不是更现代的默认答案,它只是某种阅读任务的交互选项。
小结
这个列表的核心不是 React Query 或 IntersectionObserver,而是服务端与浏览器对同一份数据达成了协议:第 1 页是初始事实,查询键表达筛选身份,id 用于跨页去重,追加失败不删除已有内容。
各位看官做混合列表时,先别急着写“加载更多”。先回答三个问题:第 1 页由谁交付,客户端用什么 key 接管,数据在分页间移动时你能保证什么。
延伸阅读
- 内容门户首页
- 多级分类、标签与 URL
- TanStack Query 不只是缓存:失效、派生与失败恢复
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer