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

水合不一致的典型来源

  1. 随机值/时间Date.now()Math.random() 在双端不同;
  2. 浏览器 API 判断typeof window !== 'undefined' 分支渲染;
  3. localStorage 读取:服务端无此 API;
  4. 第三方库双端行为差异:如日期格式化时区。

解决方案:使用 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 不是「用服务端渲染取代客户端渲染」,而是在正确的位置做正确的渲染

  1. SSR 核心价值:FCP 提前 + SEO 友好 + 同构架构;
  2. 流式渲染:Suspense + renderToPipeableStream 实现渐进式交付,TTFB 大幅缩短;
  3. 水合是关键成本:DOM 激活、事件绑定、双端一致,决定 TTI;
  4. 数据对齐:Query 缓存预取 + 脱水/注水是标准解法;
  5. 缓存策略:页面级缓存 + 边缘缓存,让 SSR 成本可承受。

实践建议:中小团队优先考虑 TanStack Start、Next.js 等框架内置的 SSR 能力,把精力放在业务与数据层,而非手写渲染管线。下一篇我们将深入 TanStack Start 的数据加载与路由设计。