成为全栈·Next.js 网站前台篇·文章阅读辅助上下篇、面包屑、相关文章与阅读记录正文是文章详情页的主任务面包屑、上下篇、相关文章和阅读记录都只能帮助阅读。任何一个辅助接口失败都不应该把正文一起拖进错误页。前言各位看官一篇文章能打开只说明“内容展示”完成了。读者还需要知道自己在哪里、上一篇和下一篇是什么、读完后还能看什么会员也希望下次回来时找到阅读历史。这些功能看起来都围绕文章数据语义却完全不同面包屑来自分类祖先链上下篇依赖发布时间排序相关文章来自后端关联算法阅读次数属于公开统计个人阅读记录则属于当前会员。如果把它们塞进一次大请求任何一个次要模块失败都会拖垮正文如果全部交给浏览器又会出现一片片加载闪烁。本文就拆开这组容易被称为“详情页周边”的真实边界。先给详情页数据排优先级数据是否为主任务失败后的页面行为数据所有权文章标题与正文是404 或进入错误边界公开内容分类面包屑否保留首页、文章两级公开导航上下篇否隐藏对应入口公开导航相关文章否不显示继续阅读区公开推荐阅读次数否不阻断阅读允许重试公开统计会员阅读记录否不提示全页错误允许下次重试私有数据这张表决定了代码里的错误处理。核心文章不能吞错后伪装成空对象辅助模块则可以在明确的局部边界内降级。核心文章与辅助请求分两步获取详情页先获得文章因为后续请求需要它的 id 与 categoryIdconst load cache(async (slug: string) getArticle(slug).catch((error) { if (error instanceof ApiError error.status 404) notFound() throw error }), ) const article await load((await params).slug)这里只把后端明确返回的 404 转成notFound()。超时、502 和响应格式异常继续抛出让路由错误边界表达“系统暂时不可用”。有了文章身份以后再并发加载彼此独立的辅助信息const [adjacent, related, tree, allTags, breadcrumb] await Promise.all([ getArticleAdjacent(article.id).catch(() null), getArticleRelated(article.id, 4).catch(() []), categories().catch(() []), tags().catch(() []), article.categoryId ? getCategoryBreadcrumb(article.categoryId).catch(() []) : Promise.resolve([]), ])这里的catch不是“错误都不重要”而是各模块已经有清楚的缺席表现。相关文章失败返回空数组上下篇失败返回null分类树失败只会让分类标签缺席正文仍然成立。面包屑来自分类祖先链不是字符串切割分类 URL 中只有当前 slug无法靠/categories/frontend/react这样的路径切割推导祖先项目公开地址本来也不是嵌套分类路径。正确数据来自后端 breadcrumb 接口nav classNamebreadcrumb aria-label面包屑 Link href/首页/Link span//span Link href/articles文章/Link {breadcrumb.map((category) ( span key{category.id} { / } Link href{/categories/${category.slug}}{category.name}/Link /span ))} /nav祖先顺序由后端分类关系决定。即使分类被移动只要接口返回新的祖先链页面就不会继续展示旧层级。上下篇的“上”和“下”必须由接口定义“上一篇”可能表示发布时间更早也可能表示排序位置更靠前。如果前端分别请求publishedAt 当前时间和publishedAt 当前时间还要自行处理草稿、相同时间和稳定排序。当前项目把相邻关系交给后端前端只负责可访问的导航结构{adjacent ( nav classNameadjacent aria-label相邻文章 {adjacent.prev ? ( Link href{articleUrl(adjacent.prev)} small← 上一篇/small {adjacent.prev.title} /Link ) : span /} {adjacent.next ( Link href{articleUrl(adjacent.next)} small下一篇 →/small {adjacent.next.title} /Link )} /nav )}位于序列第一篇或最后一篇时一侧为空是正常边界不是错误。页面也不能为了对称编造一个返回首页的“下一篇”。相关文章是一种推荐结果不是前端数组过滤前端手里只有当前文章和少量摘要无法完整实现分类、标签、状态和排序规则。相关文章由/articles/{id}/related返回页面只限制展示数量{related.length 0 ( section aria-labelledbyrelated-title h2 idrelated-title继续阅读/h2 {related.map((item) ( div classNamerank key{item.id} Link href{articleUrl(item)}{item.title}/Link /div ))} /section )}当前接口返回的是规则推荐不应在文案里夸大为“猜你喜欢”。如果以后引入个性化算法还需要解释推荐依据、隐私与冷启动问题而不是只替换标题。阅读五秒后再记录比页面一打开就加一更诚实详情页面一挂载就上报阅读会把误点、预取和立即返回都算作有效阅读。当前实现等待五秒并在当前浏览器会话中按文章去重const viewed new Setnumber() useEffect(() { const timer setTimeout(() { if (viewed.has(id)) return viewed.add(id) void request(/articles/${id}/view, { method: POST, skipAuth: true, skipRefresh: true, }).catch(() viewed.delete(id)) }, 5000) return () clearTimeout(timer) }, [id])失败时从集合删除用户再次访问仍有重试机会。这个五秒阈值只是产品规则不代表读者一定读完更不能把viewCount写成“完读人数”。公开阅读次数与个人阅读历史不是同一份状态游客也可以增加公开阅读统计会员登录后还会写入个人历史。个人去重键必须包含用户身份const recorded new Setstring() const key ${user?.id}:${id} if (user !recorded.has(key)) { recorded.add(key) void reportReadingProgress(id).catch(() recorded.delete(key)) }若只按文章 id 去重A 账号读过文章后同一浏览器切换到 B 账号B 的历史将不会记录。身份是私有状态键的一部分这与 React Query 的私有缓存隔离是同一原则。这里也刻意没有用 localStorage 保存完整阅读清单。服务端已经为会员提供历史浏览器长期存储会留下额外隐私痕迹。内存集合只负责当前页面生命周期内的重复上报控制。一套覆盖降级与隐私的验收清单1. 正常文章面包屑、上下篇、相关文章均可到达 2. 第一篇与最后一篇缺失的一侧不生成假链接 3. adjacent 返回 500正文、评论和目录仍可使用 4. related 返回空数组不显示空的“继续阅读”标题 5. 停留不足五秒后离开不记录阅读 6. 上报失败后重新进入允许再次尝试 7. A 账号读完后切换 B 账号B 能写入自己的历史 8. 游客阅读不调用私有历史接口这组验证不能只看页面截图还要观察网络请求的时间、次数和用户身份。辅助模块的质量往往藏在“失败以后还剩什么”和“切换账号后写给谁”。适用边界五秒去重适合一般博客的轻量统计不适合课程进度、计费阅读或合规审计。后几类业务需要服务端事件、幂等键、可追溯时间和更明确的完成条件。相关文章和上下篇也依赖内容规模。只有几篇文章时可以不显示推荐区内容量变大后则需要观察推荐重复率、相关性与接口成本。小结文章详情页不是接口越多越完整而是主次关系越清楚越可靠。正文负责成立面包屑说明位置上下篇延续顺序相关文章提供发现阅读记录保留个人进度。真正重要的实现原则只有两条辅助模块各自降级私有状态始终带上身份。做到这两点详情页才能在部分服务失败时继续完成阅读任务。延伸阅读文章详情Markdown、代码高亮与目录多级分类、标签与 URL让内容导航既可读又可索引网站前台篇·文章列表服务端首屏与客户端持续加载怎样协作如果这篇文章对你有帮助欢迎订阅我的 CSDN 专栏「成为全栈」 专栏地址https://blog.csdn.net/fungleo/category_13204651.html 本系列配套代码仓库https://github.com/fengcms/become-a-full-stack-developer