用 Claude Code 实现可上线的无限滚动
用 Claude Code、React、Intersection Observer 和 cursor API 实现无限滚动,包含无障碍、SEO 与失败处理。
无限滚动是指用户接近列表底部时,页面自动加载下一批内容。它常见于社交动态、文章归档、商品列表、通知中心和后台审计日志。看起来只是“最后一项出现时再 fetch 一次”,但真正上线时会遇到重复请求、旧响应覆盖新状态、返回按钮丢失位置、SEO 不可见、读屏器无法理解状态、API 分页不稳定等问题。
因此,给 Claude Code 的任务不能只写“帮我加无限滚动”。更好的做法是把 UI、API、无障碍、失败恢复和验证标准一起交代清楚。本文会用 React hook、列表组件、Next.js cursor API 和具体失败案例,说明如何把无限滚动做成可维护的功能。大量 DOM 的渲染优化可以继续看虚拟滚动实现,需要显式页码时则参考分页实现。
先确定设计边界
Intersection Observer 是浏览器提供的 API,用来观察某个元素是否进入 viewport 或指定容器。简单说,它能告诉你“底部的观察点快出现在屏幕里了”。相比每次滚动都触发计算,它更适合作为无限滚动的触发器。官方行为和术语可以参考 MDN Intersection Observer API。
列表底部那个被观察的小元素通常叫 sentinel,可以理解为“哨兵元素”。哨兵进入屏幕附近时,我们加载下一页。rootMargin 可以让请求提前发生,避免用户已经看到底部才开始等待。
分页方式也要提前决定。offset 分页是“跳过 40 条,再取 20 条”。数据不断新增时,它容易出现重复或漏项。cursor 分页是“从这个 id 之后继续取”,更适合文章流、通知、日志和搜索结果。
可以这样要求 Claude Code:
请用 React 和 Next.js 实现文章列表无限滚动。
使用 Intersection Observer,API 使用 cursor 分页。
包含重复请求保护、AbortController 清理、错误提示、手动“加载更多”按钮、
aria-live、role="feed",并保留 SEO 可访问的普通链接。
不要删除现有 frontmatter、heroImage、内部链接或多语言路由。
Claude Code 官方常见工作流强调要给出清晰任务、示例和约束。无限滚动正是这种场景:如果限制条件写得越明确,Claude Code 生成的差异就越接近可上线版本。
适合的使用场景
第一个场景是文章归档。教程型网站会不断增加内容,无限滚动能让首屏保持轻量,又让愿意继续阅读的人不断发现下一篇。但必须处理“打开文章后返回列表”的位置恢复,否则读者很容易放弃。
第二个场景是电商或 SaaS 的搜索结果。用户浏览模板、集成、商品时,连续滚动比不断翻页更轻松。前提是筛选条件、排序和搜索词仍然保存在 URL 中,方便分享和再次访问。
第三个场景是后台通知和审计日志。运营人员通常按最新顺序快速扫描。这里要把 cursor、时间戳和已读状态分开设计,不能把“最后看过的位置”同时当作数据库游标和业务状态。
第四个场景是聊天、评论和活动流。它们常常需要反向无限滚动,也就是向上加载更早的消息。给 Claude Code 下指令时,一定要说明加载方向,因为向下信息流和向上历史记录的滚动保持逻辑不同。
第五个场景是学习仪表盘。课程、练习和清单可以连续展示,但每个章节仍要有稳定 URL、进度标记和 CTA,例如 Claude Code 培训。
React Hook 实现
下面的 hook 以 cursor API 为前提。它使用 AbortController 清理旧请求,用 loadingRef 防止重复 fetch,并用 rootMargin 在哨兵真正到达屏幕前预加载。
import { useCallback, useEffect, useRef, useState } from "react";
export type CursorPage<T> = {
items: T[];
nextCursor: string | null;
};
type FetchPage<T> = (args: {
cursor: string | null;
signal: AbortSignal;
}) => Promise<CursorPage<T>>;
type InfiniteStatus = "idle" | "loading" | "error" | "done";
type UseInfiniteCursorOptions<T> = {
fetchPage: FetchPage<T>;
mergeItems?: (previous: T[], next: T[]) => T[];
initialCursor?: string | null;
};
export function useInfiniteCursor<T>({
fetchPage,
mergeItems,
initialCursor = null,
}: UseInfiniteCursorOptions<T>) {
const [items, setItems] = useState<T[]>([]);
const [cursor, setCursor] = useState<string | null>(initialCursor);
const [status, setStatus] = useState<InfiniteStatus>("idle");
const [error, setError] = useState<Error | null>(null);
const abortRef = useRef<AbortController | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef(false);
const hasMore = cursor !== null || items.length === 0;
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setStatus("loading");
setError(null);
try {
const page = await fetchPage({ cursor, signal: controller.signal });
setItems((previous) =>
mergeItems ? mergeItems(previous, page.items) : [...previous, ...page.items],
);
setCursor(page.nextCursor);
setStatus(page.nextCursor ? "idle" : "done");
} catch (unknownError) {
if (unknownError instanceof DOMException && unknownError.name === "AbortError") {
return;
}
setError(unknownError instanceof Error ? unknownError : new Error("Load failed"));
setStatus("error");
} finally {
loadingRef.current = false;
}
}, [cursor, fetchPage, hasMore, mergeItems]);
const sentinelRef = useCallback(
(node: HTMLElement | null) => {
observerRef.current?.disconnect();
if (!node || !hasMore) return;
observerRef.current = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) void loadMore();
},
{ rootMargin: "600px 0px", threshold: 0 },
);
observerRef.current.observe(node);
},
[hasMore, loadMore],
);
useEffect(() => {
void loadMore();
return () => {
abortRef.current?.abort();
observerRef.current?.disconnect();
};
}, [loadMore]);
return { items, status, error, hasMore, loadMore, sentinelRef };
}
关于 Effect 和外部系统同步,建议以 React useEffect 官方文档为准。让 Claude Code 审查时,要明确要求检查 observer 与 fetch 的 cleanup。
列表组件
组件不要只依赖自动加载。下面的例子保留了手动按钮,这对键盘用户、企业浏览器、网络失败后的恢复都很重要。
import { useCallback } from "react";
import { useInfiniteCursor, type CursorPage } from "./useInfiniteCursor";
type Article = {
id: string;
title: string;
summary: string;
href: string;
publishedAt: string;
};
function mergeUniqueById(previous: Article[], next: Article[]) {
const seen = new Set(previous.map((item) => item.id));
return [...previous, ...next.filter((item) => !seen.has(item.id))];
}
async function fetchArticlePage({
cursor,
signal,
}: {
cursor: string | null;
signal: AbortSignal;
}): Promise<CursorPage<Article>> {
const params = new URLSearchParams({ limit: "20" });
if (cursor) params.set("cursor", cursor);
const response = await fetch(`/api/articles?${params}`, { signal });
if (!response.ok) throw new Error(`Failed to load articles: ${response.status}`);
return response.json();
}
export function ArticleFeed() {
const fetchPage = useCallback(fetchArticlePage, []);
const { items, status, error, hasMore, loadMore, sentinelRef } = useInfiniteCursor({
fetchPage,
mergeItems: mergeUniqueById,
});
return (
<section aria-labelledby="article-feed-title">
<h2 id="article-feed-title">最新文章</h2>
<div role="feed" aria-busy={status === "loading"}>
{items.map((article, index) => (
<article
key={article.id}
role="article"
aria-posinset={index + 1}
aria-setsize={hasMore ? -1 : items.length}
>
<a href={article.href}>
<h3>{article.title}</h3>
</a>
<p>{article.summary}</p>
<time dateTime={article.publishedAt}>
{new Intl.DateTimeFormat("zh-CN").format(new Date(article.publishedAt))}
</time>
</article>
))}
</div>
{error && <p role="alert">加载失败。请检查网络后重试。</p>}
<div ref={sentinelRef} aria-hidden="true" />
<p aria-live="polite">
{status === "loading" && "正在加载更多文章。"}
{status === "done" && "所有文章已显示。"}
</p>
{hasMore && (
<button type="button" onClick={() => void loadMore()} disabled={status === "loading"}>
加载更多
</button>
)}
</section>
);
}
如果使用 role="feed",请查看 WAI-ARIA feed pattern。并不是每个列表都必须使用它,但它能提醒我们:读屏器用户是否知道当前位置、加载状态和失败状态?
Next.js Cursor API
前端写得再好,API 不稳定也会出问题。下面的 route 取 limit + 1 条,只返回 limit 条,多出来的一条用来判断是否还有下一页。
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const limit = Math.min(Math.max(Number(searchParams.get("limit") ?? "20"), 1), 50);
const cursor = searchParams.get("cursor");
const rows = await prisma.article.findMany({
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
select: {
id: true,
title: true,
summary: true,
href: true,
publishedAt: true,
},
});
const items = rows.slice(0, limit);
const nextCursor = rows.length > limit ? items.at(-1)?.id ?? null : null;
return NextResponse.json({ items, nextCursor });
}
上线前要检查数据库索引是否匹配排序字段。列表滚动是否顺滑,不只取决于前端,也取决于查询是否稳定。这里可以参考性能优化的思路,把 API 延迟和数据库执行计划纳入验证。
失败案例与坑
第一个坑是重复触发。哨兵元素停留在屏幕里时,Observer 可能连续触发。如果只靠 React state 判断 loading,渲染时机可能来不及,所以需要同步的 loadingRef。
第二个坑是在实时列表中使用 offset。新记录插入顶部后,第二页可能和第一页重叠。对于会变化的列表,API 用 cursor,前端再按稳定 id 去重。
第三个坑是用户永远到不了页脚。无限滚动会把公司信息、联系入口和 Claude Code 培训 这类 CTA 一直往下推。可以在若干页后停止自动加载,改为手动按钮。
第四个坑是忽视 SEO。搜索引擎和社交卡片不能依赖用户滚动后的状态。分类页、分页 URL、站点地图、内部链接都要保留。
第五个坑是返回按钮。用户点进详情页再返回,如果列表回到顶部,体验会很差。要测试 scroll restoration、缓存策略,以及筛选条件和 cursor 是否能恢复。
让 Claude Code 做失败模式审查
实现后,不要只让 Claude Code “看一下代码”。请要求它按照失败模式审查。
请按生产风险审查这个无限滚动实现。
检查重复 fetch、旧响应混入、IntersectionObserver cleanup、
AbortError、cursor 分页、无障碍、SEO、浏览器返回、
数据库索引、失败后的手动恢复。
请按文件列出问题和修复建议。
Claude Code 的工具定位可以查看 Anthropic Claude Code overview。越是让代理处理跨文件任务,越要把约束和审查清单写清楚。
总结与 CTA
无限滚动不是一个小动画,而是浏览器、API、数据库、无障碍、SEO 和业务转化共同组成的功能。使用 Claude Code 时,要把 Intersection Observer、cursor API、手动恢复、cleanup、返回按钮、验证清单一起写进任务。
如果团队想把这种质量变成日常流程,可以从 Claude Code 培训 开始,把提示词设计、差异审查、测试和上线检查整理成团队规范。
实测结果
这次更新中,我参考了 MDN、React、WAI-ARIA 和 Anthropic 官方文档,把原来损坏的文字内容替换为可上线的实现说明。代码按 TypeScript/TSX 组织,覆盖重复请求保护、AbortController、cursor API、手动恢复和 aria-live。在真实项目中,还应继续运行 build、API 压力检查、移动端浏览器测试和返回按钮恢复测试。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。