首页  |  学习总览  |  ← 返回专题总览 进阶专题 02 · 渲染架构

SSR 与同构渲染

首屏为什么慢?SEO 为什么差?—— SSR 不是银弹,但它是大前端必备的架构武器
学习时长:约 12 小时 | 前置:阶段 02 / 03 / 05 | 产出:SSR 电商页 + 性能对比报告

一、四种渲染模式:一张表看懂

模式渲染位置首屏SEO交互适用
CSR 客户端渲染浏览器 JS慢(先空白后渲染)快(无需水合)后台系统、工具类
SSR 服务端渲染服务器(每次请求)快(直接返回 HTML)需水合电商、内容站、实时数据页
SSG 静态生成构建时最快(CDN 缓存)需水合博客、文档、营销页
ISR 增量静态再生构建时 + 后台定时快(缓存 + 过期重建)需水合商品详情、新闻列表
记忆口诀: CS 慢但省心 / SS 快但费服务器 / SSG 最快但数据旧 / ISR 折中:旧数据先出,后台刷新。

二、SSR 原理:从请求到可交互(全流程)

一次 SSR 请求的完整链路

1
浏览器请求 /product/1001
2
Node 服务器接收请求,执行 React/Vue 组件(在服务端渲染成 HTML 字符串
3
组件内 fetch 数据(在服务端完成,用户无感知)
4
服务器返回完整 HTML(含内容,可被爬虫解析),并把数据序列化为 window.__INITIAL_STATE__
5
浏览器收到 HTML 立即显示(首屏已可读)
6
浏览器下载 JS → 水合 Hydration:把事件监听绑定到已有 DOM 上
7
页面变为完全可交互 → 后续导航走 CSR(客户端路由)
核心理解:SSR 产出的 HTML 不是"空壳",而是带内容的真页面;水合是"给静态 HTML 装上大脑",不是重新渲染。

三、水合(Hydration)详解

水合的本质:服务端渲染的 HTML 与客户端渲染结果必须一致,React 才能"接管"这棵 DOM。

React 19 中:

const root = hydrateRoot(document.getElementById('root'), <App />);
// 注意:是 hydrateRoot 而不是 createRoot!

水合时 React 会对比服务端 HTML 与客户端组件输出,发现不一致会警告(Hydration failed)并整棵重渲染该分支,导致性能回退。

水合不匹配的常见元凶:使用了 Date.now()Math.random()、浏览器 API(window/localStorage)、不稳定的 ID 生成。解决:延迟到客户端再执行(useEffect 里取)或用确定性数据。

四、Next.js 15 App Router 实战详解

App Router 的核心:文件系统路由 + 组件级渲染策略

// app/products/page.tsx —— 服务端组件:数据在服务端拿,HTML 直接输出
export default async function ProductsPage() {
  const res = await fetch('https://api.example.com/products', { next: { revalidate: 60 } });
  const products = await res.json();
  return (
    <div>
      {products.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}
// 'use client' 组件:只有交互部分才需要 JS
'use client';
export function AddToCart({ id }: { id: number }) {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>加入购物车({count})</button>;
}
RSC(React Server Components)核心价值:把"读取数据 + 渲染大组件树"的代码留在服务端,客户端只下载交互部分的 JS —— 这就是"同构"的现代形态:一份代码,两种运行环境。

五、同构代码的 5 个要点(必踩坑区)

  1. 环境判断typeof window === 'undefined' 区分服务端/客户端,SSR 时不能碰 document
  2. 数据预取:服务端拿到的数据要通过 window.__INITIAL_STATE__ 传给客户端,避免客户端二次请求。
  3. Node 特有 API 隔离fs/process.env 只在服务端用,打包时做 webpack 区分(server bundle / client bundle)。
  4. CSS 与图片:样式在 SSR 时内联注入,图片用绝对 URL(CDN)。
  5. 内存与流式:服务端渲染长页面用 streaming(Next.js 的 Suspense 流式渲染),避免长 TTFB 与内存峰值。

六、案例解析:电商列表页 CSR → SSR 改造(首屏 +42%)

背景

一个商品列表页(PC 端),CSR 架构。用户反馈"打开一片白,等 3 秒才见内容";SEO 完全拿不到商品内容,自然流量接近零。

诊断(用 Performance 面板看关键指标)

改造方案(Next.js App Router)

  1. 列表页改为服务端组件,数据在服务端 fetch(revalidate: 60 走 ISR,60 秒内直接吐缓存)
  2. 分页交互部分(排序/筛选)拆成 'use client' 小组件,挂到服务端渲染的静态列表上
  3. 图片全部 next/image(自动懒加载 + CDN 尺寸裁剪)
  4. 页头加 loading.tsx 骨架屏流式输出

结果与复盘

七、坑清单(一线浓缩)

坑 1:水合不匹配 —— 服务端与客户端渲染结果不一致(时间/随机数/浏览器 API),React 报 Hydration failed 并整树重渲染。解法:不一致的部分延迟到 useEffectsuppressHydrationWarning
坑 2:服务端 fetch 没加缓存 —— SSR 每请求都打后端,DB 压力爆炸。解法:ISR/缓存层/数据层 LRU。
坑 3:把大组件库塞进客户端 JS —— 一个 'use client' 引入 lodash/moment,整包进 bundle。解法:Server Component 优先,动态 import。
坑 4:内存泄漏 —— 服务端每请求创建全局单例/连接不释放,进程内存持续上涨。解法:请求级作用域、连接池、压测观察。
坑 5:SSR 与 CDN 缓存打架 —— 用户 A 看到旧数据,B 看到新数据。解法:缓存键要含用户维度/版本号。
坑 6:首屏"假快" —— 服务端 HTML 出来了但 JS 还没下载,页面不可点(TTI 差)。解法:关键交互组件流式输出 + 优先水合(selective hydration)。
坑 7:Node 版本与部署 —— 本地好好的上生产白屏。解法:Docker 镜像锁定 Node 版本,SSR 服务做健康检查与多实例。
坑 8:重复请求 —— 服务端 fetch 一次 + 客户端水合再 fetch 一次。解法:__INITIAL_STATE__ 透传数据,客户端跳过。

八、实战项目:把博客改造成 SSR + ISR

要求(用 Next.js 或 Nuxt 3 任选)

  1. 文章列表页服务端渲染,标题/摘要/封面在 HTML 里可见(爬虫可读)
  2. 文章详情用 ISR(revalidate: 3600),新增文章 1 小时内生效
  3. 阅读计数用 'use client' 小组件实现(不与 SSR HTML 冲突)
  4. 文章内容来自 Markdown 文件或 mock API

通关标准

九、通关自检题

Q1:CSR 与 SSR 的首屏差异本质是什么?
展开答案
CSR 首屏要等 HTML 空壳 + JS 下载执行 + 数据请求才能出内容;SSR 首屏 HTML 自带内容,浏览器直接绘制,JS 只负责"唤醒"交互。
Q2:水合(Hydration)是什么?为什么可能失败?
展开答案
把事件监听绑定到服务端生成的 DOM 上,让页面可交互。失败 = 服务端 HTML 与客户端组件输出不一致(时间/随机数/浏览器 API),React 重渲染该分支。
Q3:什么时候选 SSG / ISR / SSR?
展开答案
内容几乎不变 → SSG(构建一次);变但可容忍延迟 → ISR(缓存 + 定时重建);强实时(登录态/个性化/实时价)→ SSR。
Q4:Server Component 为什么能减包?
展开答案
它只在服务端运行,读取数据、渲染大组件树的代码不打包进客户端 JS;客户端只下载 'use client' 的交互部分,因此 bundle 大幅减小。
Q5:SSR 项目上线前要压什么?
展开答案
TTFB(服务端渲染耗时)、内存泄漏(压测长跑)、并发峰值(CPU 打满会拖垮其他服务)、CDN 缓存命中率。