浏览器篇 | SSR流式渲染
前言
你做了一个 React 单页应用(SPA),功能完美,交互流畅。但上线后发现两个问题:一是首屏白屏时间太长——用户打开页面,盯着空白看了好几秒才看到内容;二是 SEO 效果很差——搜索引擎爬虫抓到的 HTML 里只有一个空的 <div id="root"></div>。
这两个问题的根源都在于:SPA 是客户端渲染(CSR)——HTML 只是一个空壳,所有内容都靠浏览器执行 JavaScript 后才能渲染出来。
于是你开始研究 SSR(服务端渲染)。进一步了解后发现,SSR 也在不断进化——从传统的”整页渲染完再发送”,到 React 18 引入的流式渲染(Streaming SSR)和选择性注水(Selective Hydration),再到 Next.js、Remix 等框架把这些能力封装成开箱即用的方案。
面试中,SSR 相关的问题已经越来越常见,尤其是在中高级岗位。面试官可能会问:
- CSR、SSR、SSG 有什么区别?各自适合什么场景?
- 传统 SSR 有什么问题?流式渲染解决了什么?
- 什么是”注水”(Hydration)?选择性注水又是什么?
- Next.js 的 App Router 和 Pages Router 在 SSR 上有什么区别?
本章我们就从渲染模式的演进出发,逐一拆解这些概念。
诊断自测
Q1:CSR、SSR、SSG 分别是什么?用一句话概括各自的特点。
点击查看答案
- CSR(Client-Side Rendering):HTML 是空壳,内容在浏览器端通过 JS 渲染。首屏慢、SEO 差,但交互体验好。
- SSR(Server-Side Rendering):服务器在请求时生成完整的 HTML 返回。首屏快、SEO 友好,但服务器压力大。
- SSG(Static Site Generation):在构建时预先生成静态 HTML。速度最快、可 CDN 缓存,但不适合频繁变化的数据。
Q2:什么是”注水”(Hydration)?为什么 SSR 页面需要注水?
点击查看答案
注水(Hydration)是指浏览器收到服务端渲染的 HTML 后,React(或其他框架)在客户端重新执行一遍组件逻辑,把事件监听器绑定到已有的 DOM 上,让静态 HTML 变成可交互的应用。SSR 返回的 HTML 只是”看起来有内容”,但没有任何交互能力(按钮点不动、表单提交不了)。注水就是给这些”静态骨架”注入”活力”的过程。
Q3:传统 SSR 的主要瓶颈是什么?流式渲染如何解决这个问题?
点击查看答案
传统 SSR 的瓶颈是全部或者无(All-or-Nothing):服务器必须等整个页面的数据都准备好、所有组件都渲染完毕后,才能发送 HTML 给浏览器。如果某个组件依赖一个慢接口(比如 3 秒才返回),整个页面都要等 3 秒。流式渲染打破了这个限制——先把已经准备好的部分发送给浏览器(用户可以先看到),慢的部分用 <Suspense> 包裹,等数据就绪后再”流式”追加到页面中。
一、CSR vs SSR vs SSG:三种渲染模式对比
1.1 CSR(Client-Side Rendering)
传统 SPA 使用的渲染方式。服务器只返回一个几乎空白的 HTML:
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="/bundle.js"></script>
</body>
</html>
浏览器下载并执行 bundle.js,JS 代码发起数据请求、构建 DOM、渲染页面。
整个流程:
1. 浏览器请求 HTML → 收到空壳
2. 下载 JS bundle → 等待...
3. 执行 JS → 等待...
4. JS 发起 API 请求 → 等待...
5. 拿到数据、渲染 DOM → 用户终于看到内容
用户在第 5 步之前看到的都是白屏(或 loading)。
优势:
- 开发简单,前后端完全分离
- 页面切换体验好(无刷新)
- 服务器压力小(只返回静态文件)
劣势:
- 首屏白屏时间长
- SEO 差(爬虫看到的是空 HTML)
- JS bundle 大时影响体验
1.2 SSR(Server-Side Rendering)
服务器在收到请求时执行 React 组件,生成完整的 HTML 返回:
<!DOCTYPE html>
<html>
<body>
<div id="root">
<h1>Hello, Alice!</h1>
<p>Welcome to the dashboard.</p>
<!-- 完整的页面内容 -->
</div>
<script src="/bundle.js"></script>
</body>
</html>
整个流程:
1. 浏览器请求 HTML → 服务器执行组件、获取数据、生成 HTML
2. 收到完整 HTML → 用户看到内容(FCP 快)
3. 下载 JS bundle → 等待...
4. Hydration(注水) → 页面变得可交互(TTI)
用户在第 2 步就能看到内容,但要等到第 4 步才能交互。
优势:
- 首屏快(FCP 早)
- SEO 友好
- 适合内容密集型页面
劣势:
- 服务器压力大(每个请求都要渲染)
- TTFB(Time To First Byte)可能慢(服务器需要时间渲染)
- 注水前页面不可交互,可能造成”可看不可用”的尴尬
1.3 SSG(Static Site Generation)
在构建时(build time)就生成好所有页面的静态 HTML。部署时直接放 CDN 即可。
优势:
- 速度最快(CDN 直出,无需服务器渲染)
- SEO 极好
- 可靠性高(纯静态,不怕服务器挂)
劣势:
- 数据变化后需要重新构建
- 不适合高度个性化的页面(每个用户看到不同内容)
- 页面数量极大时,构建时间可能很长
1.4 选择策略
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 博客、文档、营销页 | SSG | 内容变化少,CDN 直出最快 |
| 电商商品页、新闻详情 | SSR(或 ISR) | SEO 重要,数据较实时 |
| 后台管理系统 | CSR | 不需要 SEO,交互复杂 |
| 仪表盘 + 公开首页 | 混合模式 | 首页 SSR/SSG + 后台 CSR |
现代框架(如 Next.js)允许你在同一个项目中混合使用多种渲染模式——某些页面 SSG,某些页面 SSR,某些组件 CSR。
二、SSR 的优势与劣势深入
2.1 SSR 的核心优势
更快的首屏内容展示(FCP): 用户不需要等 JS 下载和执行就能看到完整的页面内容。对于网络较慢的设备(低端手机、弱网环境),这个差异尤其明显。
更好的 SEO: 搜索引擎爬虫(尤其是非 Google 的爬虫)不一定会执行 JavaScript。SSR 返回的是完整的 HTML,爬虫可以直接解析内容。
更好的社交媒体分享: 微信、Twitter、Facebook 等平台在抓取链接预览时,通常只读取 HTML 中的 meta 标签。SSR 可以根据页面内容动态设置 <meta> 标签。
2.2 传统 SSR 的瓶颈
传统 SSR 有一个严重的问题——一切都是”全部或者无”的:
- 数据获取:必须在服务端获取所有数据后才能开始渲染
- HTML 生成:必须渲染完整的 HTML 后才能发送给浏览器
- JS 加载:必须下载所有 JS 代码后才能开始注水
- 注水:必须对整个页面完成注水后才能交互
传统 SSR 的瀑布流:
服务端:[等数据A 3s][等数据B 1s][渲染HTML 0.5s]
│ 发送 HTML
浏览器: [下载JS 1s][注水 0.5s] → 可交互
│
用户看到内容
(等了 4.5s!)
如果数据 A 需要 3 秒,整个页面就要等 3 秒才能开始发送——即使数据 B 和其他不依赖数据的部分早就准备好了。
三、流式渲染(Streaming SSR)
3.1 核心思想
流式渲染的核心思想是:不等所有内容都准备好,先把准备好的部分发给浏览器,剩下的后续追加。
流式 SSR:
服务端:[准备好头部+骨架]→ 立即发送
[等数据A 3s]→ 追加发送
浏览器:[收到头部+骨架] → 用户看到骨架
[收到数据A的HTML] → 用户看到完整内容
浏览器在收到第一块 HTML 后就可以开始渲染,用户几乎立刻就能看到页面的”骨架”,等慢数据就绪后,对应区域的内容会自动填充进去。
3.2 React 18 的 renderToPipeableStream
React 18 引入了 renderToPipeableStream API 来支持流式渲染(取代了之前的 renderToString):
// server.js
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
app.get('/', (req, res) => {
const { pipe, abort } = renderToPipeableStream(
<App />,
{
bootstrapScripts: ['/bundle.js'],
onShellReady() {
// Shell(Suspense 边界以外的内容)准备好了
// 可以开始流式发送
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(error) {
// Shell 渲染失败,返回错误页面
res.statusCode = 500;
res.send('<!DOCTYPE html><html><body><p>Something went wrong</p></body></html>');
},
onAllReady() {
// 所有内容(包括 Suspense 内的)都准备好了
// 如果你想做 SSG(crawlers),可以在这里发送
},
onError(error) {
console.error(error);
}
}
);
// 超时处理
setTimeout(() => abort(), 10000);
});
两个关键回调:
onShellReady:Suspense 边界以外的内容渲染完毕时触发。这是大多数情况下开始pipe的时机——用户先看到页面骨架onAllReady:所有内容(包括 Suspense 内的异步部分)都渲染完毕时触发。适用于爬虫场景(爬虫需要完整内容)
3.3 Suspense + Streaming 的协作
流式渲染的能力需要配合 <Suspense> 组件使用:
function App() {
return (
<html>
<body>
<Header />
<Suspense fallback={<Spinner />}>
<MainContent /> {/* 依赖一个慢接口 */}
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* 依赖另一个接口 */}
</Suspense>
<Footer />
</body>
</html>
);
}
流式渲染的过程:
- 服务端先渲染
<Header />和<Footer />(不依赖异步数据) - 遇到
<Suspense>边界,先输出 fallback(<Spinner />和<SidebarSkeleton />) onShellReady触发,开始发送 HTML 给浏览器- 浏览器收到 HTML 后立即渲染——用户看到头部、底部和两个 loading 状态
Sidebar的数据先返回 → 服务端渲染<Sidebar />,通过流追加一段<script>标签,让浏览器用真实内容替换<SidebarSkeleton />MainContent的数据后返回 → 同理替换<Spinner />
关键机制: 服务端追加的内容不是直接替换 DOM,而是通过内联的 <script> 标签,调用 React 的运行时代码来执行替换。这个过程对用户来说是无缝的。
四、选择性注水(Selective Hydration)
4.1 传统注水的问题
在 React 18 之前,注水是同步且一次性的。浏览器必须下载完所有 JS 代码,然后一口气把整个页面注水完毕后,页面才能交互。如果页面很大,注水过程可能需要好几秒——在这期间用户点什么都没反应。
4.2 选择性注水解决了什么
React 18 引入了选择性注水(Selective Hydration),带来两个关键改进:
1. 不需要等所有 JS 加载完才开始注水
配合 React.lazy 和 <Suspense>,不同组件的代码可以独立加载。某个组件的代码加载完了,React 就可以先注水它,不需要等其他组件。
const Comments = React.lazy(() => import('./Comments'));
const Sidebar = React.lazy(() => import('./Sidebar'));
function App() {
return (
<>
<Header />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</>
);
}
如果 Sidebar 的代码先加载完,React 会先对 Sidebar 进行注水,不等 Comments。
2. 用户交互可以打断注水顺序
如果 React 正在注水 Sidebar,但用户点击了 Comments 区域——React 会暂停 Sidebar 的注水,优先注水 Comments,让用户的交互尽快得到响应。这就是”选择性”的含义——根据用户的行为动态调整注水的优先级。
传统注水:
[====== 注水整个页面 ======] → 才能交互
选择性注水:
[Header 注水][Comments 注水][Sidebar 注水] → 各自独立
↑
用户点击了这里?优先注水这里!
五、现代框架的 SSR 方案对比
5.1 Next.js
Next.js 是 React 生态中最主流的 SSR 框架。它经历了从 Pages Router 到 App Router 的重大演进。
Pages Router(传统方式):
// pages/posts/[id].js
export async function getServerSideProps({ params }) {
const post = await fetchPost(params.id);
return { props: { post } };
}
export default function Post({ post }) {
return <article>{post.content}</article>;
}
getServerSideProps:每次请求时在服务端执行,获取数据后传给组件getStaticProps+getStaticPaths:构建时生成静态页面(SSG)- 数据获取和渲染在页面级别,不能细粒度控制
App Router(React Server Components):
// app/posts/[id]/page.js
async function Post({ params }) {
const post = await fetchPost(params.id); // 直接在组件中获取数据
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<Suspense fallback={<Spinner />}>
<Comments postId={params.id} />
</Suspense>
</article>
);
}
export default Post;
- Server Components:默认在服务端渲染,不发送到客户端,减少 JS bundle 大小
- Client Components:用
'use client'标记的组件在客户端渲染,支持交互 - 天然支持流式渲染和 Suspense
- 数据获取在组件级别,粒度更细
5.2 Remix
Remix 的哲学和 Next.js 有所不同——它更贴近 Web 标准(HTML form、HTTP caching 等)。
// app/routes/posts.$id.tsx
export async function loader({ params }) {
const post = await fetchPost(params.id);
return json({ post });
}
export default function Post() {
const { post } = useLoaderData();
return (
<article>
<h1>{post.title}</h1>
<Await resolve={post.comments} fallback={<Spinner />}>
{(comments) => <CommentList comments={comments} />}
</Await>
</article>
);
}
- Loader:在服务端获取数据(类似 Next.js 的 getServerSideProps)
defer+<Await>:支持流式渲染,可以先返回部分数据,慢数据后续流式补充- 嵌套路由:父路由和子路由可以并行获取数据,而不是瀑布式
- 渐进增强:即使 JS 加载失败,表单提交等核心功能仍然可用
5.3 对比总结
| 特性 | Next.js (App Router) | Remix |
|---|---|---|
| 服务端组件 | React Server Components | Loader + 传统组件 |
| 流式渲染 | Suspense + 自动流式 | defer + Await |
| 数据获取 | 组件内直接 async/await | loader 函数 |
| 代码分割 | 自动 + Server/Client 分离 | 自动(基于路由) |
| 渲染模式 | SSR / SSG / ISR / CSR 灵活切换 | 主要 SSR(也支持 SSG) |
| 设计哲学 | React 最新特性先行 | 拥抱 Web 标准 |
六、一些实际的选型建议
选择渲染方案时,不要盲目追求”最新最先进”,而要根据实际情况决定:
什么时候不需要 SSR?
- 纯内部工具/后台管理系统
- 不需要 SEO 的应用
- 用户都在高速网络和现代设备上
什么时候需要 SSR?
- 面向公众的内容型网站(SEO 重要)
- 首屏性能是核心指标
- 需要支持社交媒体分享预览
什么时候需要流式渲染?
- 页面由多个数据源组成,部分数据获取较慢
- 希望用户尽早看到页面骨架
- 已经在使用 React 18+ 或支持流式的框架
常见误区
误区一:“SSR 就是把 React 组件在服务端跑一遍”
技术上没错,但这只是 SSR 最基础的部分。完整的 SSR 还包括数据预获取、HTML 流式发送、客户端注水、状态同步等一系列复杂的环节。光把组件在服务端渲染成 HTML 字符串不难,难的是让这个 HTML 在到达浏览器后能无缝”复活”成可交互的应用。
误区二:“SSR 页面一定比 CSR 快”
不一定。SSR 的首屏内容展示(FCP)通常更快,但 TTFB(首字节时间)可能更慢——因为服务器需要时间获取数据和渲染 HTML。如果服务端的数据获取很慢(比如依赖多个外部 API),SSR 的 TTFB 可能比 CSR 还差。流式渲染就是为了解决这个问题——先发快的部分,慢的后续补上。
误区三:“注水就是重新渲染一遍”
注水(Hydration)不是重新渲染。React 在注水时不会重新创建 DOM——它会复用服务端已经生成的 DOM 节点,只是在这些节点上附加事件监听器和 React 的内部状态。如果注水时发现客户端渲染的结果和服务端的 HTML 不一致(hydration mismatch),React 会发出警告,并可能强制重新渲染——这会导致性能下降。
误区四:“用了 Server Components 就不需要客户端 JS 了”
Server Components 确实不会把自身的代码发送到客户端,但它们只能处理不需要交互的部分。任何需要用户交互的功能(点击按钮、输入表单、状态管理)仍然需要 Client Components,这些组件的 JS 代码仍然会被发送到浏览器。Server Components 减少的是那些纯展示、不需要交互的组件的 JS 开销。
小结
本章我们从渲染模式的对比出发,深入探讨了 SSR 的演进——从传统的全量渲染到流式渲染和选择性注水。
核心要点
- CSR 首屏慢、SEO 差但开发简单;SSR 首屏快、SEO 好但服务器压力大;SSG 最快但不适合动态内容
- 传统 SSR 的瓶颈是”全部或者无”——必须等所有数据就绪才能发送 HTML
- 流式渲染打破了这个限制:先发准备好的部分,慢数据后续追加
- React 18 的
renderToPipeableStream+<Suspense>是流式 SSR 的核心 API - 选择性注水允许不同组件独立注水,用户交互可以打断注水顺序
- Next.js App Router 引入了 Server Components,天然支持流式渲染
- Remix 通过
defer+<Await>实现流式渲染,哲学更贴近 Web 标准 - 选型不要盲目——根据 SEO 需求、首屏性能要求、数据实时性来决定
本章思维导图
- 三种渲染模式
- CSR:浏览器渲染,首屏慢,SEO 差
- SSR:服务端渲染,首屏快,SEO 好
- SSG:构建时生成,最快,适合静态内容
- 传统 SSR 的问题
- 数据获取:等所有数据就绪
- HTML 生成:等整页渲染完毕
- 注水:等所有 JS 加载 + 整页注水
- "全部或者无"的瀑布流
- 流式渲染(Streaming SSR)
- 核心思想:先发快的,慢的后续追加
- React 18: renderToPipeableStream
- onShellReady vs onAllReady
- Suspense 标记异步边界 + fallback
- 选择性注水(Selective Hydration)
- 不等所有 JS 加载完才注水
- 用户交互打断注水顺序
- React.lazy + Suspense 配合
- 现代框架方案
- Next.js
- Pages Router: getServerSideProps / getStaticProps
- App Router: Server Components + Suspense
- Remix
- loader + defer + Await
- 嵌套路由并行数据获取
- 渐进增强
- Next.js
- 选型建议
- 不需要 SEO → CSR
- 内容型网站 → SSR / SSG
- 多数据源慢接口 → 流式渲染
练习挑战
第一题 ⭐(基础):选择渲染模式
为以下场景选择最合适的渲染模式(CSR / SSR / SSG),并说明理由。
- 企业官网(内容半年更新一次)
- 电商商品详情页(SEO 重要,价格和库存实时变化)
- 公司内部的项目管理工具
- 个人技术博客
点击查看答案
- 企业官网 → SSG。内容变化极少,构建时生成静态 HTML,通过 CDN 分发,速度最快、SEO 最好、运维成本最低。
- 电商商品详情页 → SSR(或 ISR/混合方案)。SEO 重要,需要搜索引擎收录;商品基本信息可以 SSR/SSG,价格和库存等实时数据可以在客户端动态更新。Next.js 的 ISR(Incremental Static Regeneration)是个很好的选择——大部分时间用缓存的静态页面,定期后台更新。
- 内部项目管理工具 → CSR。不需要 SEO(内部工具不需要被搜索引擎收录),交互复杂(看板拖拽、实时协作等),CSR 开发最简单且交互体验最好。
- 个人技术博客 → SSG。文章发布后内容不变,SSG 生成静态页面最合适。用 Astro、Next.js 的静态导出或 Hugo 等工具都行。
第二题 ⭐⭐(进阶):分析流式渲染的好处
下面是一个 Next.js App Router 的页面组件。假设 fetchPost 需要 200ms,fetchComments 需要 3 秒。请分析:传统 SSR 和流式 SSR 下,用户分别在什么时间点看到内容?
// app/posts/[id]/page.js
import { Suspense } from 'react';
async function PostContent({ id }) {
const post = await fetchPost(id);
return <article>{post.content}</article>;
}
async function Comments({ postId }) {
const comments = await fetchComments(postId);
return (
<ul>
{comments.map(c => <li key={c.id}>{c.text}</li>)}
</ul>
);
}
export default function PostPage({ params }) {
return (
<div>
<Header />
<Suspense fallback={<p>Loading post...</p>}>
<PostContent id={params.id} />
</Suspense>
<Suspense fallback={<p>Loading comments...</p>}>
<Comments postId={params.id} />
</Suspense>
<Footer />
</div>
);
}
点击查看答案
传统 SSR:
- 服务端等待
fetchPost(200ms)和fetchComments(3s)都完成后,才开始发送 HTML - 用户在 ~3s 后一次性看到完整页面(文章 + 评论)
- 在这 3 秒内,用户看到的是白屏
流式 SSR:
- 服务端先渲染
<Header />、<Footer />和两个 Suspense 的 fallback - ~0ms:
onShellReady触发,开始发送 HTML。用户几乎立刻看到页面骨架(Header + “Loading post…” + “Loading comments…” + Footer) - ~200ms:
fetchPost完成,服务端流式发送<PostContent />的 HTML,替换 “Loading post…”。用户看到文章内容 - ~3s:
fetchComments完成,服务端流式发送<Comments />的 HTML,替换 “Loading comments…”。用户看到评论
流式渲染的关键好处:用户在 200ms 后就能看到文章主体内容,不需要等 3 秒的评论接口。页面是渐进式加载的,用户体验好得多。
第三题 ⭐⭐⭐(综合):解释 Hydration Mismatch
以下代码在 SSR 时会产生 hydration mismatch 警告。找出原因并给出修复方案。
function Greeting() {
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : 'Good afternoon';
return <h1>{greeting}, {Math.random().toFixed(4)}</h1>;
}
点击查看答案
问题原因:
Hydration mismatch 发生在服务端渲染的 HTML 和客户端注水时渲染的结果不一致时。这段代码有两个问题:
new Date().getHours():服务端和客户端的时间可能不同(时区差异、网络传输延迟),导致greeting不一致Math.random():每次调用结果不同,服务端和客户端几乎不可能生成相同的随机数
修复方案:
'use client';
import { useState, useEffect } from 'react';
function Greeting() {
const [greeting, setGreeting] = useState('Hello');
const [randomNum, setRandomNum] = useState('');
useEffect(() => {
// useEffect 只在客户端执行,不会影响服务端渲染
const hour = new Date().getHours();
setGreeting(hour < 12 ? 'Good morning' : 'Good afternoon');
setRandomNum(Math.random().toFixed(4));
}, []);
return <h1>{greeting}, {randomNum}</h1>;
}
或者使用 suppressHydrationWarning(不推荐,只是隐藏警告):
return <h1 suppressHydrationWarning>{greeting}, {Math.random().toFixed(4)}</h1>;
核心原则: 任何在服务端和客户端可能产生不同结果的逻辑(时间、随机数、浏览器 API、用户信息等),都应该放在 useEffect 中,或者使用 suppressHydrationWarning 明确标注。更好的做法是让这些内容作为 Client Component 渲染,服务端只渲染确定性的内容。
自我检测
- 能清楚说出 CSR、SSR、SSG 的区别、各自的优缺点和适用场景
- 能解释”注水(Hydration)“的概念——不是重新渲染,而是在已有 DOM 上附加事件和状态
- 能描述传统 SSR 的”全部或者无”瓶颈
- 能解释流式渲染如何解决这个瓶颈——Suspense + fallback + 渐进式发送
- 能说出
renderToPipeableStream的onShellReady和onAllReady的区别 - 能解释选择性注水的两个关键改进:独立加载注水 + 用户交互优先
- 能对比 Next.js(App Router vs Pages Router)和 Remix 的 SSR 方案
- 能说出什么是 Hydration Mismatch,以及如何避免
- 能根据实际场景(SEO 需求、数据实时性、交互复杂度)选择合适的渲染模式
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90