返回博客列表
项目复盘2026年10月14日10 分钟阅读

让博客列表真正分页:Next.js 16 + Go + Tiptap 的实战复盘

结合当前博客的真实代码,复盘 Tiptap 文档如何进入 Go 内容 API,再通过 Next.js 16 的服务端分页、URL 查询状态与不同缓存策略抵达列表、正文、RSS 和 sitemap,并说明当前实现的边界。

#Go#Next.js#Tiptap

把文章写进编辑器、点下发布,只是内容系统的一半。另一半是:读者能不能按分类找到它,第二页是否保留搜索条件,正文更新后多久可见,以及 RSS 和 sitemap 会不会漏掉后面的文章。

最近整理自己的博客项目,我重新看了一遍这条链路。项目使用 Next.js 16 和 React 做页面,Go、Gin、GORM 和 MySQL 提供内容 API,Tiptap 负责结构化编辑,MinIO 保存媒体文件。这篇文章记录当前实现中的几个选择,重点是分页、查询和缓存之间的边界。

1. 一份正文,几种不同的读取需求

文章在后台保存为 Tiptap JSON,但每个页面并不需要拿到整个文档。

编辑页需要可修改的文档树和版本号;详情页需要完整正文、目录及相邻文章;列表页只需要标题、摘要、封面、分类和标签;RSS 和 sitemap 则需要遍历已发布文章。

我把这几种需求拆开处理:正文详情走独立接口,普通列表按页读取,订阅和站点地图才执行全量枚举。这样,增加一个目录或表格节点,不会顺带增加每次列表请求的正文传输量。

当前文章协议中,contentFormat 区分 Markdown 和 Tiptap,contentJson 保存文档树,contentText 保存派生文本,contentVersion 记录编辑版本。旧 Markdown 仍可读取,后台打开旧文章时再转换为结构化文档。

Tiptap 提供 editor.getJSON() 获取文档,并支持用 JSON 恢复内容。这让保存与再次编辑可以使用同一种表示。Tiptap 内容存储文档

前台则递归地把节点渲染成 React 元素,并从二、三级标题提取目录。后台阅读预览复用同一个渲染组件,减少两套展示逻辑产生差异的机会。

2. 分页必须进入查询链路

假设一个博客有 300 篇文章,每页展示 6 篇。把 300 篇都取回来,再在浏览器里执行 slice,页面虽然出现了页码,请求规模却没有变。这个数字只是说明问题的例子,并非项目性能测试结果。

当前博客列表使用 getRemoteBlogPage。它接受页码、每页数量、关键词、分类 ID 和标签 ID,默认每页 6 条,然后把条件传给 Go API。

const payload = await articlesApi.publicList(
  {
    page: safePage,
    pageSize: safePageSize,
    keyword: keyword?.trim() || undefined,
    categoryId,
    tagId
  },
  listRequestOptions
);

后端先限制已发布状态,再叠加搜索和分类标签条件,计算匹配记录的 total,最后应用排序、Limit 和 Offset。前端用匹配结果的总数计算页数。

这里的顺序很关键:先筛选,再分页。否则“第二页里的某个分类”与“这个分类的第二页”会成为两种不同结果。

公开列表返回摘要字段,不包含完整 contentJson。正文只在打开详情时获取。对当前项目来说,这个接口分工比给浏览器里的数组切片增加更多页码按钮更有意义。

3. URL 是搜索和分页的共同状态

列表查询状态放在 URL 中,例如:

/blog?q=Go&category=backend-engineering&page=2

q 表示关键词,category 使用可读的分类 slug,page 表示页码。Next.js 页面读取这些参数,把分类 slug 映射为 API 使用的真实分类 ID,再请求当前页。

客户端组件负责构造地址:

const params = new URLSearchParams();
if (targetKeyword.trim()) params.set("q", targetKeyword.trim());
if (targetCategory) params.set("category", targetCategory);
if (targetPage > 1) params.set("page", String(targetPage));
router.push(`/blog${params.size ? `?${params}` : ""}`);

搜索提交或分类切换会回到第一页,页码和查询参数随导航一起写入 URL。刷新页面、复制链接和使用浏览器后退时,都可以从地址重新得到相同的查询意图。

还有一个细节:分类不存在时,页面不会悄悄退回全部文章。分类接口失败也会显示错误状态,避免让读者把接口异常理解成“这个分类没有内容”。

当前关键词查询覆盖标题、摘要、分类名和标签名。虽然数据库已保存 contentText,这条搜索链路还没有使用正文全文检索,不能把它描述成完整的全文搜索系统。

4. 列表与正文采用不同的缓存选择

