ByteNoteByteNote
Next.js 文章页侧边栏:布局、随机推荐与流式加载
字

字节笔记本

2026年10月7日 · 约 9 分钟读完

Next.js 文章页侧边栏:布局、随机推荐与流式加载

API中转
¥120

内容站的文章页几乎都有一个侧边栏:放导航、放推荐、放广告。它看起来只是「在正文旁边再画一列」,真动手时会接连撞上三个问题:布局上,桌面端双栏、移动端单栏怎么切换,顶栏怎么常驻;数据上,侧边栏塞「相关文章」还是「随机推荐」,随机接口怎么写才真的是随机;加载上,这块次要内容慢了,会不会把正文首屏一起拖住。最近在一次基于 Next.js App Router 的改造里,这三个问题被从头到尾走了一遍,本文把过程中的结论和踩到的坑整理出来。

一、先把骨架立起来:双栏布局与 sticky 顶栏

原始页面是一个全宽单栏:max-w-7xl 容器里一个 article 元素占满。改造第一步是把外层容器换成 flex flex-col lg:flex-row lg:space-x-8,正文列收缩为 lg:w-3/4,新增的 <aside> 占 lg:w-1/4。注意前缀是 lg::小屏幕上仍是上下堆叠的单栏,桌面端才分栏。这是 Tailwind 响应式布局最省心的用法——不要在移动端硬藏,而是让文档流自然换行。

文章页布局与组件结构

两个容易忽略的细节:

  • 侧边栏要 h-fit。flex 子项默认沿交叉轴拉伸,正文很长时侧边栏卡片会被拉到和正文一样高,留出大片空白。h-fit(即 height: fit-content)让卡片只包裹自己的内容。
  • 顶栏常驻用 sticky 而不是 fixed。fixed 让元素脱离文档流,页面开头会凭空少一块高度,需要手动补 padding;sticky top-0 z-50 的元素仍占位,滚动越过阈值才吸附,再配 z-50 盖住滚动内容即可。

第二个动作是把顶栏从文章页的路由布局提升到 RootLayout。路由布局只对特定路由段生效,而品牌顶栏、搜索入口应该全站可见——放进根布局,所有页面自动继承,文章页里就只剩内容本身。

顺带一提,这类文章页通常还在 generateMetadata 里再取一次同一篇文章的数据,用来生成标题、描述和 OpenGraph 图片,供搜索引擎与社交分享卡片使用。同一请求周期内,App Router 会自动合并相同 URL 的 fetch,两处取数并不会真的发两次请求;但哪天把取数改成直连数据库,这种「一处数据两处取」的写法就值得合并成单次查询了。

二、侧边栏放什么:相关文章还是随机推荐

侧边栏的经典内容是「相关文章」,常见实现是拿当前文章标题当关键词去搜索接口查一圈。这个方案在内容多了以后效果不错,但有个冷启动悖论:站内文章不多、标题又写得具体时,搜索经常空手而归,侧边栏一块醒目的「没有相关文章」比空白还尴尬。

随机推荐是更朴素的替代:不管文章多冷门,侧边栏永远有内容,旧文章也多了一条被翻牌的通道。这次改造的接口形如 GET /api/articles?random=true&count=5,一次返回五篇。

有意思的是这个接口的第一版藏着一行真 bug。最初的思路是「先查一次拿到文章总数,再按随机偏移取一篇」:

ts
.limit(1)
.then(response => {
  const count = response.data.length;   // 永远是 1
  const randomIndex = Math.floor(Math.random() * count); // 永远是 0
  return supabase.from('blog')
    .select('id,title,created_at,content,summary')
    .range(randomIndex, randomIndex);
})

limit(1) 取回的数组长度恒为 1,随机索引于是永远是 0——所谓随机,实际上每次都返回同一篇。要拿总数,正确姿势是查询时带上 count: 'exact',或者干脆换个思路。

