写在前面
如果你还在把 Next.js 当成一个「支持 SSR 的 React 框架」,那你的认知至少落后了两个大版本。从 Pages Router 到 App Router,Next.js 的渲染模型经历了一次彻底的重写。这篇文章不讲 Hello World,不教你怎么搭项目,只聚焦三个核心渲染机制:SSR(服务端渲染)、RSC(React Server Components)、Streaming(流式渲染),以及它们背后的架构取舍。
SSR:不是新东西,但实现方式变了
Pages Router 时代的 SSR
在 Pages Router 时代,SSR 的逻辑很直观:用户请求一个页面,服务端调用 getServerSideProps 获取数据,然后通过 renderToString 把整个组件树渲染成 HTML 字符串,一次性发给客户端。
// Pages Router 的 SSR
export async function getServerSideProps(context) {
const data = await fetch('https://api.example.com/posts')
const posts = await data.json()
return { props: { posts } }
}
export default function Page({ posts }) {
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
)
}这个模型的瓶颈很明显:瀑布式的阻塞。服务端需要等待所有数据就绪才能生成 HTML,浏览器需要等待整个 HTML 和 JS 加载完毕才能 hydration,页面中任何一个慢接口都会拖慢全局。更隐蔽的问题是,renderToString 是同步的——你没法在渲染过程中做任何并行的事情。
App Router 的 SSR:async component 原生支持
App Router 把 SSR 推进了一大步:组件本身可以是 async function。
// App Router 的服务端组件,无需 getServerSideProps
async function getPosts() {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function Page() {
const posts = await getPosts()
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}这看起来只是语法糖,但背后的架构变化是根本性的:组件即数据源。不再有 getServerSideProps 和组件之间的割裂,数据获取变成了组件树的一部分,每个组件自己声明数据依赖。
RSC:Server Components 才是真正的变革
如果说 SSR 是优化,RSC 就是范式转移。这两个概念经常被混为一谈,但它们的本质完全不同。
SSR vs RSC 的本质区别
| 维度 | SSR | RSC |
|---|---|---|
| 运行时机 | 每次请求时在服务端渲染 | 在服务端运行,结果序列化为特殊格式 |
| 产物 | HTML 字符串 | RSC Payload(一种紧凑的数据流格式) |
| 客户端交互 | 需要 hydration 才能交互 | Server Components 不发送 JS 到客户端 |
| 状态 | 每次请求重新执行 | 无状态,不持有客户端状态 |
RSC 的核心思路是:有些组件根本不应该出现在客户端。一个渲染 Markdown 的博客正文、一个查询数据库的面板、一个调用内部 API 的组件——它们不涉及交互,不需要 useState,不需要 useEffect,那为什么要把它们的 JS 发到客户端再跑一遍 hydration?
// 这个组件只在服务端运行,不会出现在客户端 bundle 中
import { db } from '@/lib/db'
import { remark } from 'remark'
export async function ArticleContent({ id }: { id: string }) {
const article = await db.article.findUnique({ where: { id } })
const html = await remark().process(article.content)
// 这段 JS 永远不会发送到客户端
return (
<article
className="prose"
dangerouslySetInnerHTML={{ __html: String(html) }}
/>
)
}RSC Payload 不是 HTML,而是一种紧凑的流式格式,描述了组件树的结构和最终渲染结果。客户端收到后,可以直接合并到客户端组件树中,不需要额外执行任何 JS。
客户端组件和服务端组件的边界
在 App Router 中,文件顶层的 'use client' 指令声明了客户端组件的边界。关键原则:客户端组件可以引入服务端组件,但不能反过来。
Streaming:让每个组件独立呼吸
如果说 RSC 改变了什么被渲染,Streaming 改变了什么时候被渲染。
Suspense 作为流式边界
在 App Router 中,Suspense 不仅是一个 loading 状态管理器,更是一个流式边界。RevenueChart 和 RecentOrders 会并行请求数据,各自独立 streaming 到客户端,哪个接口先返回,哪个区块就先出现在页面上。
从架构角度看 Streaming
传统的 SSR 是"全部或 nothing"——服务端要等到完整的 HTML 生成完毕才开始传输。Streaming 把渲染过程从批处理变成了管道。
传统 SSR:
请求 → 获取所有数据 → renderToString → 发送完整 HTML → hydration
Streaming SSR:
请求 → 获取部分数据 → 渲染部分内容 → 发送 → 继续获取 → 继续渲染 → 继续发送...
这个变化在慢接口或复杂页面上效果显著。假设一个页面有三个数据源:一个 50ms,一个 200ms,一个 800ms。传统 SSR 的 TTFB 至少是 800ms(最慢的接口决定一切)。Streaming 模式下,客户端可以在 50ms 后就看到第一部分内容。
缓存策略与架构总结
Next.js 15 引入了 'use cache' 指令,让缓存从"配置项"变成了"组件声明"。
把三个机制放在一起看,架构层次就很清晰了:
- 层 1 — RSC:决定组件在服务端执行还是客户端执行,减少客户端 JS 体积
- 层 2 — Streaming:决定渲染结果的传输时机,优化感知性能
- 层 3 — Cache:决定数据的复用策略,优化实际性能
这三层是正交的,可以独立调整。你可以在 RSC 基础上同时使用 Streaming 和缓存,也可以只取其中一部分。
实战建议
- 默认用 Server Components,只在需要交互时加
'use client' - 为每个独立的数据源包裹 Suspense 边界
- 使用
fetch的缓存参数控制数据新鲜度 - 避免在客户端组件中包裹大体积的服务端组件树
- SSR 不是银弹——不依赖 SEO 的内部工具页面,SPA 或许更简单
总结
Next.js 的渲染机制已经从"服务端生成 HTML,客户端 hydration"进化到了"服务端组件 + 客户端组件混合渲染,数据流式到达"。理解 SSR、RSC 和 Streaming 的本质差异和配合方式,是写出高性能 Next.js 应用的基础。