服务端渲染(SSR)的新篇章:从 Next.js 看 React Server Components
从“SSR 不是更快吗?为什么还会卡在 hydration?”这个痛点出发,厘清 SSR/SSG/ISR 的边界,再用 Next.js(App Router)视角讲清 React Server Components(RSC)的核心:组件分工模型改变、减少客户端 JS、支持流式渲染与更自然的数据获取;最后给出实践落地的拆分策略与常见坑
服务端渲染(SSR)的新篇章:从 Next.js 看 React Server Components
很多人第一次接触 SSR 的心态是:
“服务端先把 HTML 渲染好发给浏览器,那肯定更快、更丝滑吧?”但现实往往是:
- 首屏 HTML 很快就到了
- 你却仍然点不了、交互没反应
- 等到一堆 JS 下完、执行完、hydrate 完,页面才“活过来”
于是你开始困惑:
SSR 到底解决了什么?又没解决什么?RSC 又是来干嘛的?这篇我们用一条主线讲清楚:
SSR 是“提前出 HTML”,而 RSC 是“重新划分组件在哪运行”,目标是减少客户端 JS 与 hydration 压力。
0. 先把四个名词放到同一张地图里:SSR / SSG / ISR / RSC
你可以先用一句话记住它们的“主要矛盾”:
- SSR(Server-Side Rendering):请求来了,在服务端现算 HTML
- SSG(Static Site Generation):构建时预生成 HTML,部署后直接读静态文件
- ISR(Incremental Static Regeneration):静态页面可按策略增量再生成(兼顾更新与缓存)
- RSC(React Server Components):不是“渲染模式”,而是组件执行分工模型:有些组件只在服务端执行,不打包到客户端
关键点在这里:
SSR/SSG/ISR 讨论的是“HTML 何时生成”。
RSC 讨论的是“组件在哪执行、JS 发不发到浏览器”。
所以 RSC 不是 SSR 的同义词,也不是简单“更强 SSR”。
1. SSR 的真实价值:更快的首屏内容(但不保证更快可交互)
1.1 SSR 解决什么?
- 更快看到内容:HTML 先到,浏览器能更早 paint
- 更好 SEO:爬虫能直接拿到内容(注意:现代爬虫对 JS 也越来越强,但 SSR 仍然更稳)
- 更容易做流式输出(Streaming SSR):服务端可以边算边吐 HTML,用户更快看到“部分页面”
1.2 SSR 没解决什么?(很多人以为 SSR = 立刻可点)
SSR 的 HTML 到了之后,页面要“活”起来还得靠:
- 下载客户端 JS(包含 React runtime + 组件代码)
- 执行 JS,注册事件
- Hydration(注水):把服务端 HTML 与客户端 React 树对齐,绑定事件与状态
这一步如果 JS 体积大、组件多、设备弱,就会出现:
- 内容已显示但不可交互(TTI 慢)
- 输入/点击延迟
- hydrate 期间卡顿
一句话:
SSR 加速 FCP(首次内容绘制),但不天然加速 TTI(可交互时间)。
2. Hydration 到底在干嘛?为什么它会成为瓶颈?
把 hydration 想成一件很“较真”的事:
- 浏览器拿到 SSR 的 HTML,先画出来(很好)
- 客户端 JS 运行后,React 要在内存里“重建”一棵组件树
- React 要确保这棵树与现有 DOM 一致(否则会警告或回退到客户端重渲染)
- React 逐步给 DOM 节点绑定事件、恢复状态、建立关系
这件事慢的原因很常见:
- 客户端 JS 太大(下载 + 解析 + 执行)
- 组件树太深太重(构建与对齐成本高)
- 页面需要大量交互组件(事件绑定多)
- 低端设备 上 JS 执行成本被放大
所以很多 SSR 项目“首屏很快但不跟手”,根因往往就是:
hydration 成本与客户端 JS 成本没有下降。
3. RSC 的核心动机:让很多组件根本不需要发到客户端
现在进入主角:React Server Components(RSC)。
你可以用一句话理解 RSC:
把“只负责展示与数据拼装、无需浏览器能力”的组件,留在服务端执行;
只把真正需要交互(事件、状态、浏览器 API)的部分,打包给客户端。
这带来一个直接收益:
- 客户端需要下载/执行的 JS 更少
- hydration 的工作量更小(因为客户端组件更少)
3.1 RSC 与 SSR 的关系(最容易混淆的点)
- SSR:把 HTML 提前吐给浏览器(渲染时机)
- RSC:决定哪些组件不进客户端 bundle(执行位置与产物)
在 Next.js(App Router)里经常是组合出现:
- 页面是 SSR/SSG/ISR 之一(决定 HTML 何时生成)
- 同时用 RSC 让大量组件成为 Server Component(减少客户端 JS)
4. 在 Next.js 里怎么看 RSC?——“默认 Server,遇到交互才 Client”
在 Next.js App Router 的语境下,一个非常重要的默认是:
组件默认是 Server Component,只有标记了
"use client"才是 Client Component。
4.1 Server Component 能做什么?
适合做:
- 读取数据库/后端接口(在服务端环境)
- 拼装页面结构(Header、Layout、列表容器)
- 做权限判断(在服务端更安全)
- 处理与密钥相关的逻辑(不暴露给客户端)
特点:
- 不需要把组件 JS 发给浏览器(减少 bundle)
- 产物更多是“可序列化的 UI 描述 + 片段输出”,而不是完整的客户端组件代码
4.2 Client Component 负责什么?
只要你需要:
useState/useEffectonClick/onChange等事件- 访问
window/document - 使用依赖浏览器的第三方库(很多 UI/图表/富文本)
你就必须是 Client Component("use client")。
这也引出一个非常实用的拆分原则:
把交互尽量下沉到叶子组件,把大容器与数据获取尽量留在 Server Component。
5. “组件分工模型”改变后,你的心智模型要怎么变?
你以前可能习惯这样写:
- 页面组件里
useEffect拉数据 - loading 状态在客户端维护
- 数据到齐后再渲染列表
在 RSC 模型里,更自然的方式是:
- 在 Server Component 里直接
await数据(服务端环境) - 把数据作为 props 传给 Client 子组件(如果子组件要交互)
- loading 用流式/分段渲染(或 Suspense 边界)来表达
核心变化是:
数据获取从“浏览器发起”更多回到“服务端发起”。
客户端更像是“交互层”,而不是“数据拼装层”。
6. 典型拆分示例(概念代码):服务器拿数据,客户端负责交互
假设你有一个商品列表页面:
- 列表展示很多(适合服务端拼装)
- 每行有一个“加入购物车”按钮(需要交互)
你可以拆成:
6.1 Server:负责拿数据与拼装结构
// Server Component(默认)
export default async function ProductsPage() {
const products = await fetchProducts() // 服务端拉数据
return (
<div>
<h1>Products</h1>
<ProductList products={products} />
</div>
)
}
6.2 Client:负责交互
'use client'
export function AddToCartButton({ productId }) {
const [pending, setPending] = React.useState(false)
return (
<button
disabled={pending}
onClick={async () => {
setPending(true)
try {
await addToCart(productId)
} finally {
setPending(false)
}
}}
>
{pending ? 'Adding...' : 'Add to cart'}
</button>
)
}
6.3 组合:Server 组件渲染 Client 子组件(交互下沉)
export function ProductList({ products }) {
return (
<ul>
{products.map((p) => (
<li key={p.id}>
<div>{p.name}</div>
<AddToCartButton productId={p.id} />
</li>
))}
</ul>
)
}
这套结构的直观收益:
ProductsPage和ProductList不需要打包进客户端(在可行的前提下)- 客户端只需要
AddToCartButton的 JS - hydration 面积显著缩小
7. RSC 的常见“坑点”与边界(必须提前知道)
7.1 不是所有第三方库都能直接放 Server
很多库假设浏览器环境(window/document),只能在 Client 用。 因此你可能会看到:
- “这个包只能在客户端使用”的报错
- 或构建时提示不支持某些 API
应对思路:
- 把依赖这些库的部分下沉到
"use client"组件里 - Server 组件只做数据与结构
7.2 序列化边界:Server -> Client 的 props 需要可序列化
你不能把:
- 函数
- class 实例
- 含循环引用的对象 随便从 Server 传给 Client。
应对思路:
- 传纯数据(JSON 形态)
- 或传 ID,让客户端再做局部请求/动作
7.3 “全量 Client 化”会抵消 RSC 的收益
如果你在顶层写了 "use client",那么它的所有子组件都会变成客户端树的一部分(在很多情况下意味着更大 bundle)。
这会让你回到“SSR + 大 hydration”的老问题。
原则再强调一次:
交互下沉,容器上浮到 Server。
8. SSR + RSC 的最佳实践:用“分段渲染”改善体验
当页面里有不同耗时的数据块:
- 用户信息很快
- 推荐列表很慢
- 评论区更慢
你不应该让“最慢的一块”拖住整个页面。
更好的体验是:
- 先把骨架与快数据流式输出
- 慢数据块用边界包起来,晚点再流式补齐
- 客户端交互组件只在需要处 hydrate
你可以把它理解为:
把“首屏体验”拆成多个可独立完成的块,而不是一次性全成全败。
9. 选型与落地:什么时候值得上 RSC 思路?
你可以用一个非常实用的判断:
更适合 RSC/Server-first 的场景
- 内容型页面、列表型页面居多(交互占比不高)
- 强依赖数据读取与权限判断
- 希望减少客户端 JS(移动端/弱网/低端设备友好)
- 页面结构复杂但交互点集中在少量组件
Client-heavy 的场景需要谨慎
- 大量富交互(编辑器、画布、拖拽、复杂图表)
- 几乎每个区域都有状态与事件 这类场景 RSC 依然能用,但收益可能没有想象中大,拆分成本更高。
10. 本文小结:把“模式”看清,比记名词更重要
你需要带走的不是一堆缩写,而是一套判断框架:
- SSR/SSG/ISR 决定:HTML 什么时候生成
- Hydration 决定:页面什么时候能交互(JS 体积与树规模是关键)
- RSC 决定:哪些组件根本不需要发到客户端(减少 JS 与 hydration 压力)
- 在 Next.js 里:默认 Server,遇到交互才
"use client" - 拆分原则:交互下沉、容器上浮、数据在服务端、UI 分段流式
留言讨论
Discussion
欢迎交流与反馈