React SSR 与流式渲染
React SSR 服务端渲染与流式渲染:原理、水合与实战
引言
服务端渲染(SSR)曾是 React 应用提升首屏体验的「标准答案」,而 React 18 的流式渲染(Streaming SSR)与 Suspense 集成又为这一领域带来了新范式。但 SSR 不是银弹——它引入的水合(Hydration)成本、双端状态一致性、TTI 延迟等问题,都需要架构师清晰权衡。本文将从「为什么需要 SSR」讲起,逐步拆解 React SSR 的完整链路、流式渲染的实现原理、水合机制与优化策略,最后给出适合中小团队的落地清单。
一、SSR 解决什么问题
客户端渲染(CSR)下,用户要经历「下载 JS → 解析执行 → 构建 DOM」三个阶段才能看到内容;SSR 则由服务端直接输出完整的 HTML,浏览器首帧即为真实内容。
CSR 与 SSR 的对比
flowchart TB
classDef a1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef a2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef a3 fill:#e0f7fa,stroke:#00838f,color:#004d40
classDef a4 fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
subgraph CSR[客户端渲染]
direction LR
C1[请求 HTML 壳] --> C2[下载 JS Bundle]
C2 --> C3[解析执行 React]
C3 --> C4[渲染 DOM 首屏]
end
subgraph SSR[服务端渲染]
direction LR
S1[请求 URL] --> S2[服务端执行组件]
S2 --> S3[输出完整 HTML]
S3 --> S4[首屏立即可见]
S4 --> S5[加载 JS 后水合激活]
end
click C4 "https://react.dev/learn/client-side-rendering" "CSR 说明"
click S3 "https://react.dev/learn/server-side-rendering" "SSR 说明"
class CSR a1
class C1 a2
class C2 a3
class C3 a3
class C4 a4
class SSR a1
class S1 a2
class S2 a3
class S3 a4
class S4 a4
class S5 a3
SSR 的核心收益与代价
| 维度 | SSR 收益 | SSR 代价 |
|---|---|---|
| 首屏内容 | FCP 显著提前,HTML 直达 | 服务端需处理每个请求的渲染 |
| SEO | 爬虫可直接抓取完整内容 | 需关注服务端数据请求兜底 |
| 架构 | 前后端同构,共享代码 | 双端环境差异(window/document) |
| 交互 | — | 水合前页面不可交互(TTI 延迟) |
二、React SSR 的完整链路
一次 SSR 请求的旅程可以拆解为六步:路由匹配、数据预取、渲染输出、HTML 返回、JS 加载、水合激活。
全链路时序
sequenceDiagram
participant U as 浏览器
participant N as Node 服务端
participant D as 数据源
participant R as React 渲染
participant H as 客户端水合
U->>N: 发起页面请求
N->>N: 匹配路由与 loader
N->>D: 并行预取页面数据
D-->>N: 返回数据
N->>R: renderToString / renderToPipeableStream
R-->>N: 生成 HTML 字符串/流
N-->>U: 返回完整 HTML + 内联数据
U->>U: 首屏立即渲染(静态)
U->>N: 下载并执行 JS Bundle
U->>H: hydrateRoot 激活事件与状态
H->>U: 页面可交互(TTI)
服务端渲染输出流程
flowchart TB
classDef b1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef b2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef b3 fill:#e0f7fa,stroke:#00838f,color:#004d40
A[请求进入] --> B{是否命中缓存}
B -->|命中| C[返回缓存 HTML]
B -->|未命中| D[执行路由 loader]
D --> E{服务端数据是否就绪}
E -->|是| F[React 渲染组件树]
E -->|否| G[等待或降级为空壳]
F --> H[输出 HTML]
H --> I[注入 hydration 数据]
I --> J[设置缓存与响应头]
J --> K[返回响应]
click D "https://reactrouter.com/start/data/loaders" "路由 loader 数据加载"
class A b1
class B b1
class C b3
class D b2
class E b2
class F b2
class G b3
class H b1
class I b2
class J b1
class K b1
三、水合(Hydration)机制详解
SSR 输出的 HTML 是「静态的骨架」——DOM 存在、内容可见,但没有任何事件监听。水合就是在客户端「复活」这些静态节点的过程:React 在 hydrateRoot 调用时遍历 DOM 树,为每个节点挂载 Fiber 结构、绑定事件处理器。
水合的匹配规则
flowchart TD
classDef c1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef c2 fill:#ffebee,stroke:#c62828,color:#b71c1c
classDef c3 fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
A[hydrateRoot 开始] --> B{服务端 HTML 与客户端树是否一致}
B -->|一致| C[复用已有 DOM 节点]
C --> D[挂载事件监听]
D --> E[水合完成 页面可交互]
B -->|不一致| F[React 警告 mismatch]
F --> G{差异类型}
G -->|文本/属性| H[客户端覆盖重建]
G -->|树结构| I[整体重建该子树]
G -->|时间相关| J[日期/随机数需对齐]
click A "https://react.dev/reference/react-dom/client/hydrateRoot" "hydrateRoot 文档"
class A c1
class B c1
class C c3
class D c3
class E c3
class F c2
class G c2
class H c3
class I c2
class J c3
水合不一致的典型来源
- 随机值/时间:
Date.now()、Math.random()在双端不同; - 浏览器 API 判断:
typeof window !== 'undefined'分支渲染; - localStorage 读取:服务端无此 API;
- 第三方库双端行为差异:如日期格式化时区。
解决方案:使用 useSyncExternalStore 或自定义 hook 延迟到水合后再渲染差异内容(isHydrated 标志),保证首屏一致,水合后补充。
四、流式渲染:Suspense 驱动的渐进式交付
React 18 引入 renderToPipeableStream,让服务端可以分块输出 HTML。结合 Suspense,页面中可以标记「依赖异步数据的区域」——这些区域先用 fallback(占位内容)输出,数据就绪后再以流的形式补发真实内容,浏览器端自动替换。
流式渲染的时序优势
flowchart TB
classDef d1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef d2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef d3 fill:#e0f7fa,stroke:#00838f,color:#004d40
subgraph 传统同步 SSR
direction LR
T1[等待全部数据] --> T2[一次性输出完整 HTML]
T2 --> T3[首字节出现晚]
end
subgraph 流式 SSR
direction LR
S1[首屏无依赖部分立即输出] --> S2[慢数据区域先输出 fallback]
S2 --> S3[数据就绪后流式补发]
S3 --> S4[首字节极快 TTFB 短]
end
click S3 "https://react.dev/reference/react-dom/server/renderToPipeableStream" "renderToPipeableStream 文档"
class 传统同步SSR d2
class T1 d2
class T2 d2
class T3 d2
class 流式SSR d1
class S1 d3
class S2 d3
class S3 d3
class S4 d3
流式渲染的工作方式
sequenceDiagram
participant B as 浏览器
participant S as 服务端
participant A as 慢速 API
B->>S: 请求页面
S->>S: renderToPipeableStream 开始
S-->>B: 立即输出 header + 静态部分
S->>A: 并行发起慢速数据请求
S-->>B: 输出 Suspense fallback 占位
A-->>S: 数据返回
S-->>B: 流式补发真实内容(script 指令)
B->>B: 浏览器替换占位内容
B->>B: 继续接收后续 chunk
关键收益:TTFB 从「等待全部数据」缩短到「首屏无依赖部分」,首屏 FCP 大幅提前;配合 onShellReady 等回调可精细控制流何时开始与结束。
五、数据预取与状态对齐
SSR 应用最棘手的问题是双端数据一致:服务端渲染用的数据,客户端水合时必须拿到同一份,否则水合不匹配。
双端数据传递的两种模式
flowchart LR
classDef e1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef e2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef e3 fill:#e0f7fa,stroke:#00838f,color:#004d40
subgraph 内联模式
direction LR
A1[服务端渲染] --> A2[序列化数据到 window.__DATA__]
A2 --> A3[客户端水合时读取]
end
subgraph 重取模式
direction LR
B1[客户端重新发起请求] --> B2[获取相同数据]
B2 --> B3[水合后刷新]
end
subgraph 缓存对齐
direction LR
C1[Query 缓存服务端填充] --> C2[客户端预热同一缓存]
C2 --> C3[避免重复请求]
end
click A2 "https://react.dev/learn/server-and-client-components" "服务端与客户端组件"
class 内联模式 e1
class A1 e1
class A2 e1
class A3 e1
class 重取模式 e2
class B1 e2
class B2 e2
class B3 e2
class 缓存对齐 e3
class C1 e3
class C2 e3
class C3 e3
推荐做法:使用 TanStack Query 的 prefetchQuery + dehydrate/hydrate——服务端把 Query 缓存序列化注入 HTML,客户端用同一份缓存初始化,水合后不重复请求、数据天然一致。
六、SSR 性能优化实战
优化清单
flowchart TD
classDef g1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef g2 fill:#fff3e0,stroke:#f57c00,color:#e65100
classDef g3 fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
A[SSR 性能优化] --> B[渲染层]
B --> B1[Suspense 拆分慢区域]
B --> B2[流式输出 提前 TTFB]
B --> B3[服务端 memo 减少重复渲染]
A --> C[数据层]
C --> C1[并行数据预取]
C --> C2[边缘缓存页面 HTML]
C --> C3[缓存优先 回源兜底]
A --> D[交付层]
D --> D1[仅发送水合必需 JS]
D --> D2[路由级代码分割]
D --> D3[图片懒加载 字体预加载]
click B2 "https://nextjs.org/docs/app/building-your-application/rendering/streaming-and-suspense" "Next.js 流式渲染"
class A g1
class B g1
class B1 g3
class B2 g3
class B3 g2
class C g1
class C1 g3
class C2 g3
class C3 g2
class D g1
class D1 g3
class D2 g3
class D3 g2
常见陷阱与对策
| 陷阱 | 表现 | 对策 |
|---|---|---|
| 服务端触发浏览器 API | 渲染崩溃 | typeof window 守卫 + 动态导入 |
| 数据请求重复 | 水合后二次请求 | 缓存预取对齐 |
| 大 Bundle 阻塞水合 | TTI 推迟 | 代码分割 + 流式交付 |
| 水合不匹配警告 | 交互异常 | 修复双端差异源 |
| 缓存与个性化冲突 | 用户信息串台 | 缓存键按用户维度隔离 |
七、总结
React SSR 不是「用服务端渲染取代客户端渲染」,而是在正确的位置做正确的渲染:
- SSR 核心价值:FCP 提前 + SEO 友好 + 同构架构;
- 流式渲染:Suspense +
renderToPipeableStream实现渐进式交付,TTFB 大幅缩短; - 水合是关键成本:DOM 激活、事件绑定、双端一致,决定 TTI;
- 数据对齐:Query 缓存预取 + 脱水/注水是标准解法;
- 缓存策略:页面级缓存 + 边缘缓存,让 SSR 成本可承受。
实践建议:中小团队优先考虑 TanStack Start、Next.js 等框架内置的 SSR 能力,把精力放在业务与数据层,而非手写渲染管线。下一篇我们将深入 TanStack Start 的数据加载与路由设计。
