React Server Components 渲染策略:静态、动态与流式的选择边界
一、RSC 带来的渲染策略选择困惑
Next.js App Router 将组件默认设为 Server Component 后,开发者面临一个新的决策:哪些组件应该留在服务端渲染,哪些应该标记为 Client Component,哪些应该用 Streaming 流式传输。
一个博客列表页的典型困境:文章列表数据来自 CMS,适合服务端渲染;搜索框需要响应用户输入,必须是客户端组件;评论区需要实时更新,适合流式加载。三者如何在同一个页面中共存而不互相拖累?
最直观的"都放服务端"会阻塞页面直到最慢的数据返回。"都放客户端"会失去 SEO 优势并增加首屏 JS 体积。正确的做法是根据数据获取时机和交互需求为每个组件选择渲染策略,而非全局一刀切。
二、四种渲染策略的协作机制
Next.js App Router 提供了四种渲染策略,它们在同一页面中的协作关系如下:
graph TB subgraph 静态渲染 S1[Navbar 导航栏] S2[Footer 页脚] S3[侧边栏静态内容] end subgraph 动态渲染 D1[文章列表<br/>cookies/headers依赖] D2[用户推荐<br/>基于登录态] end subgraph 流式渲染 ST1[评论区<br/>Suspense包裹] ST2[相关文章<br/>Suspense包裹] end subgraph 客户端渲染 C1[搜索框] C2[点赞按钮] C3[主题切换器] end S1 --> PAGE[完整页面] S2 --> PAGE S3 --> PAGE D1 --> PAGE D2 --> PAGE ST1 -.->|边渲染边传输| PAGE ST2 -.->|边渲染边传输| PAGE C1 -.->|水合后激活| PAGE C2 -.->|水合后激活| PAGE静态渲染的组件在构建时生成 HTML,请求到达时直接返回。动态渲染的组件在请求时生成,但会阻塞整个响应直到完成。流式渲染通过 Suspense 边界将慢速组件拆分为独立的数据流,页面主体先展示,慢速部分后抵达。客户端组件在服务端完成 SSR 后,浏览器水合激活交互。
三、博客列表页的混合策略实现
// app/blog/page.tsx // 设计意图:在同一页面中协调四种渲染策略 // 静态部分由 Next.js 自动识别(无动态函数调用) // 动态部分显式标记 // 流式部分用 Suspense 包裹 // 客户端部分用 'use client' 标记 import { Suspense } from 'react'; import { headers } from 'next/headers'; // === 静态渲染:build 时生成,CDN 缓存 === // 此组件不依赖 request 信息,默认为 Server Component async function StaticSidebar() { // 分类列表很少变化,适合静态生成 const categories = await fetch( 'https://cms.example.com/categories', { next: { revalidate: 3600 } } // ISR 每小时刷新 ).then(r => r.json()); return ( <aside className="sidebar"> <h3>文章分类</h3> {categories.map((cat: { slug: string; name: string }) => ( <a key={cat.slug} href={`/blog/${cat.slug}`}>{cat.name}</a> ))} </aside> ); } // === 动态渲染:依赖 request 信息 === async function DynamicArticleList() { // headers() 的使用将此组件标记为动态渲染 const headersList = headers(); const userLocale = headersList.get('accept-language')?.split(',')[0] || 'zh'; const articles = await fetch( `https://cms.example.com/articles?locale=${userLocale}`, { cache: 'no-store' } // 不做缓存,每次请求都重新获取 ).then(r => { if (!r.ok) throw new Error(`文章列表加载失败: ${r.status}`); return r.json(); }); return ( <div className="article-list"> {articles.map((article: Article) => ( <ArticleCard key={article.id} article={article} /> ))} </div> ); } // === 流式渲染:Suspense 包裹的组件独立传输 === async function StreamingComments({ articleId }: { articleId: string }) { // 评论数据加载较慢(需要聚合多个来源),使用 Suspense 避免阻塞 const comments = await fetch( `https://cms.example.com/articles/${articleId}/comments`, { next: { revalidate: 60 } } ).then(r => r.json()); return ( <section className="comments"> <h3>评论 ({comments.length})</h3> {comments.map((c: Comment) => ( <CommentItem key={c.id} comment={c} /> ))} </section> ); } // 评论区的加载骨架——在数据到达前展示 function CommentsSkeleton() { return ( <section className="comments skeleton"> <div className="skeleton-title" /> {[1, 2, 3].map(i => ( <div key={i} className="skeleton-comment"> <div className="skeleton-avatar" /> <div className="skeleton-text" /> </div> ))} </section> ); } // === 客户端渲染:需要交互的组件 === // SearchBox 单独放在一个文件中,顶部有 'use client' import { SearchBox } from './search-box'; // === 页面主组件:编排四种渲染策略 === export default async function BlogPage({ params, }: { params: { slug?: string }; }) { return ( <div className="blog-page"> {/* 静态部分——由框架自动决定渲染策略 */} <StaticSidebar /> <main className="main-content"> {/* 客户端部分——水合后激活交互 */} <SearchBox /> {/* 动态部分——每次请求都重新渲染 */} <DynamicArticleList /> {/* 流式部分——不阻塞页面主体展示 */} {params.slug && ( <Suspense fallback={<CommentsSkeleton />}> <StreamingComments articleId={params.slug} /> </Suspense> )} </main> </div> ); }四种策略的触发机制:不调用动态函数的组件自动静态渲染;调用headers()/cookies()的组件动态渲染;被 Suspense 包裹的组件流式渲染;标记'use client'的组件先 SSR 后水合。
四、策略选择的反面案例与决策矩阵
过度使用动态渲染:将站点 Logo 也放进headers()依赖链中,导致本可静态缓存的组件变成每次请求都重新渲染。原则是:能不依赖 request 信息的组件尽量保持静态。
Suspense 粒度过粗:用单个 Suspense 包裹整页内容,等于放弃流式渲染的优势。应该为每个独立的慢速数据源设置独立的 Suspense 边界。
| 组件特征 | 推荐策略 | 触发方式 |
|---|---|---|
| 内容不随请求变化 | Static | 默认(无动态函数) |
| 依赖 cookie/header | Dynamic | 调用cookies()/headers() |
| 加载时间 > 500ms | Streaming | Suspense包裹 |
| 需要用户交互 | Client | 'use client' |
| 内容更新频率低但非静态 | Static + ISR | next: { revalidate } |
五、总结
RSC 渲染策略的选择原则:
- 优先静态——不依赖请求信息的组件让框架自动优化;
- 按需动态——仅在需要 cookie/header 时标记为动态;
- 拆分 Suspense——每个慢速数据源一个独立的 Suspense 边界;
- 客户端做交互——需要事件处理、状态、Effect 的组件明确标记;
- ISR 替代纯静态——内容变化频率低的组件用 ISR 自动刷新。
落地检查清单:
- 确认
headers()和cookies()的使用范围仅限于必要的组件; - 为每个数据获取操作设置独立的 Suspense,提供对应的骨架 UI;
- 用 Lighthouse 的"阻塞时间"指标验证流式渲染的效果——首屏不应被慢速数据阻塞。