SSR 与同构渲染
首屏为什么慢?SEO 为什么差?—— SSR 不是银弹,但它是大前端必备的架构武器
学习时长:约 12 小时 | 前置:阶段 02 / 03 / 05 | 产出:SSR 电商页 + 性能对比报告
一、四种渲染模式:一张表看懂
| 模式 | 渲染位置 | 首屏 | SEO | 交互 | 适用 |
| CSR 客户端渲染 | 浏览器 JS | 慢(先空白后渲染) | 差 | 快(无需水合) | 后台系统、工具类 |
| SSR 服务端渲染 | 服务器(每次请求) | 快(直接返回 HTML) | 好 | 需水合 | 电商、内容站、实时数据页 |
| SSG 静态生成 | 构建时 | 最快(CDN 缓存) | 好 | 需水合 | 博客、文档、营销页 |
| ISR 增量静态再生 | 构建时 + 后台定时 | 快(缓存 + 过期重建) | 好 | 需水合 | 商品详情、新闻列表 |
记忆口诀: CS 慢但省心 / SS 快但费服务器 / SSG 最快但数据旧 / ISR 折中:旧数据先出,后台刷新。
二、SSR 原理:从请求到可交互(全流程)
一次 SSR 请求的完整链路
2Node 服务器接收请求,执行 React/Vue 组件(在服务端渲染成 HTML 字符串)
3组件内 fetch 数据(在服务端完成,用户无感知)
4服务器返回完整 HTML(含内容,可被爬虫解析),并把数据序列化为 window.__INITIAL_STATE__
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/page.tsx —— 默认是 Server Component(服务端组件):可以直接 async + await fetch,不打包进客户端 JS!
'use client' —— 标记为客户端组件:需要 useState/useEffect/onClick 时才加
- 布局
layout.tsx 保持状态不重载;加载态 loading.tsx;错误边界 error.tsx
// 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 个要点(必踩坑区)
- 环境判断:
typeof window === 'undefined' 区分服务端/客户端,SSR 时不能碰 document。
- 数据预取:服务端拿到的数据要通过
window.__INITIAL_STATE__ 传给客户端,避免客户端二次请求。
- Node 特有 API 隔离:
fs/process.env 只在服务端用,打包时做 webpack 区分(server bundle / client bundle)。
- CSS 与图片:样式在 SSR 时内联注入,图片用绝对 URL(CDN)。
- 内存与流式:服务端渲染长页面用
streaming(Next.js 的 Suspense 流式渲染),避免长 TTFB 与内存峰值。
六、案例解析:电商列表页 CSR → SSR 改造(首屏 +42%)
背景
一个商品列表页(PC 端),CSR 架构。用户反馈"打开一片白,等 3 秒才见内容";SEO 完全拿不到商品内容,自然流量接近零。
诊断(用 Performance 面板看关键指标)
- LCP 4.2s(超 2.5s 阈值)—— 白屏来自 JS 下载 + 执行 + 数据请求串行
- TTI 5.1s —— 页面长时间不可点
- Google 抓取为空 —— 无 SSR 无内容
改造方案(Next.js App Router)
- 列表页改为服务端组件,数据在服务端 fetch(
revalidate: 60 走 ISR,60 秒内直接吐缓存)
- 分页交互部分(排序/筛选)拆成
'use client' 小组件,挂到服务端渲染的静态列表上
- 图片全部
next/image(自动懒加载 + CDN 尺寸裁剪)
- 页头加
loading.tsx 骨架屏流式输出
结果与复盘
- LCP 4.2s → 2.1s(+50%);TTI 5.1s → 2.8s
- SEO:商品内容进入搜索引擎索引,自然流量 3 个月 +180%
- 代价:新增 Node 服务与缓存层,服务器成本 +30%;教训:ISR 的 revalidate 时间要根据商品更新频率调,否则后台改价用户 60 秒内看不到
七、坑清单(一线浓缩)
坑 1:水合不匹配 —— 服务端与客户端渲染结果不一致(时间/随机数/浏览器 API),React 报 Hydration failed 并整树重渲染。解法:不一致的部分延迟到 useEffect 或 suppressHydrationWarning。
坑 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 任选)
- 文章列表页服务端渲染,标题/摘要/封面在 HTML 里可见(爬虫可读)
- 文章详情用 ISR(
revalidate: 3600),新增文章 1 小时内生效
- 阅读计数用
'use client' 小组件实现(不与 SSR HTML 冲突)
- 文章内容来自 Markdown 文件或 mock API
通关标准
- curl 页面返回的 HTML 中含文章正文(证明 SSR 生效)
- 浏览器无 Hydration 报错
- Lighthouse SEO 评分 ≥ 90
- 能口头讲清:CSR/SSR/SSG/ISR 各自何时用、水合是什么
九、通关自检题
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 缓存命中率。