修正后的版本权衡了实现成本:取最近 100 篇(.limit(100)),在内存里洗牌后切前 N 篇返回。限定「最近 100 篇」而不是全表,一是控制传输量,二是让推荐偏向近期内容;在应用层洗牌,则是因为 PostgREST 的排序参数不能直接用 random() 这类函数。不过有两点值得再进一步:

  • sort(() => 0.5 - Math.random()) 是出了名的有偏洗牌,元素留在原位的概率更高。侧边栏场景无所谓,要严格随机应换 Fisher-Yates 算法;
  • 数据量上去之后,更好的路径是写一个 Postgres 函数(order by random() limit n),通过 Supabase 的 RPC 调用,把随机下推给数据库。

随机推荐接口的三代演进

另一次迭代是把「循环调五次单篇随机接口」合并成「一次带 count 参数的多篇接口」。前者在服务端渲染时会串行发出五个 HTTP 请求,后端与序列化开销都翻五倍;合并后只剩一次往返。侧边栏这类「小而多」的数据源,接口粒度宁可粗一点。

三、别让侧边栏拖住正文:Suspense 流式加载

数据就位后,下一个问题是加载时序。Next.js 的服务器组件可以直接写成 async 函数在组件里 await 数据——但如果页面组件和侧边栏组件各自 await,整页的返回时间就被最慢的那条链决定,50 毫秒的正文查询可能因为推荐接口慢 300 毫秒而整体变慢。

解法是把侧边栏包进 <Suspense>:

tsx
<div className="lg:col-span-1 space-y-6">
  <Suspense fallback={<RelateSkeletonCard />}>
    <RelatedArticles id={post.id} />
  </Suspense>
</div>

正文外壳先渲染先返回,侧边栏作为独立的流式块,数据好了再补上。用户看到的是:正文秒开,侧边栏位置先出现骨架屏,随即填充。骨架屏用 animate-pulse 的灰色块模拟真实卡片的标题和列表行,占位尺寸要和最终内容一致,否则内容到达时页面跳动,CLS 就上去了。

两个配套细节:

  • 随机内容不希望被缓存,fetch 要显式 { cache: 'no-store' }。App Router 下 fetch 默认走缓存语义,忘了这条,用户每次刷新看到的可能是同一批「随机」文章;
  • 同一个组件可以被 <Suspense> 包两次,生成两个独立推荐位,各自流式到达,互不阻塞。

四、踩坑清单与延伸思考

把这次改造里值得记下的点收拢一下:

  1. sticky 的祖先链上有 overflow: hidden 会失效。卡片常写 overflow-hidden 裁圆角,如果它套在 sticky 元素外层,粘性会退化成随容器滚动。顶栏要挂在没有 overflow 裁剪的容器下。
  2. dangerouslySetInnerHTML 渲染富文本前先消毒。文章内容若来自多人投稿或外部导入,直接注入 HTML 是标准 XSS 入口,入库前应过一遍白名单清洗。
  3. 服务器组件里 fetch('http://localhost:3000/...') 自己的 API 能跑,但绕了一圈:多一跳 HTTP 和一次序列化,同进程直接查库更快;接口层留给客户端组件用。
  4. 随机内容与缓存天生相克。推荐位上了 CDN 或 ISR 之后,用户看到的其实是缓存快照;可以折中成「主文档放心缓存 + 推荐位实时」——这正是 Suspense 流式块的另一个好处,推荐位单独成块、单独请求。
  5. 移动端别一刀切隐藏。hidden lg:block 最省事,但随机推荐在移动端往往是有效的「继续读」入口,把它挪到正文末尾比直接藏掉更划算。
  6. 列表型接口别拖正文。随机接口的 select 里带着 content 整列,五篇文章的正文全塞进一个侧边栏请求;列表型接口只取 id、title、created_at 这类摘要字段,正文留给详情页按需加载,带宽和序列化时间一起省。

收尾

一个侧边栏,从 CSS 布局写到 API 设计,再落到 React 的流式渲染模型——它小到一个下午能做完,又完整覆盖了内容站技术栈的三个层面。更值得带走的是决策顺序:先定布局与信息架构,再定数据策略并为「看起来能跑」的实现较真(那行恒为 0 的随机索引就是教训),最后才考虑加载时序。下次给自己的文章页加侧边栏,不妨按这条链路走一遍。

相关文章

分享: