渲染模式的选择常被简化成"要不要 SEO",这是把架构决策降级成单一指标。真正的差别在三个位置:HTML 何时生成、JS 在谁的机器上执行、数据在哪一侧取。这三件事一定下来,就同时决定了首屏指标、服务端成本、缓存可行性与发布流程。下面按"差别 → 代价 → 缓存 → 迁移"给判断结构;框架细节按 Next.js 14/15、Astro 4/5 通用语义,开关按所部署版本核对。
1. 三类模式的本质差别
| 模式 | HTML 生成时机 | 执行 JS 的位置 | 数据在哪取 |
|---|---|---|---|
| SSR | 每次请求(或缓存命中时) | 服务端渲染 + 客户端水合 | 请求期,服务端 |
| 静态生成 | 构建期(或增量再生成) | 客户端水合 | 构建期 |
| 客户端渲染 | 浏览器内由 JS 生成 | 全部在客户端 | 请求期,客户端 |
差别不在"有没有 HTML",而在首字节里有什么:SSR 与静态生成含内容,客户端渲染通常只是空壳加脚本。
2. 首屏指标与模式的对应
| 指标 | SSR | 静态生成 | 客户端渲染 | 关键影响 |
|---|---|---|---|---|
| TTFB | 受服务端渲染耗时影响 | 最低(可直接 CDN 命中) | 低(壳很快) | 服务端逻辑与缓存命中率 |
| FCP | 快 | 最快 | 慢,等 JS 下载执行 | 首包大小 |
| LCP | 取决于数据就绪时间 | 好 | 常被 JS 阻塞 | 数据取用位置 |
| TTI / INP | 受水合成本影响 | 受水合成本影响 | 受整包执行影响 | 水合范围与拆包 |
常见误判:TTFB 好看不代表体验好。
静态页 TTFB 很低,但若水合包过大,INP 会很差;
反过来 SSR 页 TTFB 高,却可能因为内容已就位而 LCP 更早。
指标要成对看:TTFB 配 LCP,FCP 配 INP。单看一项,容易选出在另一项上崩掉的方案。
3. SSR 的代价
| 代价 | 机制 | 缓解手段 |
|---|---|---|
| 服务端负载 | 每次请求都要执行渲染与数据请求 | 页面级缓存、增量再生 |
| 缓存难度 | 内容随用户/参数变化,命中率低 | 按 URL + 少量维度做键 |
| 状态序列化 | 服务端数据要注入客户端,体积与安全都要管 | 只传必要字段,避免整对象透传 |
| 部署复杂度 | 需要常驻进程、健康检查、扩容 | 边缘/无服务运行时或容器化 |
// 页面级缓存响应头:给 CDN 与浏览器不同的 TTL
res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate=300');
stale-while-revalidate 是 SSR 场景性价比最高的一条:过期后先返回旧内容,后台再生成,把渲染耗时从用户等待里挪走。代价是内容有一个再生成窗口的滞后,这个窗口要写进业务预期。
适合 SSR 的页面:个性化强、实时性要求高、首屏重要的页面(用户中心、商品详情、搜索结果)。
4. 静态生成的适用边界
| 条件 | 适合静态生成 | 不适合 |
|---|---|---|
| 内容更新频率 | 分钟级以上 | 秒级实时 |
| 页面总量 | 构建时间可接受 | 数十万页全量构建 |
| 个性化 | 无或可按 URL 参数切分 | 依赖登录态 |
| 数据源稳定性 | 构建期可取到 | 只能请求期取 |
// 增量再生成:构建期生成 + 到期后台重建(按版本核对 API 名)
export const revalidate = 3600;
export async function generateStaticParams() {
const posts = await listPublishedSlugs();
return posts.map((s) => ({ slug: s }));
}
静态生成的隐性成本是构建时间随页面数线性增长。上千页后全量构建从分钟级走向十几分钟,发布节奏被构建时长锁死。此时要的是增量构建 + 按需失效,而不是加构建机器。
5. 客户端渲染的合理性
后台系统、强交互页面、登录后才有意义的界面,客户端渲染是正解:数据本来就在客户端上下文里,服务端渲染只是多一次往返。
判断清单(满足多数则选客户端渲染):
1. 页面不在索引范围内
2. 首屏之外的操作占比高(表单、表格、编辑器)
3. 数据依赖客户端状态(token、本地缓存)
4. 无 SEO 与分享卡片需求
"SEO 与首屏"的真实影响面:对外内容页大,对内系统近乎为零。为内部后台引入 SSR,换来常驻服务、部署复杂度与一类新故障(渲染进程挂掉整站白屏),收益接近零。
6. 混合渲染的成本收益
| 方案 | 收益 | 成本 |
|---|---|---|
| 岛屿架构(部分水合) | 交互组件才加载 JS,首包显著变小 | 组件边界要重新划分,跨岛状态传递受限 |
| 按路由分流 | 对外页静态、后台页 CSR | 两套构建与部署路径并存 |
| 流式渲染 + Suspense | 首字节更早,慢区块后到 | 需要服务端支持分块传输,错误处理更复杂 |
---
// 岛屿:只有这个组件在客户端水合
---
<SearchBox client:visible />
<StaticArticle />
// 流式:先送出外壳,慢区块后补
const shell = <Layout><Header /></Layout>;
return new Response(stream, { headers: { 'Content-Type': 'text/html' } });
混合渲染的前提是**"静态大头 + 少量交互"的比例足够高**。每个区块都要交互时,岛屿划分只是把复杂度摊到组件边界,收益接近零。
7. 缓存四层与失效传播
| 层 | 缓存什么 | 失效方式 | 风险 |
|---|---|---|---|
| CDN | 完整 HTML/静态资源 | TTL、按路径清除 | 清除延迟、命中率低 |
| 服务端 | 渲染结果、数据片段 | TTL、主动失效 | 键设计错导致串数据 |
| 数据层 | 查询结果 | 写后失效 | 失效与写不在一个事务里 |
| 客户端 | 内存/本地存储 | 版本号、TTL | 旧版本 UI 与新接口不兼容 |
# 静态资源长缓存 + 指纹;HTML 短缓存
location ~* \.(js|css|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
add_header Cache-Control "public, max-age=0, s-maxage=60";
}
失效传播必须从内到外:数据层 → 服务端 → CDN。反过来做,外层会在内层更新后重新缓存住旧内容,形成"清了又回来"的循环。
8. 数据获取位置与一致性风险
| 位置 | 延迟 | 一致性风险 | 适用 |
|---|---|---|---|
| 构建期 | 最低 | 内容滞后一个构建周期 | 低频变更内容 |
| 请求期(服务端) | 中 | 与源数据近实时,但受缓存影响 | 详情页、列表首页 |
| 客户端 | 高(多一次往返) | 可能出现"页面已渲染、数据未到" | 个性化、实时交互 |
// 同一页面里的三种取数要显式标注,避免"看起来一致"
const cfg = await fetchAtBuild('/api/site-config'); // 构建期
const list = await fetchPerRequest('/api/posts'); // 请求期
const mine = await fetchOnClient('/api/me'); // 客户端
一致性风险集中在同一屏混用三种取数:价格来自构建期、库存来自请求期,不同步就是"页面自相矛盾"。这不是渲染模式的错,是取数位置没按数据敏感度分层。
9. 多页与单页的取舍
| 维度 | 多页(路由级页面) | 单页(长驻应用) |
|---|---|---|
| 路由 | 服务端路由,天然可分享 | 前端路由,需额外处理深链 |
| 状态 | 页面级,刷新即重置 | 全局常驻,易泄漏 |
| 分享链接 | 直接可用 | 依赖路由实现质量 |
| 内存 | 随导航释放 | 长期增长 |
| 首屏 | 每页独立,可控 | 首包偏大 |
选择口径:内容型站点走多页,工具型应用走单页,
混合站点按"入口页多页 + 应用区内单页"切分
单页的内存问题在长时间使用后显现:路由切换不释放的监听、缓存、闭包会持续累积。这是"应用不重启"这个前提的必然代价,不是框架缺陷。
10. 构建产物、可观测与迁移
# 产物核对:指纹、首包体积、预加载清单
ls dist/_astro/*.js | head
du -sh dist/client dist/server
| 观测口径 | 优点 | 局限 |
|---|---|---|
| 实验室指标(Lighthouse) | 可复现、可比对 | 与真实网络/设备差距大 |
| 真实用户指标(RUM) | 反映真实分布 | 需要采样,长尾要足够样本 |
| 服务端渲染耗时 | 直接反映 SSR 成本 | 不含水合 |
// RUM 采样:低采样率下按页面类型分层,避免长尾页被平均掉
new PerformanceObserver((l) => {
for (const e of l.getEntries()) report('lcp', e.startTime, { route });
}).observe({ type: 'largest-contentful-paint', buffered: true });
迁移按风险递增:先静态化首屏 → 按路由分流(对外静态、后台 CSR) → 统一缓存与失效策略。一次全站切 SSR 的问题不在可行性,而在把首屏、缓存、部署三件事同时变成未知数,出问题无法定位层级。
| 迁移阶段 | 验证口径 |
|---|---|
| 静态化首屏 | LCP 与 TTFB 对比基线 |
| 按路由分流 | 各路由的错误率与渲染耗时 |
| 统一缓存 | CDN 命中率与失效延迟 |
11. 排错:五类交付异常
| 现象 | 原因 | 处理 |
|---|---|---|
| 首屏白屏时间长 | 首包过大、水合阻塞 | 拆包、减少水合范围 |
| 内容与接口不一致 | 取数位置混用、缓存键缺维度 | 显式分层取数,补缓存键 |
| 清缓存后仍旧内容 | 失效顺序从外到内 | 改为数据 → 服务端 → CDN |
| 部署后部分用户报错 | 静态资源与新接口版本不匹配 | 资源指纹 + 接口版本兼容窗口 |
| SSR 页面偶尔 500 | 渲染期取数失败未兜底 | 渲染降级为骨架 + 客户端补数据 |
最后一条最易忽略:SSR 把一次局部数据失败升级成整页 500。渲染路径必须有降级,否则可用性低于纯客户端方案。
12. 边界:这些情况不值得上 SSR
| 场景 | 判断 | 理由 |
|---|---|---|
| 纯内部系统 | 客户端渲染 | 无 SEO 需求,SSR 只增加部署面 |
| 极低频访问页面 | 静态生成或按需生成 | 常驻渲染成本换不到收益 |
| 内容几乎不变 | 纯静态 | 每次请求渲染是浪费 |
| 强实时个性化 + 高并发 | 壳静态 + 客户端取数 | 服务端渲染成本随并发线性上升 |
| 团队无服务端运维能力 | 静态优先 | SSR 引入常驻进程与一类新故障 |
13. 自测
- 三类渲染模式的本质差别,用哪三个位置可以一次说清?
- 为什么 TTFB 低不代表体验好?该和哪个指标配对看?
stale-while-revalidate把哪部分耗时挪走了,代价是什么?- 静态生成的隐性成本为什么随页面数增长?
- 缓存失效为什么要从内到外,反过来会出现什么现象?
- 迁移到 SSR 时,为什么建议分三段推进而不是一次切换?