项目没有给所有内容请求设置同一个缓存策略:

const requestOptions = { cache: "no-store" as const };
const listRequestOptions = { next: { revalidate: 60 } };

列表和分类标签读取使用 60 秒再验证配置,正文详情使用 no-store。前者允许内容集合短暂过时,后者让详情请求从内容服务读取。

这些参数控制 Next.js 服务端的数据缓存;它们的含义可以在官方 fetch 文档中核对。60 秒是缓存再验证配置,不是“发布后所有访问入口必定在 60 秒内同步”的承诺,还需要考虑其他缓存层。

文章详情读取函数另外使用 React cache 包装,正文页面和 generateMetadata 都调用它,让同一次服务端渲染中的重复读取可以复用。这里需要区分请求内复用和跨请求的持久缓存。

当前实现还没有在后台发布后主动触发列表缓存失效。如果以后要求发布后立即出现在所有列表中,应当补上发布事件与再验证链路,并对失败情况做验证,而不能只调整页面上的提示文案。

5. RSS 和 sitemap 需要完整枚举

普通页面只取当前页,RSS 和 sitemap 却需要遍历内容集合。为此,我保留了独立的 getRemoteBlogContent,供这类出口使用。

它先请求 100 条,从 total 计算总页数,再并行请求剩余页:

const first = await getRemoteBlogPage({ pageSize: 100 });
if (first.error) return { posts: [], error: first.error };

const pageCount = Math.ceil(first.total / 100);
const rest = await Promise.all(
  Array.from({ length: Math.max(0, pageCount - 1) }, (_, index) =>
    getRemoteBlogPage({ page: index + 2, pageSize: 100 })
  )
);

const error = rest.find((result) => result.error)?.error ?? null;
return {
  posts: error ? [] : [first, ...rest].flatMap((result) => result.posts),
  error
};

如果其中一页失败,整个枚举返回错误。RSS 和 sitemap 路由相应返回 503,避免把缺了一部分文章的结果当成完整成功响应。

这是我更在意的取舍:空列表、接口失败和完整枚举成功,是三种不同状态。页面可以展示错误信息,机器消费的出口则需要明确的失败信号。

目前这些分页请求没有共享数据快照。枚举期间若发生发布或删除,仍可能出现重复或遗漏;文章数量继续增长时,也需要限制并行请求数。游标分页、稳定排序和快照机制是后续演进方向,当前实现不能保证一次全量读取中的集合完全不变。

6. 自动保存也有自己的边界

后台对已有文章使用 1600 毫秒防抖自动保存;新文章在首次手动保存或发布后获得 ID,再进入编辑地址。更新请求携带当前 contentVersion,服务端发现版本过期时返回 409,让编辑者刷新后处理冲突。

这个机制已经能识别部分过期编辑,但当前代码先比较版本,再按文章 ID 更新,没有把版本条件放进最终更新语句,也没有采用行锁。我不会把它写成完善的并发控制方案。

后续如果加强这一点,可以使用带版本条件的原子更新,并检查受影响行数;或者采用合适的锁策略。同时还需要在编辑器里保留未保存内容,让用户能够比较和恢复自己的修改。冲突提示与真正的恢复能力是两件都要完成的工作。

7. 我会怎样检查这条链路

这次整理让我更明确:验证不能只停在“发布接口返回成功”。对这套实现,我会沿着读者和订阅工具实际经过的路径检查:

  • 搜索后翻页,确认 URL、请求参数和总页数仍对应相同条件。

  • 切换分类或重新搜索,确认页码回到第一页。

  • 检查公开列表的响应,确认没有完整正文,且只包含已发布文章。

  • 打开正文并修改内容,分别观察详情和列表的更新行为。

  • 在内容超过一页时核对 RSS 和 sitemap,并模拟某一页请求失败。

  • 从两个编辑窗口更新同一篇文章,核对冲突反馈及未保存内容的处理。

这些是验证清单,并不代表每一种场景都已经完成压力测试或故障演练。项目也没有提供可引用的性能提升比例,所以本文只讨论实现和取舍。

8. 下一步:让各个入口的行为更一致

这套博客的内容边界已经落在代码中:Tiptap 文档用于编辑和正文渲染,Go 管理存储与公开查询,Next.js 根据页面需求选择读取方式;普通列表按页请求,RSS 和 sitemap 单独枚举。

接下来我更想补齐的是发布后的主动缓存再验证、严格的版本条件更新,以及全量枚举的一致性。它们都不直接增加页面数量,却决定了一篇文章在不同入口里是否表现一致。

对个人博客而言,点下“发布”之后的这些细节,正是把一组页面维护成一个内容系统时最值得持续打磨的部分。