同一份数据、同一套组件、同一个页面,分别在构建、请求和浏览器阶段生成,性能结果会差在哪里?
我用 Next.js 16 做了三条渲染路径,每条执行 5 次冷导航。结果里最值得注意的不是谁的 LCP 最低,而是客户端路线 LCP 很早、完整内容却最晚就绪。这个反差正好解释了为什么"上 SSR"不能代替性能诊断。
如果不先拆开这些变量,"用了 SSR"只是架构描述,不是性能结论。
先澄清:Server Component 不等于每次请求 SSR
在当前 Next.js App Router 中,页面和布局默认是 Server Components。官方文档说明,它们可以在服务端取数、缓存结果并流式发送;需要状态、事件、生命周期或浏览器 API 时,再用 Client Components 增加交互。
但"组件在服务端执行"并不表示"页面每次请求都重新生成"。
同一个 Server Component 路由可能在构建时预渲染,也可能使用缓存结果,还可能等到请求到来后动态渲染。Next.js 16 的 Cache Components 还允许在同一路由内组合静态壳、缓存内容和由 Suspense 延后到请求时渲染的动态片段。
所以讨论性能前,至少要把三个概念分开:
| 概念 | 它回答的问题 |
|---|---|
| Server / Client Component | 代码主要在哪个环境执行、哪些部分需要客户端 JavaScript |
| 静态 / 请求时 / 客户端渲染 | 完整内容在构建、请求还是浏览器阶段生成 |
| 缓存与再验证 | 已生成的数据或输出可以复用多久、何时失效 |
把三者混成一句"上 SSR",很容易优化错位置。
我做了一个只改变渲染位置的对照实验
为了观察渲染位置本身的影响,我用 Next.js 16.2.12 做了三条路线:
- 静态生成:构建时等待同一份固定数据,并生成完整 HTML;
- 请求时渲染:每次请求到来后,在服务器等待数据并生成完整 HTML;
- 客户端渲染:先返回静态页面壳体,浏览器再请求数据并生成完整内容。
三条路线使用相同的数据、DOM、组件、样式和 180 ms 模拟数据延迟。差别只有等待发生在构建、请求还是浏览器阶段。
静态路线没有使用特殊静态 API,构建器可以把它预渲染:
tsx
export default async function StaticPage() {
const data = await getExperimentData();
return <ExperimentView data={data} mode="static" />;
}
请求时路线使用 connection() 明确等待真实请求。Next.js 官方文档将它定义为一种排除构建时预渲染、转入运行时动态渲染的方式:
tsx
import { connection } from "next/server";
export default async function DynamicPage() {
await connection();
const data = await getExperimentData();
return <ExperimentView data={data} mode="dynamic" />;
}
客户端路线先显示加载状态,再在 useEffect 中请求同一份数据:
tsx
"use client";
export function ClientExperiment() {
const [data, setData] = useState<ExperimentData | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch("/api/experiment-data", { signal: controller.signal })
.then((response) => response.json())
.then(setData);
return () => controller.abort();
}, []);
return data
? <ExperimentView data={data} mode="client" />
: <p>页面壳体已经到达,内容准备中。</p>;
}
实验使用生产构建,不测开发服务器。每种策略运行 5 次冷导航,每次使用独立浏览器上下文,取中位数。LCP 通过页面可见性仿真取得,因此只在同一批次、同一浏览器条件内比较。
结果:三个"快"不是一回事
| 指标 | 静态生成 | 请求时渲染 | 客户端渲染 |
|---|---|---|---|
| TTFB | 7 ms | 197.2 ms | 10.7 ms |
| LCP | 120 ms | 288 ms | 108 ms |
| 完整内容就绪 | 18.7 ms | 205.7 ms | 364.1 ms |
| 导航传输 | 5,287 B | 6,925 B | 3,129 B |
| 客户端脚本 | 224,110 B | 224,110 B | 224,110 B |
| 数据接口请求 | 0 | 0 | 1 |
这组数字最有价值的地方,不是谁赢了,而是它暴露了三条不同的等待链。
1. 请求时渲染把数据等待放进了 TTFB
动态路线的固定数据延迟是 180 ms,中位 TTFB 为 197.2 ms。服务器必须先拿到数据,才能返回完整响应。
这不表示 SSR 一定慢。它表示:当完整页面依赖请求时数据时,数据源延迟、服务器渲染和排队成本会成为 TTFB 的一部分。换成更快的数据源、可共享缓存或流式边界,结果都会改变。
2. 客户端壳体早到,不代表完整内容早到
客户端路线 TTFB 只有 10.7 ms,LCP 甚至是三者中最低的 108 ms,但完整内容到 364.1 ms 才准备好。
原因不是"LCP 无用",而是 LCP 只记录视口中最大的内容绘制。这个页面的早期壳体已经产生了候选元素,后续业务表格没有形成更晚、更大的 LCP 候选。
因此,工具型页面不能只看 LCP。还需要记录与用户任务相关的指标,例如:
- 核心内容就绪;
- 可操作时间;
- 首次有效数据;
- 搜索结果出现;
- 表单可以提交;
- 关键业务成功事件。
3. 静态生成把同一份等待移到了发布阶段
静态路线同样执行了 180 ms 数据等待,但它发生在构建阶段,不在用户请求链路中,所以运行时 TTFB 和内容就绪都更早。
这正是内容可预先确定时静态生成的优势,但代价也很明确:数据新鲜度、构建规模和失效机制要由发布系统承担。
如果页面依赖用户权限、Cookie、实时库存或强个性化数据,就不能为了这组实验数字强行静态化。
4. 不要从架构名字推断 JavaScript 体积
本轮三条路线的客户端脚本传输量完全相同,都是 224,110 B。
这是一个需要保留的反例:客户端渲染经常会带来更多客户端工作,但不能仅凭"CSR"三个字就断言本项目的脚本一定更大。组件边界、依赖和构建产物才是证据。
真正可执行的选择顺序
选择渲染策略时,可以先问四个问题。
第一问:内容能否在请求前确定?
- 能:优先静态生成;
- 能短暂陈旧:考虑缓存与时间或事件驱动再验证;
- 不能:进入请求时动态渲染。
第二问:是否依赖请求上下文?
Cookie、Header、权限、地域、A/B 分组和实时数据通常会限制共享缓存。需要先定义缓存键和隐私边界,再决定是否动态渲染,而不是先上 SSR 再补缓存。
第三问:慢数据能否缩小到一个组件边界?
如果只有推荐、评论或库存较慢,可以保留静态或缓存壳体,把慢片段放进 Suspense 流式返回。Next.js 16 的 Cache Components 就是围绕"静态、缓存和动态内容共存"设计的。
不要因为页面里一个按钮需要状态,就把整棵组件树都标成客户端组件;也不要因为一个动态区块,就让整页每次请求重新生成。
第四问:你实际要改善哪个指标?
- TTFB 慢:看数据源、服务器计算、缓存命中、边缘与回源;
- LCP 慢:继续拆 TTFB、资源发现、下载和渲染延迟;
- INP 慢:看客户端 JavaScript、长任务和渲染范围;
- 内容就绪慢:看串行数据请求、hydration 和业务数据链;
- 服务成本高:看请求时渲染次数、缓存复用和容量。
一个改动必须能说明它改变了哪一段因果链。
一份发布前检查表
在把某个路由改成 SSR、静态或客户端渲染前,至少确认:
- 完整内容是在构建、请求还是浏览器阶段生成;
- 页面与数据缓存是否分开定义了复用和失效;
- 个性化输出是否可能误入共享缓存;
- 慢数据是否能缩小到 Suspense 或组件边界;
- Client Component 是否只覆盖真正需要交互的子树;
- 除 LCP 外,是否记录了核心内容就绪或业务成功指标;
- 实验室数据是否与真实用户监控分开解释;
- 发布后能否按路由、设备、网络和版本观察回归。
SSR 的价值是真实存在的:它可以让关键 HTML 更早出现、改善无 JavaScript 时的可读性,并帮助部分抓取器获取内容。但它同时可能增加 TTFB、服务器容量和 hydration 成本。
更可靠的结论不是"SSR 更快"或"CSR 更快",而是:
先确定内容在哪个阶段生成,再测量等待落在哪条链路上;渲染策略是手段,用户任务完成时间才是结果。
事实、验证与边界说明
- 官方事实 :Next.js App Router 的页面和布局默认是 Server Components;交互与浏览器 API 需要 Client Components;
connection()可使后续渲染等待请求并排除预渲染;Cache Components 可以在同一路由组合静态、缓存与动态内容。Core Web Vitals 当前稳定指标为 LCP、INP、CLS,实验室数据不能替代真实用户数据。 - 个人已实践:本文三条路线、生产构建、15 个隔离浏览器上下文、5 次冷导航中位数、构建分类和页面行为均已在本地实验中执行并保留原始证据。
- 工程归纳:四问决策链、指标因果拆解和检查表是基于官方文档与本地实验形成的工程建议,不是 Next.js 官方固定架构。
- 未验证推断:本文没有公网 CDN、真实用户设备、搜索索引、服务器容量或生产成本数据,不声称任何渲染策略会在所有项目中固定领先。
- 版本边界:代码与结论以 Next.js 16.2.12 实验和 2026-07-28 可访问的官方文档为基线;使用其他版本时应重新核对缓存与渲染语义。