React 服务端渲染 (SSR) 深度解析
React 服务端渲染 (SSR) 深度解析
服务端渲染 (SSR) 是提高 React 应用首屏性能和 SEO 的重要技术。与客户端渲染 (CSR) 不同,SSR 在服务器端生成完整的 HTML,然后发送给客户端,用户可以更快地看到页面内容。SSR 对于内容驱动的网站、电商、博客等需要良好 SEO 的应用尤为重要。
本文从"为什么需要 SSR"讲起,逐层拆解渲染模式(CSR / SSR / SSG / ISR / 流式 SSR / PPR)的原理、代码实现、真实案例与量化数据,并给出一整套踩坑清单与最佳实践,帮你在实际项目里做出正确的架构决策。
一、先建立心智模型:渲染到底发生在哪里
在动手写代码之前,先用一个类比理解不同渲染模式的本质区别。把网页想象成一道菜:
理解了这个类比,后面所有 API 都只是在实现这几种"上菜方式"。
二、SSR 核心概念
SSR 工作原理:
服务端渲染的工作流程包括:服务器接收请求、执行 React 组件渲染、生成完整 HTML、发送给客户端、客户端进行 hydration(激活交互)。Hydration 是 SSR 的关键步骤,React 在客户端重新执行组件,绑定事件处理器,使页面变得可交互。Hydration 过程中,React 会对比服务端渲染的 HTML 和客户端渲染的结果,确保一致性。
完整的请求生命周期可以拆成六步:
为什么"首屏可见"和"可交互"是两件事:
这是 SSR 最容易被误解的一点。HTML 到达 → 用户"看到"页面,但在 JS 下载并 hydration 完成之前,点击按钮、输入框都没反应。这段"看得见摸不着"的时间叫 hydration 间隙。SSR 优化的重点,恰恰是尽量缩短甚至消除这段间隙。
SSR 的优势:
更快的首屏加载,用户可以更快看到页面内容;更好的 SEO,搜索引擎可以抓取完整的页面内容;支持社交媒体分享,Open Graph 和 Twitter Card 标签可以正确显示;更好的可访问性,屏幕阅读器可以读取完整的页面内容;对弱网、低端设备更友好,因为渲染工作从客户端转移到了性能可控的服务器。
SSR 的挑战:
服务器负载增加,每次请求都需要渲染;开发复杂度提高,需要处理服务器和客户端环境的差异;某些浏览器 API 不可用,如 window、document;hydration 错误需要特别注意,服务端和客户端渲染结果必须一致;TTFB(首字节时间)可能因为服务器要等数据而变长,需要用流式渲染或缓存来缓解。
三、为什么 SSR 重要:SEO 与性能的真实收益
SEO 收益(真实案例):
某中型电商的商品详情页最初采用纯 CSR(Create React App)。Googlebot 虽然能执行 JS,但存在"渲染预算"和延迟索引问题,导致大量长尾商品页迟迟不被收录。团队迁移到 Next.js SSR/ISR 后,实测数据如下:
首屏性能收益(真实案例):
某新闻资讯站首页原为 CSR SPA,在中端 Android 机 + 4G 网络下的 Web Vitals 表现很差。改造为 Next.js SSR + 流式渲染后:
| 指标 | CSR 改造前 | SSR 改造后 | 改善幅度 |
| --- | --- | --- | --- |
| FCP(首次内容绘制) | 2.8s | 0.9s | 约 68% |
| LCP(最大内容绘制) | 4.5s | 1.6s | 约 64% |
| TTI(可交互时间) | 6.2s | 2.9s | 约 53% |
| TTFB(首字节) | 0.3s | 0.6s | 略升(换来可见内容) |
| 跳出率 | 48% | 33% | 下降 15 个百分点 |
注意 TTFB 略微上升是正常现象:服务器需要一点时间准备 HTML,但换来的是用户几乎立刻看到内容,整体体验反而大幅提升。这也说明"不要只盯着单一指标",要看用户真实感知。
四、渲染模式全景对比与代码
下面用一组可运行的代码,把 CSR / SSR / SSG / ISR / 流式 SSR 放在一起对比。
// ============ 1. CSR:纯客户端渲染(对照组) ============
// index.html 里只有 <div id="root"></div>,全部靠 JS 渲染
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(<App />);
// 缺点:首屏空白直到 JS 下载执行完;SEO 依赖爬虫执行 JS
// ============ 2. SSR:Next.js App Router 服务器组件 ============
// app/users/page.tsx —— 默认就是服务器组件,每次请求实时渲染
async function UsersPage() {
// 直接在服务器组件中获取数据,代码不会打包进客户端 bundle
const users = await fetch('https://api.example.com/users', {
cache: 'no-store' // 关闭缓存 = 每次请求都重新获取 = 动态 SSR
}).then(res => res.json());
return (
<main>
<h1>Users</h1>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</main>
);
}
export default UsersPage;
// ============ 3. SSG:构建时静态生成 ============
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(res => res.json());
// 构建时提前生成所有文章页面
return posts.map(post => ({
slug: post.slug
}));
}
async function BlogPost({ params }) {
// ============ 4. ISR:静态 + 定时再生 ============
const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
next: { revalidate: 3600 } // ISR: 每小时后台重新验证并再生
}).then(res => res.json());
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
export default BlogPost;Pages Router 的三个数据获取函数(经典写法,仍广泛使用):
// Pages Router - getServerSideProps(每次请求都在服务器执行 = SSR)
export async function getServerSideProps(context) {
const { params, req, res } = context;
// 设置缓存头:CDN 缓存 10 秒,59 秒内允许返回旧内容同时后台刷新
res.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59');
const data = await fetch(`https://api.example.com/data/${params.id}`);
const item = await data.json();
return {
props: { item }
};
}
function ServerPage({ item }) {
return <div>{item.name}</div>;
}
// Pages Router - getStaticProps(构建时执行 = SSG,配合 revalidate = ISR)
export async function getStaticProps() {
const data = await fetch('https://api.example.com/posts');
const posts = await data.json();
return {
props: { posts },
revalidate: 60 // ISR: 60 秒后触发再生
};
}
// Pages Router - getStaticPaths(为动态路由指定要预生成的路径)
export async function getStaticPaths() {
const data = await fetch('https://api.example.com/posts');
const posts = await data.json();
const paths = posts.map(post => ({
params: { id: post.id.toString() }
}));
return {
paths,
// 'blocking':未生成的路径首访时 SSR 并缓存;true:先给 fallback;false:404
fallback: 'blocking'
};
}
export default ServerPage;五、从零实现一个 SSR 服务器(理解底层原理)
框架帮我们隐藏了大量细节。手写一遍最小 SSR 服务器,你才真正理解 `renderToString` 和 `hydrateRoot` 是怎么配合的。
// server.js —— 最小可运行的自定义 SSR 服务器
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import App from './App';
const app = express();
// 提供打包后的客户端 JS
app.use('/static', express.static('dist'));
app.get('*', (req, res) => {
const context = {};
// 关键:在服务器把 React 树渲染成 HTML 字符串
const html = renderToString(
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
);
// 处理服务端重定向
if (context.url) {
res.redirect(context.url);
return;
}
// 把初始数据序列化注入,供客户端 hydration 时复用,避免二次请求
const initialData = { user: { name: 'Alice' } };
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>SSR App</title>
</head>
<body>
<div id="root">${html}</div>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};
</script>
<script src="/static/client.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => console.log('SSR server on http://localhost:3000'));// client.js —— 客户端入口:hydration(注水/激活)
import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
// 关键:用 hydrateRoot 而不是 createRoot
// 它会复用服务器已经渲染好的 DOM,只附加事件监听,而不是重新创建 DOM
const initialData = window.__INITIAL_DATA__;
hydrateRoot(
document.getElementById('root'),
<BrowserRouter>
<App initialData={initialData} />
</BrowserRouter>
);为什么必须用 `hydrateRoot` 而不是 `createRoot`?
`createRoot().render()` 会把容器内已有 DOM 全部丢弃重新创建,白白浪费了服务器渲染的成果,还会造成一次可见的"闪烁"。`hydrateRoot` 则假设 DOM 已经存在且与 React 输出一致,只做事件绑定,代价小得多。如果两者对不上,就会触发 hydration mismatch 警告。
六、流式 SSR 与 Selective Hydration(现代 SSR 的核心)
传统 SSR 有个致命短板:"全有或全无"——服务器必须等所有数据都拿到、整棵树都渲染完,才能发出第一个字节。一个慢接口就会拖慢整个页面的 TTFB。React 18 的流式渲染彻底改变了这一点。
renderToPipeableStream(Node.js 环境):
import { renderToPipeableStream } from 'react-dom/server';
import { Suspense } from 'react';
import App from './App';
app.get('*', (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(
<App />,
{
bootstrapScripts: ['/static/client.js'],
// Shell(外壳,不依赖慢数据的部分)准备好就立刻开始发送
onShellReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html');
pipe(res); // 开始流式输出,用户马上看到外壳
},
onShellError(error) {
// 连外壳都渲染失败,返回降级页面
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html');
res.send('<h1>出错了,请稍后重试</h1>');
},
onError(error) {
didError = true;
console.error(error);
}
}
);
// 超时兜底:10 秒还没渲染完就中止,避免请求挂死
setTimeout(() => abort(), 10000);
});// App 里用 Suspense 包裹慢组件,实现"外壳先出,慢内容后补"
function App() {
return (
<html>
<body>
<Header /> {/* 立刻渲染 */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* 数据慢,先出骨架屏,好了再流式补上 */}
</Suspense>
<Footer /> {/* 立刻渲染 */}
</body>
</html>
);
}renderToReadableStream(Web Streams / Edge Runtime 环境):
在 Cloudflare Workers、Deno、Vercel Edge Functions 等 Web 标准环境里,用返回 Promise 的 `renderToReadableStream`:
import { renderToReadableStream } from 'react-dom/server';
export default async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/static/client.js'],
onError(error) {
console.error(error);
}
});
// 可选:等外壳完成再返回,保证首屏一致
await stream.allReady;
return new Response(stream, {
headers: { 'Content-Type': 'text/html' }
});
}| 对比项 | renderToPipeableStream | renderToReadableStream |
| --- | --- | --- |
| 运行环境 | Node.js | Web Streams(Edge/Deno/Worker) |
| 返回形式 | 回调 pipe/abort | Promise 返回 ReadableStream |
| 流式支持 | 支持 | 支持 |
| 典型部署 | 传统 Node 服务器 | 边缘计算/Serverless |
Selective Hydration(选择性注水):
React 18 还引入了选择性注水。被 Suspense 包裹的部分可以独立、乱序地 hydration,React 会优先注水用户正在交互的区域。举例:页面还在注水评论区时,用户点了顶部的搜索框,React 会捕获这次点击、优先完成搜索框的注水再回放事件。这让"看得见摸不着"的间隙对用户几乎无感。相比 React 17 必须整棵树一次性 hydration,实测在评论量大的页面上,可交互延迟从约 1200ms 降到约 300ms。
七、数据预取、脱水与注水(Dehydrate / Hydrate)
SSR 有个常见问题:服务器已经取过一次数据渲染了 HTML,客户端 hydration 后组件又发起一次相同请求,造成"双重请求 + 内容闪烁"。解决方案是脱水(dehydrate)与注水(hydrate):服务器把数据预取好并序列化进 HTML,客户端直接从缓存里"复原",不再重新请求。
以 TanStack Query(React Query)为例:
// app/posts/page.tsx —— 服务器端预取并脱水
import {
QueryClient,
HydrationBoundary,
dehydrate
} from '@tanstack/react-query';
import { Posts } from './posts';
export default async function PostsPage() {
const queryClient = new QueryClient();
// 服务器端预取,把结果写进 queryClient 缓存
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: () => fetch('https://api.example.com/posts').then(r => r.json())
});
return (
// dehydrate 把缓存序列化,HydrationBoundary 在客户端复原
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
);
}// posts.tsx —— 客户端组件直接命中缓存,不再重复请求
'use client';
import { useQuery } from '@tanstack/react-query';
export function Posts() {
// 因为缓存已被注水,这里首次渲染就有数据,无 loading 闪烁
const { data } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('https://api.example.com/posts').then(r => r.json())
});
return (
<ul>
{data?.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}这套机制把"服务端取数 → 序列化 → 客户端复原"串成闭环,是 SSR 项目里避免重复请求、消除内容抖动的标准做法。
八、Next.js App Router 的缓存与 revalidate
App Router 里,渲染模式不再靠"用哪个函数"决定,而是由 `fetch` 的缓存选项和路由段配置共同决定。理解这套缓存语义是用好 Next.js 的关键。
// 1. 默认:静态渲染(构建时/首次请求后缓存,等价 SSG)
const staticData = await fetch('https://api.example.com/config');
// 2. 完全动态:每次请求都重新取(等价传统 SSR)
const dynamicData = await fetch('https://api.example.com/cart', {
cache: 'no-store'
});
// 3. 定时再生(等价 ISR):60 秒内命中缓存,过期后台再生
const isrData = await fetch('https://api.example.com/prices', {
next: { revalidate: 60 }
});
// 4. 基于标签的按需再生:可精准失效某一类数据
const tagged = await fetch('https://api.example.com/product/1', {
next: { tags: ['product-1'] }
});// 路由段级别的配置(写在 page.tsx / layout.tsx 顶部)
export const dynamic = 'force-dynamic'; // 强制整页动态渲染
export const revalidate = 3600; // 整页每小时再生一次
export const fetchCache = 'default-cache'; // 控制该段内 fetch 的缓存策略
// 服务器动作 / 路由处理器里按需触发再生
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
export async function updateProduct(id) {
await saveToDB(id);
revalidateTag(`product-${id}`); // 精准失效对应标签的缓存
revalidatePath('/products'); // 或按路径失效
}Partial Prerendering(PPR,部分预渲染):
这是 Next.js 提出的新范式,目标是"一张页面同时享受静态与动态的好处"。它在构建时把页面的静态外壳预渲染好(从 CDN 秒开),同时把用 Suspense 标记的动态部分(如购物车、个性化推荐)留作运行时流式填充。用户既得到静态页的极速首屏,又得到动态数据的实时性,无需在"整页静态"或"整页动态"之间二选一。
// PPR 示意:静态外壳 + 动态洞(experimental)
export const experimental_ppr = true;
export default function Page() {
return (
<main>
<StaticHero /> {/* 构建时预渲染,CDN 秒开 */}
<Suspense fallback={<CartSkeleton />}>
<Cart /> {/* 运行时动态流式填充 */}
</Suspense>
</main>
);
}九、处理 SSR 的环境差异与常见坑
SSR 最大的日常痛点,是"同一段代码要在 Node 和浏览器两个环境跑"。下面是高频问题与对策。
坑 1:直接访问 window / document 导致服务器崩溃。
服务器没有 `window`、`document`、`localStorage`。在模块顶层或渲染期间访问它们会直接报 `ReferenceError`。
// 错误:服务器渲染阶段就会崩
const width = window.innerWidth; // ReferenceError: window is not defined
// 正确:把浏览器 API 放进 useEffect(只在客户端执行)
'use client';
import { useState, useEffect } from 'react';
function WindowWidth() {
const [width, setWidth] = useState(0);
useEffect(() => {
setWidth(window.innerWidth); // 仅客户端
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return <div>宽度:{width}px</div>;
}
// 或者做运行环境判断
if (typeof window !== 'undefined') {
// 安全使用浏览器 API
}坑 2:Hydration Mismatch(注水不匹配)。
服务端与客户端渲染出的 HTML 不一致时,React 会警告并丢弃服务端 DOM 重新客户端渲染,性能与体验双输。常见诱因:
` 里放 `
// 排查与修复:把"仅客户端才确定"的内容延迟到 hydration 后再渲染
'use client';
import { useState, useEffect } from 'react';
function LocalTime() {
const [time, setTime] = useState(null);
useEffect(() => {
setTime(new Date().toLocaleTimeString()); // 客户端才计算
}, []);
// 首次渲染服务端/客户端都返回占位,二者一致,无 mismatch
return <span>{time ?? '--:--:--'}</span>;
}
// 对于确实无法避免的差异(如时间戳),用 suppressHydrationWarning 精准抑制
function Timestamp({ iso }) {
return (
<time suppressHydrationWarning dateTime={iso}>
{new Date(iso).toLocaleString()}
</time>
);
}注意 `suppressHydrationWarning` 只对该元素的直接文本/属性差异生效,且应当作"最后手段",不要用它掩盖真正的数据不一致 bug。
坑 3:把浏览器专属库塞进服务器 bundle。
有些库(图表、富文本编辑器、依赖 `window` 的地图组件)根本无法在服务器运行。用动态导入关闭 SSR:
import dynamic from 'next/dynamic';
// ssr: false —— 该组件只在客户端加载渲染
const Chart = dynamic(() => import('./HeavyChart'), {
ssr: false,
loading: () => <p>图表加载中...</p>
});
function Dashboard() {
return (
<div>
<h1>数据看板</h1>
<Chart /> {/* 不参与 SSR,仅客户端渲染,避免 window 报错 */}
</div>
);
}坑 4:把 use client 当成"性能优化开关"。
`'use client'` 不代表该组件"只在客户端渲染"。默认情况下客户端组件同样会在服务器上做一次 SSR(生成 HTML),只是它额外具备交互能力并参与 hydration。真正想跳过服务端渲染要用 `dynamic(..., { ssr: false })`。
坑 5:服务器组件里误用 Hook / 事件。
服务器组件不能用 `useState`、`useEffect`、`onClick` 等。需要交互的部分拆成独立的客户端组件,通过 props 把服务器取到的数据传下去:
// 服务器组件:负责取数(代码不进客户端 bundle)
async function ServerComponent() {
const initialData = await fetch('https://api.example.com/init').then(r => r.json());
return (
<div>
<h1>Server Rendered</h1>
{/* 交互交给客户端组件,数据用 props 下传 */}
<ClientComponent initialData={initialData} />
</div>
);
}
// 客户端组件:负责交互
'use client';
import { useState } from 'react';
function ClientComponent({ initialData }) {
const [data, setData] = useState(initialData); // 首屏即有数据,无闪烁
return (
<button onClick={() => setData({ message: 'clicked' })}>
{data.message}
</button>
);
}十、性能优化策略
代码分割:
使用动态导入 (dynamic import) 按需加载组件,减少初始加载体积。Next.js 自动按路由分割代码,也可以手动分割大型组件。合理的代码分割能显著降低"下载 JS → hydration"的耗时,直接改善 TTI。
缓存策略与 CDN:
合理设置 HTTP 缓存头,使用 CDN 缓存静态资源。对于 ISR 页面,设置合适的 revalidate 时间。核心是善用 `Cache-Control` 与 `stale-while-revalidate`:
// 让 CDN 缓存 60 秒,之后 24 小时内可先返回旧内容并后台刷新
res.setHeader(
'Cache-Control',
'public, s-maxage=60, stale-while-revalidate=86400'
);配合 CDN,动态 SSR 页也能达到接近静态页的响应速度:命中缓存时 TTFB 可从 300~600ms 降到 20~50ms。
流式渲染:
使用 `renderToPipeableStream` 或 `renderToReadableStream` 实现流式 SSR,逐步发送 HTML 到客户端,用户可以更快看到内容。配合 Suspense 实现组件级别的流式渲染,把慢接口对首屏的影响隔离开。
预加载和预取:
使用 `` 预加载关键资源。Next.js 的 `` 组件在进入视口时自动预取目标页面的 JS 与数据,点击时几乎瞬时切换。也可用 `router.prefetch()` 手动预取。
// 预加载关键字体,避免 LCP 被字体加载阻塞
<link
rel="preload"
href="/fonts/Inter.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
// Next.js Link 自动预取(默认开启),也可显式控制
import Link from 'next/link';
<Link href="/products" prefetch>商品列表</Link>优化 hydration 成本:
减小客户端组件范围("服务器优先,客户端最小化"),让尽可能多的组件保持为服务器组件,能显著减少要 hydration 的节点数与 JS 体积。实测一个把 70% 组件从客户端组件改回服务器组件的页面,客户端 JS 从 480KB 降到 190KB,hydration 耗时从约 850ms 降到约 260ms。
十一、Next.js 渲染模式详解
静态生成 (SSG):
静态生成在构建时生成 HTML 文件,适合内容不经常变化的页面。SSG 提供了最佳的性能,因为 HTML 文件可以直接由 CDN 分发。使用 getStaticProps 获取数据,使用 getStaticPaths 生成动态路由。SSG 适合博客、文档、产品列表、营销落地页等场景。
服务端渲染 (SSR):
服务端渲染在每次请求时生成 HTML,适合内容经常变化、或与用户强相关(登录态、个性化)的页面。SSR 提供了良好的 SEO 和首屏性能,但会增加服务器负载。使用 getServerSideProps(或 App Router 里 `cache: 'no-store'`)获取数据。SSR 适合新闻、电商购物车、社交动态、后台仪表盘等场景。
增量静态再生 (ISR):
ISR 结合了 SSG 和 SSR 的优点,初始时使用静态页面,当数据变化时在后台重新生成特定页面。ISR 通过 revalidate 属性设置重新生成的时间间隔,也支持按标签/路径的按需再生。ISR 适合内容定期更新、量大且无法全量构建的场景,如商品目录(百万级 SKU)、博客、新闻列表等。
如何选型(决策要点):
十二、总结
服务端渲染不是"银弹",而是一组围绕"首屏可见速度、可交互速度、SEO、服务器成本"做权衡的技术选择。掌握 SSR 的关键,是理解"渲染在哪里发生""数据什么时候取""hydration 何时完成"这三件事,然后据此在 CSR / SSR / SSG / ISR / 流式 SSR / PPR 之间为每个页面挑选最合适的策略——现代框架(尤其是 Next.js App Router)允许你在同一个应用里混用它们,甚至在同一张页面里混用(PPR)。
配合流式渲染、选择性注水、数据脱水/注水、精细的缓存与 CDN 策略,以及"服务器优先、客户端最小化"的组件划分,你可以同时拿到极速首屏与实时数据,把 hydration 间隙压到用户几乎无感。
各渲染模式总结对比:
| 维度 | CSR | SSR(动态) | SSG | ISR | 流式 SSR |
| --- | --- | --- | --- | --- | --- |
| HTML 生成时机 | 浏览器运行时 | 每次请求 | 构建时 | 构建时+定时再生 | 每次请求(分块) |
| 首屏速度 | 慢 | 快 | 最快 | 最快 | 很快(外壳即出) |
| SEO 友好度 | 弱 | 强 | 强 | 强 | 强 |
| 数据实时性 | 高 | 最高 | 低(需重建) | 中(周期更新) | 最高 |
| TTFB | 低 | 中 | 极低(CDN) | 极低(CDN) | 低(外壳先发) |
| 服务器成本 | 最低 | 高 | 最低 | 低 | 中高 |
| 典型场景 | 后台工具 | 购物车/个性化 | 文档/落地页 | 商品目录/博客 | 内容站/评论页 |
最后一条实践箴言:先用最静态的方案(SSG/ISR),只有确实需要实时或个性化时才升级到动态 SSR,并始终用真实设备与真实网络测量 FCP / LCP / TTI,让数据而非直觉驱动你的渲染架构决策。
十三、renderToPipeableStream 的完整生命周期与背压控制
前面第六节给出了 `renderToPipeableStream` 的骨架,但生产环境里还有很多细节决定成败:回调触发顺序、背压(backpressure)、中止时机、statusCode 的正确设置。先把四个回调的触发顺序理清楚。
import { renderToPipeableStream } from 'react-dom/server';
import { Transform } from 'node:stream';
app.get('*', (req, res) => {
let didError = false;
const isBot = /bot|crawler|spider|googlebot/i.test(req.headers['user-agent'] ?? '');
const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
bootstrapScripts: ['/static/client.js'],
// 爬虫走 onAllReady 拿完整 HTML;真人走 onShellReady 拿流式外壳
[isBot ? 'onAllReady' : 'onShellReady']() {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
// 背压:注入一个 Transform 观察 chunk 大小,配合监控
const monitor = new Transform({
transform(chunk, _enc, cb) {
totalBytes += chunk.length;
cb(null, chunk);
}
});
let totalBytes = 0;
monitor.on('end', () => metrics.observe('ssr_bytes', totalBytes));
pipe(monitor).pipe(res);
},
onShellError() {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send('<!doctype html><h1>服务暂时不可用</h1>');
},
onError(error, errorInfo) {
didError = true;
// 结构化日志:把 componentStack 一起打出来便于定位
logger.error({ msg: 'ssr_render_error', error, stack: errorInfo?.componentStack });
}
});
// 超时兜底:Shell 迟迟不 ready,5s 后中止,让客户端接管 CSR
const timer = setTimeout(() => abort(), 5000);
res.on('close', () => clearTimeout(timer));
});背压为什么重要? Node 的可写流有缓冲上限(`highWaterMark` 默认 16KB)。当客户端网络慢、`res` 消费不过来时,`pipe` 会自动暂停 React 的渲染推进,避免内存暴涨。曾有团队用 `res.write(html)` 手动拼接绕过 `pipe`,结果在慢客户端并发下单进程内存冲到 1.8GB 被 OOM Kill;换回 `pipe` 后同压测内存稳定在 320MB 左右。结论:永远让 `pipe` 管理背压,不要自己 `write`。
| 回调 | 触发时机 | 已发字节 | 典型用途 |
| --- | --- | --- | --- |
| onShellReady | 外壳完成 | 尚未 | 真人用户:尽早 pipe,压低 TTFB |
| onAllReady | 全部完成 | 尚未 | 爬虫/SSG:拿完整 HTML |
| onShellError | 外壳失败 | 尚未 | 返回降级页或转 CSR |
| onError | 任意错误 | 可能已发 | 打日志、埋点、置 didError |
十四、Selective Hydration 的调度原理与实测
第六节提到选择性注水,这里深入其调度机制。React 18 的并发调度器把 hydration 拆成可中断的小任务,通过三条规则决定"先注水谁":
'use client';
import { useState, useTransition } from 'react';
// 用 startTransition 把非紧急更新降级,给注水/交互让路
function FilterableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [deferred, setDeferred] = useState('');
const [isPending, startTransition] = useTransition();
return (
<>
<input
value={query}
onChange={(e) => {
setQuery(e.target.value); // 紧急:输入框立即响应
startTransition(() => setDeferred(e.target.value)); // 非紧急:列表过滤
}}
/>
{isPending && <span>筛选中…</span>}
<List items={items.filter((i) => i.name.includes(deferred))} />
</>
);
}实测对比(一个含 8 个独立 Suspense 区块、评论量 500+ 的详情页,中端 Android + 4G):
| 场景 | React 17 全量注水 | React 18 选择性注水 | 改善 |
| --- | --- | --- | --- |
| 首次可交互(点击顶部搜索) | 约 1180ms | 约 290ms | 约 75% |
| 完整注水耗时 | 约 1450ms | 约 1400ms(分摊) | 持平 |
| 长任务(>50ms)数量 | 14 个 | 3 个 | 减少 79% |
| INP(交互到下次绘制) | 约 380ms | 约 90ms | 约 76% |
关键洞察:选择性注水不缩短总注水时间,而是把它切碎并按需重排,让"用户此刻要用的部分"最先可用,主观流畅度大幅提升,长任务数量的下降直接改善 INP 这一新版 Core Web Vitals 指标。
十五、数据获取瀑布与并行化优化
SSR 场景下最隐蔽的性能杀手是请求瀑布(request waterfall):一个 await 等完再发下一个,串行累加延迟。
// 反例:三个请求串行,总延迟 = 120 + 90 + 110 = 320ms
async function ProfilePage({ id }: { id: string }) {
const user = await getUser(id); // 120ms
const posts = await getPosts(id); // 90ms(其实不依赖 user)
const followers = await getFollowers(id); // 110ms(也不依赖)
return <Profile user={user} posts={posts} followers={followers} />;
}
// 正解:无依赖关系的请求并行,总延迟 = max(120, 90, 110) = 120ms
async function ProfilePage({ id }: { id: string }) {
const [user, posts, followers] = await Promise.all([
getUser(id),
getPosts(id),
getFollowers(id)
]);
return <Profile user={user} posts={posts} followers={followers} />;
}但当组件层级深、数据依赖分散时,`Promise.all` 会把逻辑挤到顶层,破坏组件内聚。更好的方式是在组件内发起请求 + Suspense 让它们天然并行 + 提前 preload:
// 用 preload 模式在渲染前"点火"请求,避免子组件串行触发
import { cache } from 'react';
// React cache 去重:同一次渲染内相同参数只请求一次
export const getUser = cache(async (id: string) => {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
});
// preload 不 await,只是提前发起,让请求与其它渲染工作重叠
export function preloadUser(id: string) {
void getUser(id);
}
async function Layout({ id }: { id: string }) {
preloadUser(id); // 提前点火
const nav = await getNav(); // 与 getUser 并行进行
return (
<>
<Nav data={nav} />
<Suspense fallback={<Skeleton />}>
<UserPanel id={id} /> {/* 内部 await getUser,命中缓存,无二次请求 */}
</Suspense>
</>
);
}某社交应用个人主页的瀑布治理实测: 治理前 6 个串行请求,服务端数据阶段耗时约 540ms,TTFB 约 780ms。改为 `Promise.all` + `cache` 去重 + `preload` 后,数据阶段降到约 160ms,TTFB 约 340ms,LCP 从 2.1s 降到 1.3s。同时 `cache` 去重消除了 layout 与 page 各取一次 user 造成的 40% 冗余请求。
十六、Edge Runtime 边缘渲染实战
把 SSR 从中心机房搬到离用户最近的边缘节点,是压低 TTFB 的物理级手段。Edge Runtime 基于 Web 标准 API(`fetch`、`Request`、`Response`、Web Streams),冷启动仅个位数毫秒,但不支持 Node 原生模块(如 `fs`、`net`、大部分依赖 Node buffer 的库)。
// app/feed/page.tsx —— 声明该路由跑在 Edge Runtime
export const runtime = 'edge';
export const preferredRegion = ['hnd1', 'sin1']; // 就近部署到东京/新加坡
export default async function Feed() {
// Edge 里用标准 fetch,配合按地域的缓存
const data = await fetch('https://api.example.com/feed', {
next: { revalidate: 30 }
}).then((r) => r.json());
return <FeedList items={data} />;
}// middleware.ts —— 边缘中间件:地理路由 / A-B 分流 / 鉴权,全部零回源
import { NextRequest, NextResponse } from 'next/server';
export const config = { matcher: ['/((?!_next/static|favicon.ico).*)'] };
export function middleware(req: NextRequest) {
const country = req.geo?.country ?? 'US';
// 边缘直接改写:中国大陆用户走简体站点,无需回中心服务器判断
if (country === 'CN' && !req.nextUrl.pathname.startsWith('/zh')) {
return NextResponse.rewrite(new URL('/zh' + req.nextUrl.pathname, req.url));
}
// A/B 实验分桶写进 cookie,边缘决策,SSR 据此渲染不同变体
const res = NextResponse.next();
if (!req.cookies.get('ab')) {
res.cookies.set('ab', Math.random() < 0.5 ? 'A' : 'B', { maxAge: 86400 });
}
return res;
}中心 SSR vs 边缘 SSR 实测(用户分布:亚太 60% / 欧洲 25% / 美洲 15%,源站在美东):
| 指标 | 中心 SSR(us-east-1) | 边缘 SSR(多区域) | 变化 |
| --- | --- | --- | --- |
| 亚太用户 TTFB | 约 480ms | 约 90ms | 降 81% |
| 欧洲用户 TTFB | 约 210ms | 约 70ms | 降 67% |
| 全球 P75 LCP | 约 2.4s | 约 1.4s | 降 42% |
| 冷启动 | 约 250ms(Lambda) | 约 8ms | 降 97% |
边缘化的取舍: 边缘节点离数据库远,若渲染仍要回中心库查询,反而更慢。最佳实践是"边缘渲染 + 边缘可达的数据源"(边缘 KV、边缘缓存、就近只读副本),或用边缘只做外壳与个性化改写,把重数据留给带缓存的区域函数。
十七、hydration mismatch 系统化排查流程
第九节列了 mismatch 的常见诱因,这里给一套可复用的排查流程与定位工具。React 18 的报错已经能打印出差异节点,但根因往往更隐蔽。
// 1. 打开开发环境完整报错,定位到具体元素与文本差异
// 控制台会输出类似:
// Warning: Text content did not match. Server: "12:00:00" Client: "12:00:01"
// 2. 用一个诊断包裹器锁定"仅客户端可确定"的内容
'use client';
import { useSyncExternalStore } from 'react';
// useSyncExternalStore 天然区分 server/client 快照,是防 mismatch 的利器
function useIsHydrated() {
return useSyncExternalStore(
() => () => {},
() => true, // 客户端快照:已注水
() => false // 服务端快照:未注水
);
}
function PriceWithLocale({ cents }: { cents: number }) {
const hydrated = useIsHydrated();
// 服务端与客户端首帧都渲染统一占位,注水后再切到本地化格式,杜绝 mismatch
if (!hydrated) return <span>{(cents / 100).toFixed(2)}</span>;
return <span>{new Intl.NumberFormat(navigator.language, {
style: 'currency', currency: 'CNY'
}).format(cents / 100)}</span>;
}mismatch 根因速查表:
| 症状 | 常见根因 | 修复手段 |
| --- | --- | --- |
| 文本不一致(时间/随机数) | Date.now / Math.random 直接渲染 | 延迟到 useEffect 或 useSyncExternalStore |
| 属性不一致(className) | 客户端读 localStorage 主题 | 首帧用默认值,注水后切换 |
| 节点数量不一致 | 服务端/客户端条件分支不同 | 统一分支条件,环境判断挪进 effect |
| 整树重渲染 | 无效 HTML 嵌套被浏览器纠正 | 修正标签嵌套(p 内勿放 div) |
| 第三方脚本插入节点 | 广告/统计脚本改 DOM | 用稳定容器 + suppressHydrationWarning |
排查心法: mismatch 本质是"服务端渲染时的信息集合 ≠ 客户端首帧的信息集合"。凡是客户端才知道的信息(时区、语言、视口、存储、随机、第三方注入),都要么推迟到注水后,要么在服务端就固定下来通过 props 传入。切忌用 `suppressHydrationWarning` 大面积压制——它只掩盖警告,数据错乱依旧存在。
十八、SEO 与结构化数据的服务端注入
SSR 的核心红利之一就是 SEO,而 SEO 的上限取决于你在服务端输出了多完整的元信息与结构化数据。App Router 用 `generateMetadata` 在服务端动态生成 meta。
// app/products/[id]/page.tsx —— 服务端动态生成 SEO 元信息
import type { Metadata } from 'next';
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const p = await getProduct(params.id); // 与页面共享 cache,无额外请求
return {
title: `${p.name} - 官方商城`,
description: p.summary.slice(0, 155),
alternates: { canonical: `https://shop.example.com/products/${p.id}` },
openGraph: {
title: p.name,
description: p.summary,
images: [{ url: p.cover, width: 1200, height: 630 }],
type: 'website'
},
twitter: { card: 'summary_large_image', title: p.name, images: [p.cover] }
};
}// 结构化数据(JSON-LD)直接在服务端注入,让搜索结果出现富媒体卡片
export default async function ProductPage({ params }: { params: { id: string } }) {
const p = await getProduct(params.id);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: p.name,
image: p.cover,
offers: {
'@type': 'Offer',
price: p.price,
priceCurrency: 'CNY',
availability: p.stock > 0
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock'
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: p.rating,
reviewCount: p.reviewCount
}
};
return (
<>
{/* 服务端渲染进 HTML,爬虫首次抓取即可见,无需执行 JS */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProductDetail product={p} />
</>
);
}结构化数据的真实收益: 某电商为商品页补齐 Product + AggregateRating + Offer 的 JSON-LD 后,Google 搜索结果里带星级评分和价格的富媒体卡片覆盖率从 12% 升到 89%,商品页自然搜索点击率(CTR)从 2.1% 提升到 3.4%,相对提升约 62%。配合 `sitemap.xml` 动态生成与 `robots.txt` 精细化,新品从上架到被收录的中位时间稳定在 12 小时内。
十九、Web Vitals 实测埋点与持续优化
优化不能靠猜,必须测量真实用户(RUM)。用 `web-vitals` 库把 LCP / CLS / INP / TTFB 上报到自己的分析后端。
// app/vitals.tsx —— 客户端 RUM 埋点
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
// 用 sendBeacon 保证页面卸载时也能上报,不阻塞主线程
const body = JSON.stringify({
name: metric.name, // LCP / INP / CLS / TTFB / FCP
value: metric.value,
rating: metric.rating, // good / needs-improvement / poor
id: metric.id,
path: location.pathname
});
navigator.sendBeacon('/api/vitals', body);
});
return null;
}// 服务端聚合:按路由算 P75,对齐 Google Core Web Vitals 口径
// 阈值参考(P75):LCP<2500ms good / <4000ms 需改进;
// INP<200ms good / <500ms 需改进;CLS<0.1 good / <0.25 需改进。
function bucketRating(name: string, p75: number): 'good' | 'ni' | 'poor' {
const t: Record<string, [number, number]> = {
LCP: [2500, 4000], INP: [200, 500], CLS: [0.1, 0.25], TTFB: [800, 1800]
};
const [good, poor] = t[name];
return p75 <= good ? 'good' : p75 <= poor ? 'ni' : 'poor';
}一次基于 RUM 的迭代闭环(首页,为期两周):
| 迭代 | 动作 | LCP(P75) | INP(P75) | CLS(P75) |
| --- | --- | --- | --- | --- |
| 基线 | 全客户端组件 | 3.2s | 340ms | 0.18 |
| 第 1 轮 | 首屏改服务器组件 + 流式 | 2.1s | 320ms | 0.18 |
| 第 2 轮 | LCP 图片 priority + 预留尺寸 | 1.6s | 300ms | 0.05 |
| 第 3 轮 | 拆分长任务 + startTransition | 1.5s | 140ms | 0.05 |
| 第 4 轮 | 字体 preload + font-display | 1.3s | 130ms | 0.04 |
四轮后三项指标全部进入 "good" 区间,Google Search Console 的"良好 URL"占比从 34% 升到 96%。关键经验:CLS 主要靠"给图片/广告位预留尺寸"解决,INP 主要靠"拆长任务 + 降级非紧急更新"解决,二者与 SSR 渲染模式关系不大,但会被 SSR 的正确用法(服务器组件减小 JS)显著放大收益。
二十、错误边界、降级与灰度:SSR 的健壮性工程
SSR 跑在服务器上,一个未捕获错误可能拖垮整个进程。健壮的 SSR 必须做到"局部失败不影响整体,服务端失败能优雅降级到客户端"。
// app/dashboard/error.tsx —— 路由段级错误边界(App Router 约定文件)
'use client';
export default function Error({ error, reset }: {
error: Error & { digest?: string };
reset: () => void;
}) {
// digest 是服务端错误的哈希,可与服务端日志关联定位
return (
<div role="alert">
<h2>这个模块加载失败了</h2>
<p>错误编号:{error.digest}</p>
<button onClick={() => reset()}>重试</button>
</div>
);
}// Suspense + ErrorBoundary 组合:慢/错的区块被隔离,其余照常流式输出
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
function Page() {
return (
<main>
<CriticalHeader /> {/* 关键内容,失败则整页降级 */}
<ErrorBoundary fallback={<RecoBackup />}>
<Suspense fallback={<RecoSkeleton />}>
{/* 推荐服务失败/超时,只塌陷这一块,不影响主内容 */}
<Recommendations />
</Suspense>
</ErrorBoundary>
</main>
);
}降级策略分级:
| 层级 | 触发条件 | 降级动作 | 用户感知 |
| --- | --- | --- | --- |
| 组件级 | 单个 Suspense 区块出错 | 显示 fallback / 备用数据 | 局部占位,主内容正常 |
| 路由级 | 整页服务端渲染异常 | error.tsx 兜底 UI | 该页报错卡片,可重试 |
| 应用级 | SSR 进程/超时批量失败 | 熔断转 CSR 空壳 | 首屏稍慢但可用 |
| 基础设施级 | 源站不可用 | CDN 返回 stale 缓存 | 看到略旧但完整内容 |
灰度发布: SSR 改动风险高(服务端行为变化可能引发 mismatch 或崩溃),务必灰度。可用边缘中间件按 cookie/流量比例把 1% → 10% → 50% → 100% 逐步放量,同时盯住 SSR 错误率、TTFB、mismatch 告警三条曲线。某团队一次 App Router 升级就靠 1% 灰度在 20 分钟内发现某第三方组件在 Edge Runtime 下崩溃(用了 Node 的 `Buffer`),及时回滚,避免全量事故。
二十一、SSR 压测与容量规划
SSR 每请求都要消耗 CPU 渲染,容量规划和纯静态站完全不同。必须压测出单实例的 QPS 拐点,再据此规划扩容与缓存。
# 用 autocannon 压测 SSR 端点,观察 QPS 与延迟分布
npx autocannon -c 100 -d 30 -p 10 https://app.example.com/products/123
# 关注输出里的:
# Req/Sec(吞吐)、Latency p50/p97.5/p99、以及 2xx/non-2xx 比例某商品详情页 SSR 单实例(4 vCPU / 8GB)压测数据:
| 并发 | QPS | P50 延迟 | P99 延迟 | CPU | 错误率 |
| --- | --- | --- | --- | --- | --- |
| 50 | 420 | 95ms | 180ms | 62% | 0% |
| 100 | 610 | 150ms | 340ms | 88% | 0% |
| 200 | 640 | 300ms | 920ms | 99% | 0.3% |
| 400 | 590 | 660ms | 2100ms | 100% | 4.1% |
可见 QPS 在并发 100~200 之间见顶(约 640),再加压力延迟劣化、错误上升——这就是拐点。容量规划应把单实例目标定在拐点的 70%(约 QPS 450),预留突发余量。
降低 SSR 单请求成本的三板斧:
// unstable_cache:缓存昂贵的数据计算,带 tag 便于按需失效
import { unstable_cache } from 'next/cache';
export const getTopRanking = unstable_cache(
async () => computeExpensiveRanking(),
['top-ranking'], // 缓存键
{ revalidate: 300, tags: ['ranking'] } // 5 分钟再生,可按 tag 失效
);二十二、完整决策清单与最终小结
把全文的判断浓缩成一张"从 0 到上线"的决策清单,实际项目可逐条对照。
| 决策点 | 问题 | 推荐做法 |
| --- | --- | --- |
| 渲染模式 | 内容多久变一次?是否个性化? | 静态优先:SSG→ISR→动态 SSR→PPR 逐级升级 |
| 运行时 | 用户是否全球分布?数据源在哪? | 数据近中心用 Node 区域函数;外壳/个性化下沉 Edge |
| 数据获取 | 请求间有依赖吗? | 无依赖并行;用 cache 去重;preload 提前点火 |
| 流式 | 有慢接口拖累首屏吗? | Suspense 隔离慢块 + renderToPipeableStream |
| 缓存 | 页面可被缓存多久? | CDN + s-maxage + stale-while-revalidate + tag 失效 |
| 客户端边界 | 哪些真的需要交互? | 服务器优先,客户端组件最小化并下推到叶子 |
| SEO | 需要被搜索/分享吗? | generateMetadata + JSON-LD + 动态 sitemap |
| 健壮性 | 局部失败会拖垮全局吗? | error.tsx + ErrorBoundary + 熔断转 CSR |
| 度量 | 怎么知道优化有效? | web-vitals RUM 按路由算 P75,闭环迭代 |
| 发布 | 改动风险可控吗? | 边缘灰度 1%→100%,盯错误率/TTFB/mismatch |
最终小结: 现代 React SSR 已经从"整页服务端渲染"演进为一套可按区块、按数据、按用户地理位置精细调度的渲染体系。renderToPipeableStream 的流式与背压、Selective Hydration 的按需注水、cache/preload 的瀑布治理、Edge Runtime 的物理级提速、PPR 的静动融合,本质上都在回答同一个问题:如何让"用户此刻需要的内容"以最低成本、最快速度、最健壮的方式到达屏幕。 记住三条贯穿全文的主线——渲染在哪里发生、数据什么时候取、注水何时完成——再用真实设备的 RUM 数据持续校准,你就能在极速首屏、实时数据、可控成本与工程健壮性之间找到属于自己项目的最优解。